diff --git a/.changeset/28226-credential-literal-explain-grant-kinds.md b/.changeset/28226-credential-literal-explain-grant-kinds.md new file mode 100644 index 00000000000..b9e76a48015 --- /dev/null +++ b/.changeset/28226-credential-literal-explain-grant-kinds.md @@ -0,0 +1,14 @@ +--- +'@objectstack/lint': patch +--- + +docs(lint): `os explain flow-credential-literal` names the `oauth2`, `app-credential` and `signed` connector auth kinds, and says why its fix line never routes to them + +Clause-②: no +Metadata-protocol: none + +The connector auth union now declares `oauth2` (grant `authorization_code` or `client_credentials`), `app-credential` (vendor `feishu`, `dingtalk` or `wecom`) and `signed` beside the static kinds. The explanation said only that the fix line routes to a variant the schema "really has", which read as if `bearer`, `api-key` and `basic` were the whole list. + +- The explanation now names the other kinds, says their secret is also only a `credentialRef`, and says none is a home a literal moves into by shape: `oauth2` and `app-credential` carry a grant whose token the server obtains per dispatch, and until it runs that grant such an instance is held degraded and calls no upstream; `signed` signs each request with its key. +- The `fix:` lines are unchanged and still route to `bearer`, `api-key` or `basic`. The rule's predicate and severity are unchanged. +- The rule's test holds every kind and grant the explanation names to `ConnectorInstanceAuthSchema`. diff --git a/packages/lint/src/lint-flow-credential-literals.test.ts b/packages/lint/src/lint-flow-credential-literals.test.ts index 05420fa44a0..8e4b13aa138 100644 --- a/packages/lint/src/lint-flow-credential-literals.test.ts +++ b/packages/lint/src/lint-flow-credential-literals.test.ts @@ -518,9 +518,25 @@ describe('[#22161] one-line fixes — flow-credential-literal', () => { { type: 'api-key', headerName: 'X-Api-Key', credentialRef: 'PROBE_REF' }, { type: 'api-key', paramName: 'api_key', credentialRef: 'PROBE_REF' }, { type: 'basic', username: 'probe', credentialRef: 'PROBE_REF' }, + // The grant-bearing and signing kinds the explanation names, with the + // grants and vendors it lists. + { type: 'oauth2', grant: 'authorization_code', clientId: 'probe', credentialRef: 'PROBE_REF' }, + { type: 'oauth2', grant: 'client_credentials', clientId: 'probe', credentialRef: 'PROBE_REF' }, + ...['feishu', 'dingtalk', 'wecom'].map((vendor) => ( + { type: 'app-credential', vendor, appId: 'probe', credentialRef: 'PROBE_REF' })), + { type: 'signed', scheme: 'aliyun-acs3-hmac-sha256', keyId: 'probe', credentialRef: 'PROBE_REF' }, ]) { expect(ConnectorInstanceAuthSchema.safeParse(auth).success, JSON.stringify(auth)).toBe(true); } + const explanation = explainRule(FLOW_CREDENTIAL_LITERAL)!.paragraphs.join('\n'); + for (const fact of [ + "`oauth2` (grant `authorization_code` or `client_credentials`)", + "`app-credential` (vendor `feishu`, `dingtalk` or `wecom`)", + 'held degraded and calls no upstream', + '`signed` signs each request', + ]) { + expect(explanation, `explanation names ${fact}`).toContain(fact); + } // The connector-input line offers every secret-bearing variant by name. const input = lintFlowCredentialLiteralsUnrecorded(controlStack()).find((f) => f.message.startsWith('connector input '), diff --git a/packages/lint/src/lint-flow-credential-literals.ts b/packages/lint/src/lint-flow-credential-literals.ts index eb11bb5b443..e9a20e77326 100644 --- a/packages/lint/src/lint-flow-credential-literals.ts +++ b/packages/lint/src/lint-flow-credential-literals.ts @@ -80,10 +80,12 @@ const SERVED = 'readable by every member who can read flows'; * prints it only under `--fix`, so a route stated only in the hint misses the * author who runs `os lint`. * - * The hint carries the route BY SHAPE, to a variant `ConnectorInstanceAuthSchema` - * really has (`none` / `bearer` / `api-key` / `basic`): a header credential goes - * to `bearer` or a header `api-key`, a query-string key to `api-key` with - * `paramName`. ⛔ No variant carries a secret in a url PATH, so no text here + * The hint carries the route BY SHAPE, to a static variant + * `ConnectorInstanceAuthSchema` really has (`bearer` / `api-key` / `basic`): a + * header credential goes to `bearer` or a header `api-key`, a query-string key + * to `api-key` with `paramName`. Its other kinds (`oauth2`, `app-credential`, + * `signed`) carry a grant or a signing key, not a value a literal moves into by + * shape, so no hint routes to them; the explanation names them. ⛔ No variant carries a secret in a url PATH, so no text here * may promise that `credentialRef` can hold one — an author cannot follow it. * (This rule does not judge url paths; it judges query parameters only.) */ diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 1232cb8877b..b2d1f606b22 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -4706,7 +4706,12 @@ const FLOW_CREDENTIAL_LITERAL_EXPLANATION: RuleExplanation = { 'variable in the open tier) and never stored in metadata (ADR-0097 §3), called from a ' + '`connector_action` node. The fix line routes by shape, to a variant the connector auth schema ' + 'really has: a header credential to `bearer` or a header `api-key`, a query-string key to ' + - '`api-key` with `paramName`. A credential in `connectorConfig.input` is dropped instead, ' + + '`api-key` with `paramName`. The schema\'s other kinds also take their secret only as a ' + + '`credentialRef`, but none is a home a literal moves into by shape: `oauth2` (grant ' + + '`authorization_code` or `client_credentials`) and `app-credential` (vendor `feishu`, ' + + '`dingtalk` or `wecom`) carry a grant whose token the server obtains per dispatch, and until it ' + + 'runs that grant such an instance is held degraded and calls no upstream; `signed` signs each ' + + 'request with its key. A credential in `connectorConfig.input` is dropped instead, ' + 'because the connector authenticates through its own `auth`. No variant carries a secret in a ' + 'url path, so a url path is not judged and no fix line promises one.', // [#22161] What the fix line stopped saying. The variants are held to the diff --git a/skills/objectstack-automation/evals/integrations-declared.json b/skills/objectstack-automation/evals/integrations-declared.json index b9827409a40..a910975b500 100644 --- a/skills/objectstack-automation/evals/integrations-declared.json +++ b/skills/objectstack-automation/evals/integrations-declared.json @@ -4,7 +4,7 @@ { "id": 1, "prompt": "Our contract-lifecycle app (objects `contract`, `crm_account`) needs DocuSign: send a contract out for signature, read an envelope's status, and reflect status changes on the record. DocuSign publishes a 4.7 MB Swagger 2.0 document with several hundred operations, authenticates with OAuth 2.0, and its Connect webhooks sign posts with their own HMAC header. Propose what to build and where.", - "expected_output": "A declarative instance inside the app, not a platform plugin and not a `connector-esign` family: `defineConnector` with `provider: 'openapi'`, whose `providerConfig.spec` is a packaged OpenAPI 3.x document converted offline from the Swagger 2.0 document and trimmed to the three operations (create envelope, get envelope, list status changes) (or `providerConfig.include` names those three operation keys, the `mcp` provider's allowlist meaning, where a non-array or unknown entry is fatal). Fields on `contract` (`envelope_id`, `signature_status`). Flows dispatch `connector_action`; the callback is an `api` flow with a start-node `secretRef`. Two gaps are named plainly, each blocked on its platform card: the declarative `auth` union has no `oauth2`, so the integration is marked **blocked** on outbound OAuth (no short-lived token parked behind a `bearer` `credentialRef`); DocuSign Connect signs its own header, which the `api` door does not verify, so status is polled until vendor schemes are verified. `connectorConfig.input` is a value slot: the per-contract payload reads the record through a CEL envelope (`envelopeId: { dialect: 'cel', source: 'record.envelope_id' }`), judged against the action's `inputSchema` before dispatch. The answer proposes no `registerConnector` code and no plugin.", + "expected_output": "A declarative instance inside the app, not a platform plugin and not a `connector-esign` family: `defineConnector` with `provider: 'openapi'`, whose `providerConfig.spec` is a packaged OpenAPI 3.x document converted offline from the Swagger 2.0 document and trimmed to the three operations (create envelope, get envelope, list status changes) (or `providerConfig.include` names those three operation keys, the `mcp` provider's allowlist meaning, where a non-array or unknown entry is fatal). Fields on `contract` (`envelope_id`, `signature_status`). Flows dispatch `connector_action`; the callback is an `api` flow with a start-node `secretRef`. Two gaps are named plainly, each blocked on its platform card: the declarative `auth` union declares `oauth2` (`grant: 'authorization_code'`, the client secret only a `credentialRef`), but no server runs the grant yet, so such an instance is held degraded and calls no upstream: the integration is marked **blocked** on outbound OAuth (no short-lived token parked behind a `bearer` `credentialRef`); DocuSign Connect signs its own header, which the `api` door does not verify, so status is polled until vendor schemes are verified. `connectorConfig.input` is a value slot: the per-contract payload reads the record through a CEL envelope (`envelopeId: { dialect: 'cel', source: 'record.envelope_id' }`), judged against the action's `inputSchema` before dispatch. The answer proposes no `registerConnector` code and no plugin.", "files": [], "assertions": { "must_contain": ["defineConnector", "provider: 'openapi'", "credentialRef", "connector_action", "type: 'api'", "secretRef", "Swagger 2.0", "blocked"], diff --git a/skills/objectstack-automation/references/integration-recipes.md b/skills/objectstack-automation/references/integration-recipes.md index 68385555d19..e545399824e 100644 --- a/skills/objectstack-automation/references/integration-recipes.md +++ b/skills/objectstack-automation/references/integration-recipes.md @@ -29,15 +29,16 @@ deployment and to the sender. Scaffolded apps list the three executors in `mcp`: `{ ok, content, structuredContent? }`), read from a later node's value slot. - `auth`, each variant with a `credentialRef`: `bearer`; `api-key` (`headerName`, default `X-API-Key`, or `paramName` for a query-string key); `basic` (`username` - stays in metadata). A secret in a url path has no variant — use a + stays in metadata); `signed` on `openapi` / `rest` (`scheme` + `aliyun-acs3-hmac-sha256` or `tencentcloud-tc3-hmac-sha256`, `keyId`) signs each + request. A secret in a url path has no variant — use a token-authenticated connector. `authentication: { token }` is refused at `os validate`. - `defineConnector`: `@objectstack/spec/integration`; `defineFlow`: `@objectstack/spec`; both register in `defineStack`. ## 1 · OpenAPI document, static key — Stripe balance, nightly ```ts -// Trim the vendor's OpenAPI 3.x document to the operations the app calls; its path -// is package-relative. +// Trim the vendor's OpenAPI 3.x document to the operations the app calls. export const StripeConnector = defineConnector({ name: 'stripe', label: 'Stripe', type: 'api', provider: 'openapi', providerConfig: { spec: './src/connectors/stripe.trimmed.json' }, @@ -78,7 +79,7 @@ export const GitHubMcp = defineConnector({ providerConfig: { transport: { kind: 'http', url: 'https://api.githubcopilot.com/mcp/', // `stdio` spawns a local process: headers: { 'X-MCP-Readonly': 'true' } }, // denied unless the host allowlists the command - include: ['search_issues'], // tool-name allowlist → the actions + include: ['search_issues'], }, auth: { type: 'bearer', credentialRef: 'GITHUB_MCP_TOKEN' }, // folded into the request headers }); @@ -125,7 +126,7 @@ export const CarrierCallback = defineFlow({ `x-objectstack-signature: t=,v1=` — HMAC-SHA256 of `.` under `CARRIER_HOOK_SECRET`. The door has no unsigned form and verifies no other scheme, so a vendor whose webhook signs its own header cannot post here today: -name the gap and poll its status (recipe 1's shape) until vendor schemes verify. +poll its status (recipe 1's shape) until vendor schemes verify. ## 5 · Vendor with no machine-readable spec — write the document by hand @@ -148,9 +149,10 @@ Each `operationId` is a named action in the flow palette and to agents; ## Not yet — declare the rest, mark the integration blocked, write no plugin -- **OAuth-protected vendor** (DocuSign, Salesforce, Google): the declarative `auth` - union has no `oauth2`; the authorization-code / refresh - lifecycle is not delivered in the open tier. Do not park a short-lived access - token behind a `bearer` ref. Blocked on the platform's outbound-OAuth card. -- **Signature-authenticated vendor** (Alipay, WeChat Pay, Aliyun — RSA2 / HMAC - canonical-request signing): no auth kind signs requests today; blocked on the request-signing card. +- **OAuth-protected vendor** (DocuSign, Salesforce, Google): `auth` declares + `oauth2` (`authorization_code` / `client_credentials`) and `app-credential` + (`feishu` / `dingtalk` / `wecom`), but no server runs the grant yet: the + instance is held degraded, calling no upstream. Do not park a short-lived + token behind a `bearer` ref. Blocked on the outbound-OAuth card. +- **RSA-signing vendor** (Alipay, WeChat Pay): no `signed` scheme signs RSA yet; + blocked on the request-signing card.