diff --git a/content/docs/deployment/marketplace-handbook.mdx b/content/docs/deployment/marketplace-handbook.mdx index 0eeaf8d2615..ea81abfe618 100644 --- a/content/docs/deployment/marketplace-handbook.mdx +++ b/content/docs/deployment/marketplace-handbook.mdx @@ -42,6 +42,8 @@ the declared keys below are the ones publish actually sends. | `readmePath` | A markdown file, relative to the manifest, read at publish time | `--readme-file`, `--readme` | | `translations` | Per-locale `displayName`, `description`, `tagline`, `readme` and `screenshotCaptions` | — | +An official ObjectStack listing's `manifestId` is `app.objectstack.` (`app.objectstack.hotcrm`, `app.objectstack.connector-github`); a partner listing uses its own reverse domain, `app..`. + [HotCRM](https://github.com/objectstack-ai/hotcrm/blob/main/objectstack.manifest.json) is a worked example for the keys it carries: diff --git a/packages/apps/connector-stripe/README.md b/packages/apps/connector-stripe/README.md new file mode 100644 index 00000000000..3562f0a6a07 --- /dev/null +++ b/packages/apps/connector-stripe/README.md @@ -0,0 +1,112 @@ +# Stripe + +Read Stripe customers, charges, invoices and payment intents from your flows. +The app calls the Stripe API (`https://api.stripe.com/`) through a connector +built from Stripe's own OpenAPI document, and exposes eight read operations as +connector actions. Nothing is written to Stripe. + +## What it adds + +- **A connector, `stripe`.** Its actions are Stripe's own operation ids, a + list and a retrieve for each of four resources: + + | Resource | List | Retrieve | + |:---|:---|:---| + | Customers | `GetCustomers` (`GET /v1/customers`) | `GetCustomersCustomer` (`GET /v1/customers/{customer}`) | + | Charges | `GetCharges` (`GET /v1/charges`) | `GetChargesCharge` (`GET /v1/charges/{charge}`) | + | Invoices | `GetInvoices` (`GET /v1/invoices`) | `GetInvoicesInvoice` (`GET /v1/invoices/{invoice}`) | + | Payment intents | `GetPaymentIntents` (`GET /v1/payment_intents`) | `GetPaymentIntentsIntent` (`GET /v1/payment_intents/{intent}`) | + + No other Stripe operation is in the package, so none can reach your flows + or agents. +- **Two flows that use it**, ready to run or to copy: + - `stripe_get_customer` takes a `customer_id` (for example `cus_123`) and + returns Stripe's customer object as `customer.body`. + - `stripe_list_open_invoices` takes a `customer_id` and returns that + customer's open invoices, at most ten, as `invoices.body` (the invoices + are in `invoices.body.data`). + +The app adds no objects, no sample data and no demo users. An install adds the +connector and the two flows, and nothing else. + +## Credentials + +The app needs one credential. Until it is set, the `stripe` connector is listed +as disconnected and its flows fail, naming the credential. + +| Credential | What it is | Where to get it | Permissions it needs | +|:---|:---|:---|:---| +| `STRIPE_RESTRICTED_KEY` | A Stripe restricted API key (it starts with `rk_live_`, or `rk_test_` in a sandbox), sent to Stripe as `Authorization: Bearer ` | Stripe Dashboard → **Developers → API keys** () → create a restricted key | **Read** on Customers, Charges, Invoices and PaymentIntents, and no other permission | + +Use a restricted key, not your secret key (`sk_…`): a restricted key carries +only the permissions you give it, and this app only reads. A request for a +resource the key has no permission for is refused by Stripe with HTTP 403. The +step does not fail on it: the answer lands as `.status` 403, +`.ok` false and Stripe's error in `.body`, so a flow that +must stop on it checks `ok`. + +Set the value in **Setup → Integrations → Connector Credentials**, or in the +install dialog right after installing. An environment variable named +`STRIPE_RESTRICTED_KEY` on the server takes precedence over a stored value. +Try the app with a sandbox key first; a live key reads your live account. + +The key is only ever sent to Stripe. It is never stored in the app's metadata. + +## Running it + +```bash +curl -X POST "$OS_URL/api/v1/automation/stripe_get_customer/trigger" \ + -H "Authorization: Bearer $SESSION_TOKEN" -H 'Content-Type: application/json' \ + -d '{"params": {"customer_id": "cus_123"}}' +``` + +To use an answer elsewhere, add a `connector_action` node with +`connectorId: 'stripe'` to your own flow and read `.body` in a later +node. An action takes its path parameters as one `path` object and its query +parameters as one `query` object, for example +`query: { dialect: 'cel', source: '{"customer": record.stripe_customer_id, "limit": dyn(10)}' }` +(a CEL map's values share one type, so a literal beside a field read is +wrapped in `dyn(…)`). + +## Limits + +- Read only. The package carries Stripe's GET operations for four resources + and nothing else; Stripe's write operations take form-encoded bodies, which + this connector does not send. +- Query parameters are sent as plain `name=value` pairs. Stripe's bracketed + parameters, such as `expand[]` and the `created[gte]` range filters, are + not sent in Stripe's form, so leave them out. +- The response schemas in the packaged document are not expanded (see + Provenance): each response is described as its Stripe object type, without + its fields. The answer itself is Stripe's full JSON. + +## Provenance + +`openapi/stripe.openapi.json` is generated, not written by hand. It is +Stripe's OpenAPI document, `stripe/openapi` `openapi/spec3.json` at commit +`51610f5f061c9723730582a7638b8cc0e646640d` (OpenAPI 3.0.0, API version +`2026-09-30.endive`, 612 operations), trimmed to the eight operations above by +this repository's offline normalizer: + +```bash +pnpm --filter @objectstack/connector-openapi normalize-openapi \ + --in https://raw.githubusercontent.com/stripe/openapi/51610f5f061c9723730582a7638b8cc0e646640d/openapi/spec3.json \ + --include-pattern '^Get(Customers(Customer)?|Charges(Charge)?|Invoices(Invoice)?|PaymentIntents(Intent)?)$' \ + --trim --max-depth 0 \ + --out packages/apps/connector-stripe/openapi/stripe.openapi.json +``` + +It printed 47,094 bytes, 8 operations trimmed from 612, 17 schema references +cut at `--max-depth 0` and none left, and sha256 +`3aec4b32c8cda2adeb5abb8393f02c63ecaf02ce78ca8bacd697f20edb4b2ca5`, the hash +of the committed file. The package's tests recompute that hash and fail when +the file and this record disagree. + +`--max-depth 0` keeps every parameter schema as Stripe wrote it, since those +are what a flow's input is checked against, and replaces each named response +schema with its type, title and description. At the default depth of 2 the +same eight operations write 2,574,259 bytes. + +Re-normalize from a newer commit by running the command with that commit, +then update the commit and the sha256 here in the same change. Stripe's +document is MIT-licensed; its licence notice is `openapi/LICENSE`. diff --git a/packages/apps/connector-stripe/objectstack.config.ts b/packages/apps/connector-stripe/objectstack.config.ts new file mode 100644 index 00000000000..00704905a15 --- /dev/null +++ b/packages/apps/connector-stripe/objectstack.config.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineStack } from '@objectstack/spec'; +import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; +import { StripeConnector } from './src/connectors/stripe.connector.js'; +import { allFlows } from './src/flows/stripe.flows.js'; + +/** + * Stripe connector app (read only) — open edition (every key it uses runs on + * the open tier), so it lives in this repository and is validated by its CI + * against the runtime at HEAD. One declarative connector over Stripe's own + * OpenAPI document, two flows that use it; no objects, no secret. The listing + * is `objectstack.manifest.json`, and the README is what the administrator + * reads before installing. + */ +export default defineStack({ + manifest: { + id: 'app.objectstack.connector-stripe', + namespace: 'stripe', + version: '0.1.0', + type: 'app', + name: 'Stripe', + description: 'Read Stripe customers, charges, invoices and payment intents from flows, over Stripe\'s OpenAPI document.', + engines: { protocol: '^18' }, + }, + + // `automation` materializes the `connectors:` entry at boot and runs the + // flows; the executor below registers the `openapi` provider with it. + requires: ['automation'], + + // The generic `openapi` executor, zero-arg: it contributes the provider + // factory only, which reads the packaged document through the host's + // package-file capability (confined to this package's root). + plugins: [new ConnectorOpenApiPlugin()], + + connectors: [StripeConnector], + flows: allFlows, +}); diff --git a/packages/apps/connector-stripe/objectstack.manifest.json b/packages/apps/connector-stripe/objectstack.manifest.json new file mode 100644 index 00000000000..b027a71df63 --- /dev/null +++ b/packages/apps/connector-stripe/objectstack.manifest.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://schemas.objectstack.dev/template-manifest.json", + "manifestId": "app.objectstack.connector-stripe", + "name": "connector-stripe", + "specVersion": "^18.0.0", + "displayName": "Stripe", + "description": "Read Stripe customers, charges, invoices and payment intents from flows, over Stripe's own OpenAPI document. Read only.", + "category": "finance", + "tags": ["stripe", "openapi", "connector", "payments"], + "license": "Apache-2.0", + "homepageUrl": "https://github.com/objectstack-ai/objectstack/tree/main/packages/apps/connector-stripe", + "readmePath": "README.md" +} diff --git a/packages/apps/connector-stripe/openapi/LICENSE b/packages/apps/connector-stripe/openapi/LICENSE new file mode 100644 index 00000000000..edf2d132d8b --- /dev/null +++ b/packages/apps/connector-stripe/openapi/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2011- Stripe, Inc. (https://stripe.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/apps/connector-stripe/openapi/stripe.openapi.json b/packages/apps/connector-stripe/openapi/stripe.openapi.json new file mode 100644 index 00000000000..1b57dc575ed --- /dev/null +++ b/packages/apps/connector-stripe/openapi/stripe.openapi.json @@ -0,0 +1,1176 @@ +{ + "components": { + "securitySchemes": { + "basicAuth": { + "description": "Basic HTTP authentication. Allowed headers-- Authorization: Basic | Authorization: Basic ", + "scheme": "basic", + "type": "http" + }, + "bearerAuth": { + "bearerFormat": "auth-scheme", + "description": "Bearer HTTP authentication. Allowed headers-- Authorization: Bearer ", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "contact": { + "email": "dev-platform@stripe.com", + "name": "Stripe Dev Platform Team", + "url": "https://stripe.com" + }, + "description": "The Stripe REST API. Please see https://stripe.com/docs/api for more details.", + "termsOfService": "https://stripe.com/us/terms/", + "title": "Stripe API", + "version": "2026-09-30.endive", + "x-stripeSpecFilename": "spec3" + }, + "openapi": "3.0.0", + "paths": { + "/v1/charges": { + "get": { + "description": "

Returns a list of charges you’ve previously created. The charges are returned in sorted order, with the most recent charges appearing first.

", + "operationId": "GetCharges", + "parameters": [ + { + "description": "Only return charges that were created during the given date interval.", + "explode": true, + "in": "query", + "name": "created", + "required": false, + "schema": { + "anyOf": [ + { + "properties": { + "gt": { + "type": "integer" + }, + "gte": { + "type": "integer" + }, + "lt": { + "type": "integer" + }, + "lte": { + "type": "integer" + } + }, + "title": "range_query_specs", + "type": "object" + }, + { + "type": "integer" + } + ] + }, + "style": "deepObject" + }, + { + "description": "Only return charges for the customer specified by this customer ID.", + "in": "query", + "name": "customer", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + }, + { + "description": "A cursor for use in pagination. `ending_before` is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, starting with `obj_bar`, your subsequent call can include `ending_before=obj_bar` in order to fetch the previous page of the list.", + "in": "query", + "name": "ending_before", + "required": false, + "schema": { + "type": "string" + }, + "style": "form" + }, + { + "description": "Specifies which fields in the response should be expanded.", + "explode": true, + "in": "query", + "name": "expand", + "required": false, + "schema": { + "items": { + "maxLength": 5000, + "type": "string" + }, + "type": "array" + }, + "style": "deepObject" + }, + { + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 10.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + }, + "style": "form" + }, + { + "description": "Only return charges that were created by the PaymentIntent specified by this PaymentIntent ID.", + "in": "query", + "name": "payment_intent", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + }, + { + "description": "A cursor for use in pagination. `starting_after` is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, ending with `obj_foo`, your subsequent call can include `starting_after=obj_foo` in order to fetch the next page of the list.", + "in": "query", + "name": "starting_after", + "required": false, + "schema": { + "type": "string" + }, + "style": "form" + }, + { + "description": "Only return charges for this transfer group, limited to 100.", + "in": "query", + "name": "transfer_group", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "encoding": {}, + "schema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "description": "", + "properties": { + "data": { + "items": { + "type": "object", + "title": "Charge", + "description": "The `Charge` object represents a single attempt to move money into your Stripe account.\nPaymentIntent confirmation is the most common way to create Charges, but [Account Debits](https://docs.stripe.com/connect/account-debits) may also create Charges.\nThe create and capture methods are deprecated and will be deleted soon. If your integration uses either of them, you need to update it to use a different payment flow, such as [the Payment Intents API](https://docs.stripe.com/payments/payment-intents).\n\n(Not expanded by normalize-openapi: '#/components/schemas/charge', deeper than --max-depth 0.)" + }, + "type": "array" + }, + "has_more": { + "description": "True if this list has another page of items after this one that can be fetched.", + "type": "boolean" + }, + "object": { + "description": "String representing the object's type. Objects of the same type share the same value. Always has the value `list`.", + "enum": [ + "list" + ], + "type": "string" + }, + "url": { + "description": "The URL where this list can be accessed.", + "maxLength": 5000, + "pattern": "^/v1/charges", + "type": "string" + } + }, + "required": [ + "data", + "has_more", + "object", + "url" + ], + "title": "ChargeList", + "type": "object", + "x-expandableFields": [ + "data" + ] + } + } + }, + "description": "Successful response." + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "An error response from the Stripe API\n\n(Not expanded by normalize-openapi: '#/components/schemas/error', deeper than --max-depth 0.)" + } + } + }, + "description": "Error response." + } + }, + "summary": "List all charges" + } + }, + "/v1/charges/{charge}": { + "get": { + "description": "

Retrieves the details of a charge that has previously been created. Supply the unique charge ID that was returned from your previous request, and Stripe will return the corresponding charge information. The same information is returned when creating or refunding the charge.

", + "operationId": "GetChargesCharge", + "parameters": [ + { + "in": "path", + "name": "charge", + "required": true, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "simple" + }, + { + "description": "Specifies which fields in the response should be expanded.", + "explode": true, + "in": "query", + "name": "expand", + "required": false, + "schema": { + "items": { + "maxLength": 5000, + "type": "string" + }, + "type": "array" + }, + "style": "deepObject" + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "encoding": {}, + "schema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Charge", + "description": "The `Charge` object represents a single attempt to move money into your Stripe account.\nPaymentIntent confirmation is the most common way to create Charges, but [Account Debits](https://docs.stripe.com/connect/account-debits) may also create Charges.\nThe create and capture methods are deprecated and will be deleted soon. If your integration uses either of them, you need to update it to use a different payment flow, such as [the Payment Intents API](https://docs.stripe.com/payments/payment-intents).\n\n(Not expanded by normalize-openapi: '#/components/schemas/charge', deeper than --max-depth 0.)" + } + } + }, + "description": "Successful response." + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "An error response from the Stripe API\n\n(Not expanded by normalize-openapi: '#/components/schemas/error', deeper than --max-depth 0.)" + } + } + }, + "description": "Error response." + } + }, + "summary": "Retrieve a charge" + } + }, + "/v1/customers": { + "get": { + "description": "

Returns a list of your customers. The customers are returned sorted by creation date, with the most recent customers appearing first.

", + "operationId": "GetCustomers", + "parameters": [ + { + "description": "Only return customers that were created during the given date interval.", + "explode": true, + "in": "query", + "name": "created", + "required": false, + "schema": { + "anyOf": [ + { + "properties": { + "gt": { + "type": "integer" + }, + "gte": { + "type": "integer" + }, + "lt": { + "type": "integer" + }, + "lte": { + "type": "integer" + } + }, + "title": "range_query_specs", + "type": "object" + }, + { + "type": "integer" + } + ] + }, + "style": "deepObject" + }, + { + "description": "A case-sensitive filter on the list based on the customer's `email` field. The value must be a string.", + "in": "query", + "name": "email", + "required": false, + "schema": { + "maxLength": 512, + "type": "string" + }, + "style": "form" + }, + { + "description": "A cursor for use in pagination. `ending_before` is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, starting with `obj_bar`, your subsequent call can include `ending_before=obj_bar` in order to fetch the previous page of the list.", + "in": "query", + "name": "ending_before", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + }, + { + "description": "Specifies which fields in the response should be expanded.", + "explode": true, + "in": "query", + "name": "expand", + "required": false, + "schema": { + "items": { + "maxLength": 5000, + "type": "string" + }, + "type": "array" + }, + "style": "deepObject" + }, + { + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 10.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + }, + "style": "form" + }, + { + "description": "A cursor for use in pagination. `starting_after` is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, ending with `obj_foo`, your subsequent call can include `starting_after=obj_foo` in order to fetch the next page of the list.", + "in": "query", + "name": "starting_after", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + }, + { + "description": "Provides a list of customers that are associated with the specified test clock. The response will not include customers with test clocks if this parameter is not set.", + "in": "query", + "name": "test_clock", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "encoding": {}, + "schema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "description": "", + "properties": { + "data": { + "items": { + "type": "object", + "title": "Customer", + "description": "This object represents a customer of your business. Use it to [create recurring charges](https://docs.stripe.com/invoicing/customer), [save payment](https://docs.stripe.com/payments/save-during-payment) and contact information,\nand track payments that belong to the same customer.\n\n(Not expanded by normalize-openapi: '#/components/schemas/customer', deeper than --max-depth 0.)" + }, + "type": "array" + }, + "has_more": { + "description": "True if this list has another page of items after this one that can be fetched.", + "type": "boolean" + }, + "object": { + "description": "String representing the object's type. Objects of the same type share the same value. Always has the value `list`.", + "enum": [ + "list" + ], + "type": "string" + }, + "url": { + "description": "The URL where this list can be accessed.", + "maxLength": 5000, + "pattern": "^/v1/customers", + "type": "string" + } + }, + "required": [ + "data", + "has_more", + "object", + "url" + ], + "title": "CustomerResourceCustomerList", + "type": "object", + "x-expandableFields": [ + "data" + ] + } + } + }, + "description": "Successful response." + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "An error response from the Stripe API\n\n(Not expanded by normalize-openapi: '#/components/schemas/error', deeper than --max-depth 0.)" + } + } + }, + "description": "Error response." + } + }, + "summary": "List all customers" + } + }, + "/v1/customers/{customer}": { + "get": { + "description": "

Retrieves a Customer object.

", + "operationId": "GetCustomersCustomer", + "parameters": [ + { + "in": "path", + "name": "customer", + "required": true, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "simple" + }, + { + "description": "Specifies which fields in the response should be expanded.", + "explode": true, + "in": "query", + "name": "expand", + "required": false, + "schema": { + "items": { + "maxLength": 5000, + "type": "string" + }, + "type": "array" + }, + "style": "deepObject" + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "encoding": {}, + "schema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "title": "Customer", + "description": "This object represents a customer of your business. Use it to [create recurring charges](https://docs.stripe.com/invoicing/customer), [save payment](https://docs.stripe.com/payments/save-during-payment) and contact information,\nand track payments that belong to the same customer.\n\n(Not expanded by normalize-openapi: '#/components/schemas/customer', deeper than --max-depth 0.)" + }, + { + "type": "object", + "title": "DeletedCustomer", + "description": "(Not expanded by normalize-openapi: '#/components/schemas/deleted_customer', deeper than --max-depth 0.)" + } + ] + } + } + }, + "description": "Successful response." + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "An error response from the Stripe API\n\n(Not expanded by normalize-openapi: '#/components/schemas/error', deeper than --max-depth 0.)" + } + } + }, + "description": "Error response." + } + }, + "summary": "Retrieve a customer" + } + }, + "/v1/invoices": { + "get": { + "description": "

You can list all invoices, or list the invoices for a specific customer. The invoices are returned sorted by creation date, with the most recently created invoices appearing first.

", + "operationId": "GetInvoices", + "parameters": [ + { + "description": "The collection method of the invoice to retrieve. Either `charge_automatically` or `send_invoice`.", + "in": "query", + "name": "collection_method", + "required": false, + "schema": { + "enum": [ + "charge_automatically", + "send_invoice" + ], + "type": "string" + }, + "style": "form" + }, + { + "description": "Only return invoices that were created during the given date interval.", + "explode": true, + "in": "query", + "name": "created", + "required": false, + "schema": { + "anyOf": [ + { + "properties": { + "gt": { + "type": "integer" + }, + "gte": { + "type": "integer" + }, + "lt": { + "type": "integer" + }, + "lte": { + "type": "integer" + } + }, + "title": "range_query_specs", + "type": "object" + }, + { + "type": "integer" + } + ] + }, + "style": "deepObject" + }, + { + "description": "Only return invoices for the customer specified by this customer ID.", + "in": "query", + "name": "customer", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + }, + { + "description": "Only return invoices for the account representing the customer specified by this account ID.", + "in": "query", + "name": "customer_account", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + }, + { + "explode": true, + "in": "query", + "name": "due_date", + "required": false, + "schema": { + "anyOf": [ + { + "properties": { + "gt": { + "type": "integer" + }, + "gte": { + "type": "integer" + }, + "lt": { + "type": "integer" + }, + "lte": { + "type": "integer" + } + }, + "title": "range_query_specs", + "type": "object" + }, + { + "type": "integer" + } + ] + }, + "style": "deepObject" + }, + { + "description": "A cursor for use in pagination. `ending_before` is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, starting with `obj_bar`, your subsequent call can include `ending_before=obj_bar` in order to fetch the previous page of the list.", + "in": "query", + "name": "ending_before", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + }, + { + "description": "Specifies which fields in the response should be expanded.", + "explode": true, + "in": "query", + "name": "expand", + "required": false, + "schema": { + "items": { + "maxLength": 5000, + "type": "string" + }, + "type": "array" + }, + "style": "deepObject" + }, + { + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 10.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + }, + "style": "form" + }, + { + "description": "A cursor for use in pagination. `starting_after` is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, ending with `obj_foo`, your subsequent call can include `starting_after=obj_foo` in order to fetch the next page of the list.", + "in": "query", + "name": "starting_after", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + }, + { + "description": "The status of the invoice, one of `draft`, `open`, `paid`, `uncollectible`, or `void`. [Learn more](https://docs.stripe.com/billing/invoices/workflow#workflow-overview)", + "in": "query", + "name": "status", + "required": false, + "schema": { + "enum": [ + "draft", + "open", + "paid", + "uncollectible", + "void" + ], + "type": "string" + }, + "style": "form" + }, + { + "description": "Only return invoices for the subscription specified by this subscription ID.", + "in": "query", + "name": "subscription", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "encoding": {}, + "schema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "description": "", + "properties": { + "data": { + "items": { + "type": "object", + "title": "Invoice", + "description": "Invoices are statements of amounts owed by a customer, and are either\ngenerated one-off, or generated periodically from a subscription.\n\nThey contain [invoice items](https://docs.stripe.com/api#invoiceitems), and proration adjustments\nthat may be caused by subscription upgrades/downgrades (if necessary).\n\nIf your invoice is configured to be billed through automatic charges,\nStripe automatically finalizes your invoice and attempts payment. Note\nthat finalizing the invoice,\n[when automatic](https://docs.stripe.com/invoicing/integration/automatic-advancement-collection), does\nnot happen immediately as the invoice is created. Stripe waits\nuntil one hour after the last webhook was successfully sent (or the last\nwebhook timed out after failing). If you (and the platforms you may have\nconnected to) have no webhooks configured, Stripe waits one hour after\ncreation to finalize the invoice.\n\nIf your invoice is configured to be billed by sending an email, then based on your\n[email settings](https://dashboard.stripe.com/account/billing/automatic),\nStripe will email the invoice to your customer and await payment. These\nemails can contain a link to a hosted page to pay the invoice.\n\nStripe applies any customer credit on the account before determining the\namount due for the invoice (i.e., the amount that will be actually\ncharged). If the amount due for the invoice is less than Stripe's [minimum allowed charge\nper currency](/docs/currencies#minimum-and-maximum-charge-amounts), the\ninvoice is automatically marked paid, and we add the amount due to the\ncustomer's credit balance which is applied to the next invoice.\n\nMore details on the customer's credit balance are\n[here](https://docs.stripe.com/billing/customer/balance).\n\nRelated guide: [Send invoices to customers](https://docs.stripe.com/billing/invoices/sending)\n\n(Not expanded by normalize-openapi: '#/components/schemas/invoice', deeper than --max-depth 0.)" + }, + "type": "array" + }, + "has_more": { + "description": "True if this list has another page of items after this one that can be fetched.", + "type": "boolean" + }, + "object": { + "description": "String representing the object's type. Objects of the same type share the same value. Always has the value `list`.", + "enum": [ + "list" + ], + "type": "string" + }, + "url": { + "description": "The URL where this list can be accessed.", + "maxLength": 5000, + "pattern": "^/v1/invoices", + "type": "string" + } + }, + "required": [ + "data", + "has_more", + "object", + "url" + ], + "title": "InvoicesResourceList", + "type": "object", + "x-expandableFields": [ + "data" + ] + } + } + }, + "description": "Successful response." + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "An error response from the Stripe API\n\n(Not expanded by normalize-openapi: '#/components/schemas/error', deeper than --max-depth 0.)" + } + } + }, + "description": "Error response." + } + }, + "summary": "List all invoices" + } + }, + "/v1/invoices/{invoice}": { + "get": { + "description": "

Retrieves the invoice with the given ID.

", + "operationId": "GetInvoicesInvoice", + "parameters": [ + { + "description": "Specifies which fields in the response should be expanded.", + "explode": true, + "in": "query", + "name": "expand", + "required": false, + "schema": { + "items": { + "maxLength": 5000, + "type": "string" + }, + "type": "array" + }, + "style": "deepObject" + }, + { + "in": "path", + "name": "invoice", + "required": true, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "encoding": {}, + "schema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Invoice", + "description": "Invoices are statements of amounts owed by a customer, and are either\ngenerated one-off, or generated periodically from a subscription.\n\nThey contain [invoice items](https://docs.stripe.com/api#invoiceitems), and proration adjustments\nthat may be caused by subscription upgrades/downgrades (if necessary).\n\nIf your invoice is configured to be billed through automatic charges,\nStripe automatically finalizes your invoice and attempts payment. Note\nthat finalizing the invoice,\n[when automatic](https://docs.stripe.com/invoicing/integration/automatic-advancement-collection), does\nnot happen immediately as the invoice is created. Stripe waits\nuntil one hour after the last webhook was successfully sent (or the last\nwebhook timed out after failing). If you (and the platforms you may have\nconnected to) have no webhooks configured, Stripe waits one hour after\ncreation to finalize the invoice.\n\nIf your invoice is configured to be billed by sending an email, then based on your\n[email settings](https://dashboard.stripe.com/account/billing/automatic),\nStripe will email the invoice to your customer and await payment. These\nemails can contain a link to a hosted page to pay the invoice.\n\nStripe applies any customer credit on the account before determining the\namount due for the invoice (i.e., the amount that will be actually\ncharged). If the amount due for the invoice is less than Stripe's [minimum allowed charge\nper currency](/docs/currencies#minimum-and-maximum-charge-amounts), the\ninvoice is automatically marked paid, and we add the amount due to the\ncustomer's credit balance which is applied to the next invoice.\n\nMore details on the customer's credit balance are\n[here](https://docs.stripe.com/billing/customer/balance).\n\nRelated guide: [Send invoices to customers](https://docs.stripe.com/billing/invoices/sending)\n\n(Not expanded by normalize-openapi: '#/components/schemas/invoice', deeper than --max-depth 0.)" + } + } + }, + "description": "Successful response." + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "An error response from the Stripe API\n\n(Not expanded by normalize-openapi: '#/components/schemas/error', deeper than --max-depth 0.)" + } + } + }, + "description": "Error response." + } + }, + "summary": "Retrieve an invoice" + } + }, + "/v1/payment_intents": { + "get": { + "description": "

Returns a list of PaymentIntents.

", + "operationId": "GetPaymentIntents", + "parameters": [ + { + "description": "A filter on the list, based on the object `created` field. The value can be a string with an integer Unix timestamp or a dictionary with a number of different query options.", + "explode": true, + "in": "query", + "name": "created", + "required": false, + "schema": { + "anyOf": [ + { + "properties": { + "gt": { + "type": "integer" + }, + "gte": { + "type": "integer" + }, + "lt": { + "type": "integer" + }, + "lte": { + "type": "integer" + } + }, + "title": "range_query_specs", + "type": "object" + }, + { + "type": "integer" + } + ] + }, + "style": "deepObject" + }, + { + "description": "Only return PaymentIntents for the customer that this customer ID specifies.", + "in": "query", + "name": "customer", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + }, + { + "description": "Only return PaymentIntents for the account representing the customer that this ID specifies.", + "in": "query", + "name": "customer_account", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + }, + { + "description": "A cursor for use in pagination. `ending_before` is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, starting with `obj_bar`, your subsequent call can include `ending_before=obj_bar` in order to fetch the previous page of the list.", + "in": "query", + "name": "ending_before", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + }, + { + "description": "Specifies which fields in the response should be expanded.", + "explode": true, + "in": "query", + "name": "expand", + "required": false, + "schema": { + "items": { + "maxLength": 5000, + "type": "string" + }, + "type": "array" + }, + "style": "deepObject" + }, + { + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100, and the default is 10.", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + }, + "style": "form" + }, + { + "description": "A cursor for use in pagination. `starting_after` is an object ID that defines your place in the list. For instance, if you make a list request and receive 100 objects, ending with `obj_foo`, your subsequent call can include `starting_after=obj_foo` in order to fetch the next page of the list.", + "in": "query", + "name": "starting_after", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "encoding": {}, + "schema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "description": "", + "properties": { + "data": { + "items": { + "type": "object", + "title": "PaymentIntent", + "description": "A PaymentIntent guides you through the process of collecting a payment from your customer.\nWe recommend that you create exactly one PaymentIntent for each order or\ncustomer session in your system. You can reference the PaymentIntent later to\nsee the history of payment attempts for a particular session.\n\nA PaymentIntent transitions through\n[multiple statuses](/payments/paymentintents/lifecycle)\nthroughout its lifetime as it interfaces with Stripe.js to perform\nauthentication flows and ultimately creates at most one successful charge.\n\nRelated guide: [Payment Intents API](https://docs.stripe.com/payments/payment-intents)\n\n(Not expanded by normalize-openapi: '#/components/schemas/payment_intent', deeper than --max-depth 0.)" + }, + "type": "array" + }, + "has_more": { + "description": "True if this list has another page of items after this one that can be fetched.", + "type": "boolean" + }, + "object": { + "description": "String representing the object's type. Objects of the same type share the same value. Always has the value `list`.", + "enum": [ + "list" + ], + "type": "string" + }, + "url": { + "description": "The URL where this list can be accessed.", + "maxLength": 5000, + "pattern": "^/v1/payment_intents", + "type": "string" + } + }, + "required": [ + "data", + "has_more", + "object", + "url" + ], + "title": "PaymentFlowsPaymentIntentList", + "type": "object", + "x-expandableFields": [ + "data" + ] + } + } + }, + "description": "Successful response." + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "An error response from the Stripe API\n\n(Not expanded by normalize-openapi: '#/components/schemas/error', deeper than --max-depth 0.)" + } + } + }, + "description": "Error response." + } + }, + "summary": "List all PaymentIntents" + } + }, + "/v1/payment_intents/{intent}": { + "get": { + "description": "

Retrieves the details of a PaymentIntent that has previously been created.

\n\n

You can retrieve a PaymentIntent client-side using a publishable key when the client_secret is in the query string.

\n\n

If you retrieve a PaymentIntent with a publishable key, it only returns a subset of properties. Refer to the payment intent object reference for more details.

", + "operationId": "GetPaymentIntentsIntent", + "parameters": [ + { + "description": "The client secret of the PaymentIntent. We require it if you use a publishable key to retrieve the source.", + "in": "query", + "name": "client_secret", + "required": false, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "form" + }, + { + "description": "Specifies which fields in the response should be expanded.", + "explode": true, + "in": "query", + "name": "expand", + "required": false, + "schema": { + "items": { + "maxLength": 5000, + "type": "string" + }, + "type": "array" + }, + "style": "deepObject" + }, + { + "in": "path", + "name": "intent", + "required": true, + "schema": { + "maxLength": 5000, + "type": "string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "encoding": {}, + "schema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + } + } + }, + "required": false + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "PaymentIntent", + "description": "A PaymentIntent guides you through the process of collecting a payment from your customer.\nWe recommend that you create exactly one PaymentIntent for each order or\ncustomer session in your system. You can reference the PaymentIntent later to\nsee the history of payment attempts for a particular session.\n\nA PaymentIntent transitions through\n[multiple statuses](/payments/paymentintents/lifecycle)\nthroughout its lifetime as it interfaces with Stripe.js to perform\nauthentication flows and ultimately creates at most one successful charge.\n\nRelated guide: [Payment Intents API](https://docs.stripe.com/payments/payment-intents)\n\n(Not expanded by normalize-openapi: '#/components/schemas/payment_intent', deeper than --max-depth 0.)" + } + } + }, + "description": "Successful response." + }, + "default": { + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "An error response from the Stripe API\n\n(Not expanded by normalize-openapi: '#/components/schemas/error', deeper than --max-depth 0.)" + } + } + }, + "description": "Error response." + } + }, + "summary": "Retrieve a PaymentIntent" + } + } + }, + "security": [ + { + "basicAuth": [] + }, + { + "bearerAuth": [] + } + ], + "servers": [ + { + "url": "https://api.stripe.com/" + } + ] +} diff --git a/packages/apps/connector-stripe/package.json b/packages/apps/connector-stripe/package.json new file mode 100644 index 00000000000..420bdc45d1b --- /dev/null +++ b/packages/apps/connector-stripe/package.json @@ -0,0 +1,31 @@ +{ + "name": "@objectstack/connector-app-stripe", + "version": "0.1.0", + "description": "Stripe connector app (open edition, read only): Stripe's OpenAPI document, trimmed offline to eight read operations, as a declarative `openapi` connector instance authenticated by a restricted secret key the administrator supplies through the credential store.", + "license": "Apache-2.0", + "private": true, + "type": "module", + "main": "./objectstack.config.ts", + "types": "./objectstack.config.ts", + "exports": { + ".": "./objectstack.config.ts", + "./objectstack.config": "./objectstack.config.ts" + }, + "scripts": { + "build": "objectstack build", + "validate": "objectstack validate", + "typecheck": "tsc --noEmit", + "test": "objectstack validate && vitest run" + }, + "dependencies": { + "@objectstack/connector-openapi": "workspace:*", + "@objectstack/spec": "workspace:*" + }, + "devDependencies": { + "@objectstack/cli": "workspace:*", + "@objectstack/verify": "workspace:*", + "@types/node": "^26.6.5", + "typescript": "^6.0.3", + "vitest": "^4.1.11" + } +} diff --git a/packages/apps/connector-stripe/src/connectors/stripe.connector.ts b/packages/apps/connector-stripe/src/connectors/stripe.connector.ts new file mode 100644 index 00000000000..35a52cbf9eb --- /dev/null +++ b/packages/apps/connector-stripe/src/connectors/stripe.connector.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineConnector } from '@objectstack/spec/integration'; + +/** + * The eight operations this app exposes: list and retrieve, for customers, + * charges, invoices and payment intents. Every one is a GET; the packaged + * document carries nothing else, because it was trimmed to exactly these by + * the offline normalizer (`--include-pattern … --trim`, the README records the + * command, the upstream commit and the sha256 it printed). + * + * Stripe's write operations take `application/x-www-form-urlencoded` bodies, + * which the `openapi` provider's transport does not send (it sends JSON), so + * this app is read only by construction, not by convention. + */ +export const STRIPE_READ_OPERATIONS = [ + 'GetCustomers', + 'GetCustomersCustomer', + 'GetCharges', + 'GetChargesCharge', + 'GetInvoices', + 'GetInvoicesInvoice', + 'GetPaymentIntents', + 'GetPaymentIntentsIntent', +] as const; + +/** + * Stripe over its own OpenAPI document — recipe 1 of the integration recipes + * (`skills/objectstack-automation/references/integration-recipes.md`): a + * declarative `provider: 'openapi'` instance (ADR-0097, ADR-0023) the + * `openapi` executor materializes at boot, one action per operation, keyed by + * Stripe's `operationId`. + * + * The document is `stripe/openapi`'s `openapi/spec3.json` (MIT), normalized + * and trimmed offline; its `servers[0].url` is `https://api.stripe.com/` and + * its paths carry the `/v1` prefix. Stripe's document declares both `basic` + * and `bearer` HTTP schemes (`Authorization: Bearer `); the `bearer` + * auth below is folded into exactly that header by the executor. The key is + * never in this file — `credentialRef` names it, and the administrator + * supplies the value (an environment variable of that name, or Setup → + * Integrations → Connector Credentials). + * + * `include` repeats the trimmed document's whole operation set on purpose: an + * entry the document does not carry is refused at materialization, so a + * re-normalized document that lost an operation fails the boot loudly instead + * of bringing the instance up without it. + */ +export const StripeConnector = defineConnector({ + name: 'stripe', + label: 'Stripe (read only)', + type: 'api', + description: + 'Stripe API, read operations only: list and retrieve customers, charges, invoices and payment intents, ' + + 'authenticated by a restricted API key the administrator supplies.', + provider: 'openapi', + providerConfig: { + spec: './openapi/stripe.openapi.json', + include: [...STRIPE_READ_OPERATIONS], + }, + auth: { type: 'bearer', credentialRef: 'STRIPE_RESTRICTED_KEY' }, +}); diff --git a/packages/apps/connector-stripe/src/flows/stripe.flows.ts b/packages/apps/connector-stripe/src/flows/stripe.flows.ts new file mode 100644 index 00000000000..78c37733b47 --- /dev/null +++ b/packages/apps/connector-stripe/src/flows/stripe.flows.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineFlow } from '@objectstack/spec'; + +/** + * Read one customer. Run it with + * `POST /api/v1/automation/stripe_get_customer/trigger` and a body of + * `{ "params": { "customer_id": "cus_123" } }`; Stripe's answer is the run's + * `customer.body` output (`customer.status` is the HTTP status). + * + * An `openapi` action takes its path parameters as one `path` object, so the + * value slot is a single CEL map literal, not a map of envelopes. + */ +export const StripeGetCustomerFlow = defineFlow({ + name: 'stripe_get_customer', + label: 'Stripe: get customer', + description: 'Retrieves one Stripe customer by id through the stripe connector.', + type: 'autolaunched', + status: 'active', + variables: [ + { name: 'customer_id', type: 'text', isInput: true }, + { name: 'customer.body', type: 'json', isOutput: true }, + ], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'customer', + type: 'connector_action', + label: 'GetCustomersCustomer', + connectorConfig: { + connectorId: 'stripe', + actionId: 'GetCustomersCustomer', + input: { path: { dialect: 'cel', source: '{"customer": customer_id}' } }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'customer' }, + { id: 'e2', source: 'customer', target: 'end' }, + ], +}); + +/** + * List one customer's open invoices, newest first, at most ten. Run it with + * `POST /api/v1/automation/stripe_list_open_invoices/trigger` and a body of + * `{ "params": { "customer_id": "cus_123" } }`; Stripe's list object is the + * run's `invoices.body` output (`invoices.body.data` holds the invoices). + * + * A CEL map's values share one type, so the literals beside the field read + * are wrapped in `dyn(…)`. + */ +export const StripeListOpenInvoicesFlow = defineFlow({ + name: 'stripe_list_open_invoices', + label: 'Stripe: list open invoices', + description: "Lists a Stripe customer's open invoices (at most ten) through the stripe connector.", + type: 'autolaunched', + status: 'active', + variables: [ + { name: 'customer_id', type: 'text', isInput: true }, + { name: 'invoices.body', type: 'json', isOutput: true }, + ], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'invoices', + type: 'connector_action', + label: 'GetInvoices', + connectorConfig: { + connectorId: 'stripe', + actionId: 'GetInvoices', + input: { + query: { dialect: 'cel', source: '{"customer": customer_id, "status": dyn("open"), "limit": dyn(10)}' }, + }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'invoices' }, + { id: 'e2', source: 'invoices', target: 'end' }, + ], +}); + +export const allFlows = [StripeGetCustomerFlow, StripeListOpenInvoicesFlow]; diff --git a/packages/apps/connector-stripe/test/boot.test.ts b/packages/apps/connector-stripe/test/boot.test.ts new file mode 100644 index 00000000000..a3999acfb49 --- /dev/null +++ b/packages/apps/connector-stripe/test/boot.test.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Boot test: the app, booted the way `objectstack serve` composes it + * (`bootStack` from `@objectstack/verify`), materializes its declarative + * `stripe` instance from the PACKAGED document against a FAKE Stripe — no + * call reaches Stripe. + * + * The one substitution is the HTTP transport: the app's own + * `ConnectorOpenApiPlugin` is replaced (by plugin name, + * `BootOptions.extraPlugins`) with one whose `fetchImpl` answers from a + * fixture and records every request it was handed. Everything else is the + * shipped app: the connector entry, the trimmed document it reads from this + * package's root, the flows, and the administrator's path to a working + * instance — boot with no credential (the instance is degraded, naming the + * reference), set the value through the credential door, run a flow. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { fileURLToPath } from 'node:url'; +// Paid at module load, before vitest's clocked windows: both reach the whole +// runtime. +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; +import stack from '../objectstack.config.js'; +import { STRIPE_READ_OPERATIONS } from '../src/connectors/stripe.connector.js'; + +const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)); +const REF = 'STRIPE_RESTRICTED_KEY'; +const FIXTURE_KEY = 'fixture-restricted-key-not-real'; + +interface Recorded { + method: string; + url: string; + authorization: string | null; +} + +interface Descriptor { + name: string; + state?: string; + degradedReason?: string; + actions?: Array<{ key: string }>; +} + +/** What the fake Stripe answers, by path: a Stripe-shaped object per resource. */ +function fixtureBody(url: URL): Record { + if (url.pathname === '/v1/customers/cus_fixture') { + return { id: 'cus_fixture', object: 'customer', email: 'ada@example.com' }; + } + if (url.pathname === '/v1/invoices') { + return { + object: 'list', + url: '/v1/invoices', + has_more: false, + data: [{ id: 'in_fixture', object: 'invoice', customer: url.searchParams.get('customer'), status: 'open' }], + }; + } + return { error: { type: 'invalid_request_error', message: `fixture has no answer for ${url.pathname}` } }; +} + +describe('connector-stripe app — boot against a fake Stripe', () => { + const requests: Recorded[] = []; + let booted: VerifyStack; + let token: string; + const saved = process.env[REF]; + + beforeAll(async () => { + delete process.env[REF]; + const fetchImpl: typeof fetch = async (input, init) => { + const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url); + const headers = new Headers(init?.headers); + requests.push({ method: init?.method ?? 'GET', url: url.href, authorization: headers.get('authorization') }); + const body = fixtureBody(url); + return new Response(JSON.stringify(body), { + status: 'error' in body ? 404 : 200, + headers: { 'content-type': 'application/json' }, + }); + }; + booted = await bootStack(stack, { + hostRoot: APP_ROOT, + extraPlugins: [new ConnectorOpenApiPlugin({ fetchImpl })], + }); + token = await booted.signIn(); + }, 120_000); + + afterAll(async () => { + await booted?.stop(); + if (saved === undefined) delete process.env[REF]; + else process.env[REF] = saved; + }); + + const descriptor = async (): Promise => { + const res = await booted.apiAs(token, 'GET', '/automation/connectors'); + expect(res.status).toBe(200); + const body = (await res.json()) as { data?: { connectors?: Descriptor[] } }; + return body.data?.connectors?.find((c) => c.name === 'stripe'); + }; + + it('with no credential set, the instance is degraded naming its credentialRef and nothing was sent', async () => { + const stripe = await descriptor(); + expect(stripe, 'stripe is not in the connector registry').toBeDefined(); + expect(stripe!.state).toBe('degraded'); + expect(stripe!.degradedReason).toContain(REF); + expect(requests).toEqual([]); + }); + + it('a flow run while the credential is unset fails, naming the credential, and sends nothing', async () => { + const res = await booted.apiAs(token, 'POST', '/automation/stripe_get_customer/trigger', { + params: { customer_id: 'cus_fixture' }, + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { error?: { code?: string; message?: string } }; + expect(body.error?.code).toBe('FLOW_FAILED'); + expect(body.error?.message).toContain(REF); + expect(requests).toEqual([]); + }); + + it('once the administrator sets the credential, it materializes with exactly the eight read operations', async () => { + const put = await booted.apiAs(token, 'PUT', `/automation/connectors/credential-refs/${REF}`, { value: FIXTURE_KEY }); + expect(put.status, await put.clone().text()).toBeLessThan(300); + + let stripe = await descriptor(); + for (let i = 0; i < 40 && stripe?.state !== 'ready'; i++) { + await new Promise((r) => setTimeout(r, 50)); + stripe = await descriptor(); + } + expect(stripe?.state, stripe?.degradedReason).toBe('ready'); + expect(stripe!.actions?.map((a) => a.key).sort()).toEqual([...STRIPE_READ_OPERATIONS].sort()); + // Materializing reads the packaged document from disk; it calls nothing. + expect(requests).toEqual([]); + }); + + it('stripe_get_customer sends GET /v1/customers/:id with the key as a bearer token and returns the body', async () => { + const res = await booted.apiAs(token, 'POST', '/automation/stripe_get_customer/trigger', { + params: { customer_id: 'cus_fixture' }, + }); + const text = await res.text(); + expect(res.status, text).toBe(200); + expect(requests.at(-1)).toEqual({ + method: 'GET', + url: 'https://api.stripe.com/v1/customers/cus_fixture', + authorization: `Bearer ${FIXTURE_KEY}`, + }); + expect(text).toContain('ada@example.com'); + }); + + it("stripe_list_open_invoices sends GET /v1/invoices with the customer, status open and limit 10", async () => { + const res = await booted.apiAs(token, 'POST', '/automation/stripe_list_open_invoices/trigger', { + params: { customer_id: 'cus_fixture' }, + }); + const text = await res.text(); + expect(res.status, text).toBe(200); + const sent = requests.at(-1)!; + expect(sent.method).toBe('GET'); + expect(sent.authorization).toBe(`Bearer ${FIXTURE_KEY}`); + const url = new URL(sent.url); + expect(`${url.origin}${url.pathname}`).toBe('https://api.stripe.com/v1/invoices'); + expect(Object.fromEntries(url.searchParams)).toEqual({ customer: 'cus_fixture', status: 'open', limit: '10' }); + expect(text).toContain('in_fixture'); + }); + + it("a Stripe error answer lands in the step's output (status, ok false, Stripe's error); the step does not fail", async () => { + const res = await booted.apiAs(token, 'POST', '/automation/stripe_get_customer/trigger', { + params: { customer_id: 'cus_missing' }, + }); + const text = await res.text(); + expect(res.status, text).toBe(200); + expect(requests.at(-1)?.url).toBe('https://api.stripe.com/v1/customers/cus_missing'); + expect(text).toContain('invalid_request_error'); + }); + + it('every request the app sent was a GET', () => { + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((r) => r.method === 'GET')).toBe(true); + }); +}); diff --git a/packages/apps/connector-stripe/test/listing.test.ts b/packages/apps/connector-stripe/test/listing.test.ts new file mode 100644 index 00000000000..d4142f3020d --- /dev/null +++ b/packages/apps/connector-stripe/test/listing.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The listing this package publishes as, and the document it packages: + * `objectstack.manifest.json` parses against the spec's own schema for that + * file and names the same package id as the stack; the README's credentials + * section names every credential the app's connectors reference (the + * marketplace handbook's "every credential it needs" rule, checked against + * the metadata rather than trusted); and the packaged OpenAPI document is the + * one the README's provenance names — its sha256 is the hash recorded there, + * and it carries exactly the read operations the connector includes. + */ + +import { describe, it, expect } from 'vitest'; +import { createHash } from 'node:crypto'; +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { TemplateManifestSchema } from '@objectstack/spec/marketplace'; +import stack from '../objectstack.config.js'; +import { STRIPE_READ_OPERATIONS, StripeConnector } from '../src/connectors/stripe.connector.js'; + +const manifestPath = fileURLToPath(new URL('../objectstack.manifest.json', import.meta.url)); +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Record; +const readme = readFileSync(fileURLToPath(new URL('../README.md', import.meta.url)), 'utf8'); +const documentBytes = readFileSync(fileURLToPath(new URL('../openapi/stripe.openapi.json', import.meta.url))); +const document = JSON.parse(documentBytes.toString('utf8')) as { + openapi?: string; + servers?: Array<{ url?: string }>; + paths?: Record>; +}; + +/** A README `## ` section, up to the next second-level heading. */ +function section(title: string): string { + const start = readme.indexOf(`\n## ${title}\n`); + expect(start, `README has no "## ${title}" section`).toBeGreaterThan(-1); + const rest = readme.slice(start + 1); + const next = rest.indexOf('\n## ', 1); + return next === -1 ? rest : rest.slice(0, next); +} + +describe('connector-stripe listing', () => { + it('parses against TemplateManifestSchema', () => { + const parsed = TemplateManifestSchema.safeParse(manifest); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it('names the stack manifest id as its package id', () => { + expect(manifest.manifestId).toBe(stack.manifest!.id); + }); + + it('declares no key the publisher ignores', () => { + // The handbook: the platform decides `publisher` and `isStarter`; the CLI never sends them. + expect(manifest).not.toHaveProperty('publisher'); + expect(manifest).not.toHaveProperty('isStarter'); + }); + + it("the README's credentials section names every credentialRef and where to obtain it", () => { + expect(existsSync(fileURLToPath(new URL(`../${String(manifest.readmePath)}`, import.meta.url)))).toBe(true); + const credentials = section('Credentials'); + const refs = (stack.connectors ?? []) + .map((c) => (c as { auth?: { credentialRef?: string } }).auth?.credentialRef) + .filter((r): r is string => typeof r === 'string'); + expect(refs).toEqual(['STRIPE_RESTRICTED_KEY']); + for (const ref of refs) expect(credentials).toContain(`\`${ref}\``); + expect(credentials).toContain('https://dashboard.stripe.com/apikeys'); + }); + + it('carries no secret in metadata', () => { + for (const c of stack.connectors ?? []) { + expect(c).not.toHaveProperty('authentication.token'); + expect(JSON.stringify(c)).not.toMatch(/\b[sr]k_(live|test)_/); + } + }); +}); + +describe('connector-stripe packaged OpenAPI document', () => { + it("is the document the README's provenance names: its sha256 is the one recorded there", () => { + const provenance = section('Provenance'); + const recorded = provenance.match(/sha256\s+`([0-9a-f]{64})`/)?.[1]; + expect(recorded, 'README provenance records no sha256').toBeDefined(); + expect(createHash('sha256').update(documentBytes).digest('hex')).toBe(recorded); + }); + + it('is OpenAPI 3.x against https://api.stripe.com/', () => { + expect(document.openapi).toMatch(/^3\.\d+(\.\d+)?$/); + expect(document.servers?.[0]?.url).toBe('https://api.stripe.com/'); + }); + + it('carries exactly the operations the connector includes, every one a GET', () => { + const operations: Array<{ method: string; operationId?: string }> = []; + for (const item of Object.values(document.paths ?? {})) { + for (const [method, op] of Object.entries(item)) { + if (['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'].includes(method)) { + operations.push({ method, operationId: op.operationId }); + } + } + } + expect(operations.map((o) => o.operationId).sort()).toEqual([...STRIPE_READ_OPERATIONS].sort()); + expect(operations.every((o) => o.method === 'get')).toBe(true); + expect(StripeConnector.providerConfig).toMatchObject({ include: [...STRIPE_READ_OPERATIONS] }); + }); + + it("ships the upstream document's MIT licence notice beside it", () => { + const notice = readFileSync(fileURLToPath(new URL('../openapi/LICENSE', import.meta.url)), 'utf8'); + expect(notice).toContain('The MIT License'); + expect(notice).toContain('Stripe, Inc.'); + }); +}); diff --git a/packages/apps/connector-stripe/tsconfig.json b/packages/apps/connector-stripe/tsconfig.json new file mode 100644 index 00000000000..3dfe4ff581b --- /dev/null +++ b/packages/apps/connector-stripe/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*", "objectstack.config.ts", "test/**/*", "vitest.config.ts"] +} diff --git a/packages/apps/connector-stripe/vitest.config.ts b/packages/apps/connector-stripe/vitest.config.ts new file mode 100644 index 00000000000..d61eee3cef8 --- /dev/null +++ b/packages/apps/connector-stripe/vitest.config.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineConfig } from 'vitest/config'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + // A late console.* must not redden a green suite: the boot test starts a + // whole kernel, whose logger can write after teardown. Mechanism and + // measured costs: examples/app-showcase/vitest.config.ts; enforced + // repo-wide by scripts/check-console-intercept-disarm.mjs. + disableConsoleIntercept: true, + // Quiet the registry's per-item registration chatter — the engine's own + // `OS_REGISTRY_LOG` seam, not a change to its shipped default. Enforced by + // scripts/check-registry-log-declared.mjs. + env: { OS_REGISTRY_LOG: 'warn' }, + }, + resolve: { + // Every workspace package these tests import resolves to its SOURCE, so a + // verdict here is about the checkout, never about whatever `dist/` was last + // built (`pnpm check:test-source-alias`). Anchored, array form: a bare + // string key matches by prefix and would swallow the subpaths. + alias: [ + { find: /^@objectstack\/connector-openapi$/, replacement: path.resolve(HERE, '../../connectors/connector-openapi/src/index.ts') }, + { find: /^@objectstack\/spec$/, replacement: path.resolve(HERE, '../../spec/src/index.ts') }, + { find: /^@objectstack\/spec\/integration$/, replacement: path.resolve(HERE, '../../spec/src/integration/index.ts') }, + { find: /^@objectstack\/spec\/marketplace$/, replacement: path.resolve(HERE, '../../spec/src/marketplace/index.ts') }, + { find: /^@objectstack\/verify$/, replacement: path.resolve(HERE, '../../verify/src/index.ts') }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b51a629dbc4..e7bd93c5c0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -451,6 +451,31 @@ importers: specifier: ^4.1.11 version: 4.1.12(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@4.1.12)(@vitest/ui@4.1.10)(happy-dom@21.0.4)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.6.5)(typescript@6.0.3))(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) + packages/apps/connector-stripe: + dependencies: + '@objectstack/connector-openapi': + specifier: workspace:* + version: link:../../connectors/connector-openapi + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec + devDependencies: + '@objectstack/cli': + specifier: workspace:* + version: link:../../cli + '@objectstack/verify': + specifier: workspace:* + version: link:../../verify + '@types/node': + specifier: ^26.6.5 + version: 26.6.5 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.11 + version: 4.1.12(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@4.1.12)(@vitest/ui@4.1.10)(happy-dom@21.0.4)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.6.5)(typescript@6.0.3))(vite@8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) + packages/apps/setup: dependencies: '@objectstack/platform-objects':