diff --git a/.changeset/20790-flow-credential-channel.md b/.changeset/20790-flow-credential-channel.md new file mode 100644 index 00000000000..0f65579db7c --- /dev/null +++ b/.changeset/20790-flow-credential-channel.md @@ -0,0 +1,27 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +'@objectstack/metadata-protocol': minor +'@objectstack/trigger-api': minor +'@objectstack/runtime': minor +--- + +feat(automation): a flow's credentials live in a write-only channel, not in its stored definition (#20790) + +Clause-②: yes (widening) + +A flow's two credentials, an inbound hook's `secret` on its start node and an `http` node's `signingSecret`, are no longer stored in the flow definition. The metadata save door moves each explicit value into a new platform object, `sys_flow_credential`, owned by `@objectstack/service-automation`. Its one field is `type: 'secret'`, so the engine encrypts it through the host crypto provider, masks it on every read, and dereferences it only through `resolveSecretField`. This is the same seam the webhook signing secret uses. The stored row, every new version-history row and the row's content hash carry no credential. The engine reads the value only when it verifies an inbound post or signs an outbound request. Authoring does not change: you still write the literal, a save that leaves the key out (the form every read serves) keeps the stored secret, `''` clears it, and only an explicit new value rotates it. + +**⚠️ Rotate every inbound and outbound flow secret that existed before this release.** On the first boot with a crypto provider, or when a provider registers after a boot without one, each stored flow that still carries a credential is moved into the channel once, and the log prints one notice per flow: `[Automation] flow '' (): … was stored in cleartext … ROTATE: …`. The move guarantees no new copy, but the version-history rows and audit snapshots written before it stay as they were (both are append-only), so an administrator could have read those values. To rotate, save the flow with a new `config.secret` / `config.signingSecret`, then give the new value to whoever signs posts to the hook or verifies its deliveries. The run is recorded in `sys_migration` as `flow-credential-channel` (flow names only, never values). Packaged flows are not moved: a packaged flow's literal stays its source of truth, and where the channel holds a row for it, the row wins at verification. + +What else changes: + +- **`@objectstack/spec`**: `PLATFORM_OBJECTS_BY_PACKAGE['service-automation']` lists `sys_flow_credential`. +- **`@objectstack/metadata-protocol`**: `registerCredentialChannel(type, channel)` registers a type's write-only credential channel (exported type `MetadataCredentialChannel`). `saveMetaItem` stores the body the channel returns, after the carry-forward and before the put. The runtime authoring gate reads the channel's held positions as present, on an active save and when a draft is published. `SysMetadataRepository.restoreVersion` takes `deriveRestoredBody`, shaped like `promoteDraft`'s `deriveActiveBody`. Rollback and revert pass the channel's strip, so restoring a version written before the move never puts its credential back at rest, and the channel keeps its current credential. +- **`@objectstack/service-automation`**: exports `SysFlowCredential`, `FlowCredentialChannel` and `migrateFlowCredentialsIntoChannel`. `AutomationEngine` gains `setFlowCredentialSource`, `holdsFlowCredential`, `resolveFlowCredential` and `flowCredentialHoldings`. An `api` binding carries `resolveSecret()`, which reads the secret at verification time, so a rotation applies to the next post. A draft save never rotates the live secret; publishing the draft promotes it. Deleting a flow's stored row drops its credentials. +- **`@objectstack/trigger-api`**: `FlowTriggerBinding.resolveSecret` arms a hook without a literal. A post whose secret cannot be read is answered `503 SERVICE_UNAVAILABLE` and is never verified against nothing. +- **Refused now, loudly**: + - With no crypto provider, a save that carries a flow credential is refused with `503 SERVICE_UNAVAILABLE` before anything is written. Register a provider (`setCryptoProvider`) and save again. + - The clone door (`POST /api/v1/automation/:name/clone`) refuses a source that holds a credential, as a literal or in the channel, with `409 RESOURCE_CONFLICT`, because a copy would share it. ⚠️ Accepted cost: a packaged inbound flow can no longer be cloned in one step. Author the copy as a new flow under a new name, with its own secret. + + diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 0cc96ea58bd..ea3f771b20a 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -271,7 +271,7 @@ nothing is assigned or written in its place. ``` -A flow definition, including an `http` node's `url` and `headers`, is served to every member who can read flows. A token, API key or signed webhook url written there is readable by all of them. Route an outbound credential by where it sits, and call the connector with a `connector_action` node instead — see [Connectors](/docs/automation/connectors#authentication). A credential in a header goes to a declarative connector with `bearer`, `basic` or `api-key` auth, whose `auth.credentialRef` names the secret. A key in the query string goes to `api-key` auth with `paramName`. No `credentialRef` variant carries a secret in the url path, so an incoming-webhook url (whose path is the secret) cannot be routed that way: call the service through a token-authenticated connector instead, such as the `slack` connector, whose bot token is supplied to the plugin by host code rather than written in the flow (it is registered by that plugin, not declared as a `connectors:` instance). Otherwise such a url is served with the definition. Only `signingSecret` (and a start node's `secret`) is withheld when a definition is served; `url` and `headers` are served as written. +A flow definition, including an `http` node's `url` and `headers`, is served to every member who can read flows. A token, API key or signed webhook url written there is readable by all of them. Route an outbound credential by where it sits, and call the connector with a `connector_action` node instead — see [Connectors](/docs/automation/connectors#authentication). A credential in a header goes to a declarative connector with `bearer`, `basic` or `api-key` auth, whose `auth.credentialRef` names the secret. A key in the query string goes to `api-key` auth with `paramName`. No `credentialRef` variant carries a secret in the url path, so an incoming-webhook url (whose path is the secret) cannot be routed that way: call the service through a token-authenticated connector instead, such as the `slack` connector, whose bot token is supplied to the plugin by host code rather than written in the flow (it is registered by that plugin, not declared as a `connectors:` instance). Otherwise such a url is served with the definition. Only `signingSecret` and a start node's `secret` are kept out of a flow saved through the metadata API: the metadata save door stores each in a write-only, encrypted flow credential store, a read never returns it, and the engine reads it only to verify an inbound post or sign an outbound request. A packaged flow keeps its literal in its package source. The literal is withheld when the definition is served, and a credential store row for that flow, where one exists, wins when the engine verifies or signs. `url` and `headers` are stored and served as written. **Script:** diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index be6ce4af08c..c2eee1b41a4 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -134,7 +134,7 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/ | Type | Sanctioned path the refusal names | | :--- | :--- | -| `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). | +| `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. | | `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. | | `permission` | Clone it under a new name: the "Clone" action on the permission set, or `POST /api/v1/data/sys_permission_set` with a new name. | diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 599727a062c..1c6b642827b 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -83,7 +83,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 44 of them — just under a fifth +typed `any` have no type to read, and there are 48 of them — just over a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **67 of the 219 sites are spelled that way**. A +forwarding shim cannot, and **67 of the 229 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **219** | +| 175 write call sites | quoted in the merged changeset | **229** | | 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **34** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **144 of 219** decidable, **75** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 100 decidably elevated, 0 decidably not, 102 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **152 of 229** decidable, **77** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 110 decidably elevated, 0 decidably not, 102 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -200,18 +200,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 44 sites reached -through an erased (`any`) receiver, and the 43 that name their object through a +one, and both are counted in the generated tables below: the 48 sites reached +through an erased (`any`) receiver, and the 51 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -100 of 219 (46%) as decidably elevated, with 102 more whose elevation is a +110 of 229 (48%) as decidably elevated, with 102 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `9 / 219`, and say what it is**: the sites whose options argument was +⇒ **Cite `9 / 229`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **34 further sites** have an options argument this @@ -223,31 +223,31 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **219** | -| …whose object name is statically decidable | 144 | -| …whose object name is chosen at run time | 75 | -| …against an object with tenancy ENABLED | 143 | +| write call sites on the application surface | **229** | +| …whose object name is statically decidable | 152 | +| …whose object name is chosen at run time | 77 | +| …against an object with tenancy ENABLED | 151 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 135 | +| threading a tenant context | 145 | | PROVABLY carrying none (options read, no context key) | **17** | | …of those, against a decidably tenancy-enabled object | **9** | | options argument UNREADABLE — may or may not carry one | 67 | | …of those, against a decidably tenancy-enabled object | 34 | -| threading a decidably ELEVATED (`isSystem`) context | 100 | +| threading a decidably ELEVATED (`isSystem`) context | 110 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 102 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 175 | -| receiver erased, placed by the object NAME | 24 | +| receiver carried a readable engine type | 181 | +| receiver erased, placed by the object NAME | 28 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | | object name spelled inline | 101 | -| object name spelled through a `const` | 43 | +| object name spelled through a `const` | 51 | | object name is an `object: string` parameter | 17 | -| object name is some other run-time expression | 58 | +| object name is some other run-time expression | 60 | ### Subtractions the census could NOT defend — enforced @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-01 at `752173845`. +Measured on 2026-10-02 at `c41817b12`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 586 | -| engine-shaped types recognised | 64 | -| declared objects in the registry | 115 | -| same-named calls subtracted as non-engine | 145 | +| tracked non-test sources scanned | 599 | +| engine-shaped types recognised | 66 | +| declared objects in the registry | 116 | +| same-named calls subtracted as non-engine | 150 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 0ed5ead58b6..69d9ce0a7f2 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 219 | -| Object name statically decidable | 144 | -| Object name chosen at run time | 75 | -| Against a tenancy-enabled object | 143 | +| Write call sites | 229 | +| Object name statically decidable | 152 | +| Object name chosen at run time | 77 | +| Against a tenancy-enabled object | 151 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 135 | +| Threading a tenant context | 145 | | Provably carrying none | 17 | | …and decidably tenancy-enabled | 9 | | Options argument unreadable | 67 | | …and decidably tenancy-enabled | 34 | -| Threading a decidably elevated context | 100 | +| Threading a decidably elevated context | 110 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 102 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-01 at `752173845`. +Measured on 2026-10-02 at `c41817b12`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 586 | -| engine-shaped types recognised | 64 | -| declared objects in the registry | 115 | -| same-named calls subtracted as non-engine | 145 | +| tracked non-test sources scanned | 599 | +| engine-shaped types recognised | 66 | +| declared objects in the registry | 116 | +| same-named calls subtracted as non-engine | 150 | ## Every site @@ -196,6 +196,11 @@ Measured on 2026-10-01 at `752173845`. | `packages/services/service-automation/src/builtin/crud-nodes.ts` | `delete` | `objectName` | undecidable | context, elevation undecidable | 1 | | `packages/services/service-automation/src/builtin/crud-nodes.ts` | `insert` | `objectName` | undecidable | context, elevation undecidable | 1 | | `packages/services/service-automation/src/builtin/crud-nodes.ts` | `update` | `objectName` | undecidable | context, elevation undecidable | 1 | +| `packages/services/service-automation/src/flow-credential-channel.ts` | `delete` | `sys_flow_credential` | enabled | elevated | 5 | +| `packages/services/service-automation/src/flow-credential-channel.ts` | `insert` | `sys_flow_credential` | enabled | elevated | 1 | +| `packages/services/service-automation/src/flow-credential-channel.ts` | `update` | `sys_flow_credential` | enabled | elevated | 2 | +| `packages/services/service-automation/src/flow-credential-migration.ts` | `insert` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | +| `packages/services/service-automation/src/flow-credential-migration.ts` | `update` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/services/service-automation/src/flow-dispatch-store.ts` | `insert` | `sys_flow_dispatch` | enabled | elevated | 1 | | `packages/services/service-automation/src/flow-dispatch-store.ts` | `update` | `sys_flow_dispatch` | enabled | elevated | 1 | | `packages/services/service-automation/src/suspended-run-store.ts` | `delete` | `sys_automation_run` | enabled | elevated | 3 | diff --git a/packages/metadata-protocol/src/index.ts b/packages/metadata-protocol/src/index.ts index 03c68f9dc4b..ae8e39db97f 100644 --- a/packages/metadata-protocol/src/index.ts +++ b/packages/metadata-protocol/src/index.ts @@ -148,7 +148,7 @@ export type { ClusterMetadataMutationPayload } from './protocol.js'; // kernel-wide `metadata:reloaded` announce. Exported for the same reason its // mutation sibling is: the subscriber lives in another package. export type { MetaItemPublishedEvent } from './protocol.js'; -export type { MetadataAuthoringGate, MetadataAuthoringGateContext } from './protocol.js'; +export type { MetadataAuthoringGate, MetadataAuthoringGateContext, MetadataCredentialChannel } from './protocol.js'; export { SysMetadataRepository, resetEnvWritableMetadataTypes } from './sys-metadata-repository.js'; export type { diff --git a/packages/metadata-protocol/src/protocol.credential-channel.test.ts b/packages/metadata-protocol/src/protocol.credential-channel.test.ts new file mode 100644 index 00000000000..1c580e2a3a4 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.credential-channel.test.ts @@ -0,0 +1,279 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20790] The metadata protocol's half of a type's WRITE-ONLY credential + * channel (`registerCredentialChannel`, beside the authoring gate): every + * write that lands a body at rest consults it, so a credential never reaches + * the stored row, its history, or the row's content hash. + * + * - **the save door** stores what the channel's `store` returns, after the + * carry-forward and immediately before the put — and a channel refusal (no + * crypto provider) throws before anything is written; + * - **the runtime gate**, on an active save and on the draft → active + * promotion, reads the channel's held positions as present, so a withheld + * credential the channel keeps is not refused as missing; + * - **a restore (R2)** stores the channel's strip of the history body: a + * rollback past the move never puts a credential back at rest, never + * appends a history copy of one, and never writes the channel. + * + * The channel here is a stand-in for the automation plugin's (whose own + * behaviour is pinned in `@objectstack/service-automation`), holding the start + * node's `secret` only. The repository is the real `SysMetadataRepository` + * over a fake engine that keeps both the stored rows and the history rows. + * + * Every value is a probe sentinel, not a credential. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + assertEngineDeleteDispatch, + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, +} from '@objectstack/metadata-core'; +import { registerMetadataTypeRedactor } from '@objectstack/spec/kernel'; + +import { ObjectStackProtocolImplementation, type MetadataCredentialChannel } from './protocol.js'; + +const V1 = 'pin-protocol-secret-v1-3f1a'; +const V2 = 'pin-protocol-secret-v2-b07c'; +const DRAFT = 'pin-protocol-secret-draft-77e2'; + +type Row = Record; + +/** Exact-equality WHERE matching; an operator this stand-in does not implement is refused, never read as a field. */ +function matchesWhere(row: Row, where: Record = {}): boolean { + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`); + return v === undefined || row[k] === v; + }); +} + +/** Rows AND history rows, exact-equality predicates, the producer's own write-verb dispatch. */ +function makeEngine() { + const rows: Row[] = []; + const historyRows: Row[] = []; + /** Every other table: reads find nothing, as the stand-in has none. */ + const others: Row[] = []; + let next = 1; + const tableOf = (t: string) => (t === 'sys_metadata_history' ? historyRows : t === 'sys_metadata' ? rows : null); + const matches = matchesWhere; + const engine: any = { + async find(t: string, opts: { where?: Record; limit?: number } = {}) { + const table = t === 'sys_metadata_history' ? historyRows : t === 'sys_metadata' ? rows : others; + const hits = table.filter((r) => matchesWhere(r, opts.where)); + return typeof opts?.limit === 'number' ? hits.slice(0, opts.limit) : hits; + }, + async findOne(t: string, opts: { where: Record }) { + assertEngineFindOnePredicate(t, opts); + return (tableOf(t) ?? []).find((r) => matches(r, opts.where)) ?? null; + }, + async insert(t: string, data: Row) { + const table = tableOf(t); + const id = (data.id as string | undefined) ?? `r_${next++}`; + table?.push({ ...data, id }); + return { id }; + }, + async update(t: string, data: Row, opts: { where: Record }) { + assertEngineUpdateDispatch(data, opts); + const row = (tableOf(t) ?? []).find((r) => matches(r, opts.where)); + if (row) Object.assign(row, data); + return { id: row?.id ?? null }; + }, + async delete(t: string, opts: { where: Record }) { + assertEngineDeleteDispatch(opts); + const table = tableOf(t); + const idx = table ? table.findIndex((r) => matches(r, opts.where)) : -1; + if (table && idx >= 0) table.splice(idx, 1); + return { deleted: idx >= 0 ? 1 : 0 }; + }, + async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + async syncObjectSchema() {}, + registry: { + listItems: () => [], + isPackageDisabled: () => false, + getItem: () => undefined, + registerItem: () => {}, + registerObject: () => {}, + getPackage: () => undefined, + }, + }; + return { engine, rows, historyRows }; +} + +/** The start node's `secret` is the one credential position this stand-in table knows. */ +const startIndex = (body: any) => (Array.isArray(body?.nodes) ? body.nodes.findIndex((n: any) => n?.type === 'start') : -1); + +function withoutSecret(body: any): any { + const i = startIndex(body); + if (i < 0 || !body.nodes[i].config || !('secret' in body.nodes[i].config) || body.nodes[i].config.secret === '') return body; + const nodes = body.nodes.slice(); + const { secret: _s, ...config } = nodes[i].config; + void _s; + nodes[i] = { ...nodes[i], config }; + return { ...body, nodes }; +} + +/** A stand-in for the automation plugin's channel: `${name}|${state}` → secret. */ +function makeChannel(opts: { refuse?: boolean } = {}) { + const held = new Map(); + const calls: string[] = []; + const channel: MetadataCredentialChannel = { + async store({ name, state, body }) { + calls.push(`store:${state}`); + const i = startIndex(body); + const secret = i >= 0 ? (body as any).nodes[i].config?.secret : undefined; + if (typeof secret === 'string' && secret !== '') { + if (opts.refuse) { + throw Object.assign(new Error('no crypto provider'), { code: 'SERVICE_UNAVAILABLE', status: 503 }); + } + held.set(`${name}|${state}`, secret); + } + return withoutSecret(body); + }, + async heldPaths({ name, state, item }) { + calls.push(`heldPaths:${state}`); + const i = startIndex(item); + if (i < 0 || (item as any).nodes[i].config?.secret !== undefined) return []; + const has = held.has(`${name}|active`) || (state === 'draft' && held.has(`${name}|draft`)); + return has ? [`nodes.${i}.config.secret`] : []; + }, + strip(body) { + calls.push('strip'); + return withoutSecret(body); + }, + }; + return { channel, held, calls }; +} + +/** The read projection the automation plugin registers for `flow`, start-node half. */ +function registerStandInRedactor(): void { + registerMetadataTypeRedactor('flow', (item) => { + const out = withoutSecret(item); + const i = startIndex(item); + return { item: out, redactedKeys: out === item ? [] : [`nodes.${i}.config.secret`] }; + }); +} + +function inbound(secret?: string, label = 'Inbound') { + return { + name: 'channel_intake', + label, + type: 'api', + status: 'active', + nodes: [ + { id: 'begin', type: 'start', label: 'Start', config: { hookId: 'h1', ...(secret !== undefined ? { secret } : {}) } }, + { id: 'finish', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'begin', target: 'finish' }], + }; +} + +const bodiesOf = (table: Row[]) => table.filter((r) => r.name === 'channel_intake').map((r) => String(r.metadata)); + +describe('[#20790] the save door stores the body the credential channel returns', () => { + beforeEach(registerStandInRedactor); + + it('the stored row, its history row and its hash carry no credential; the channel holds it', async () => { + const { engine, rows, historyRows } = makeEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + const { channel, held } = makeChannel(); + protocol.registerCredentialChannel('flows', channel); + + await protocol.saveMetaItem({ type: 'flow', name: 'channel_intake', item: inbound(V1) }); + + expect(held.get('channel_intake|active')).toBe(V1); + expect(bodiesOf(rows)).toHaveLength(1); + for (const body of [...bodiesOf(rows), ...bodiesOf(historyRows)]) expect(body).not.toContain(V1); + // The hash is over the stored body, so it is no verifier of the secret either. + const stored = rows.find((r) => r.name === 'channel_intake')!; + expect(String(stored.checksum ?? '')).not.toBe(''); + }); + + it('an active save in the WITHHELD form passes the runtime gate on the channel\'s held position', async () => { + const { engine } = makeEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + const { channel, held, calls } = makeChannel(); + protocol.registerCredentialChannel('flow', channel); + await protocol.saveMetaItem({ type: 'flow', name: 'channel_intake', item: inbound(V1) }); + + // The served form round-trips: no secret in the body, none refused. + await expect( + protocol.saveMetaItem({ type: 'flow', name: 'channel_intake', item: inbound(undefined, 'Edited') }), + ).resolves.toMatchObject({ success: true }); + expect(calls).toContain('heldPaths:active'); + expect(held.get('channel_intake|active')).toBe(V1); + }); + + it('a channel refusal (no crypto provider) refuses the save before anything is written', async () => { + const { engine, rows, historyRows } = makeEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + protocol.registerCredentialChannel('flow', makeChannel({ refuse: true }).channel); + + const refusal = await protocol + .saveMetaItem({ type: 'flow', name: 'channel_intake', item: inbound(V1) }) + .then(() => undefined, (e: any) => e); + expect(refusal?.code).toBe('SERVICE_UNAVAILABLE'); + expect(refusal?.status).toBe(503); + expect(bodiesOf(rows)).toEqual([]); + expect(bodiesOf(historyRows)).toEqual([]); + }); +}); + +describe('[#20790] the draft → active promotion reads the channel\'s held positions', () => { + beforeEach(registerStandInRedactor); + + it('publishing a draft whose secret the channel holds is not refused as missing', async () => { + const { engine, rows } = makeEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + const { channel, held, calls } = makeChannel(); + protocol.registerCredentialChannel('flow', channel); + + await protocol.saveMetaItem({ type: 'flow', name: 'channel_intake', item: inbound(V1) }); + await protocol.saveMetaItem({ type: 'flow', name: 'channel_intake', item: inbound(DRAFT, 'Drafted'), mode: 'draft' }); + expect(held.get('channel_intake|draft')).toBe(DRAFT); + // The live row is untouched by the draft save. + expect(held.get('channel_intake|active')).toBe(V1); + + await expect(protocol.publishMetaItem({ type: 'flow', name: 'channel_intake' })).resolves.toMatchObject({ success: true }); + expect(calls).toContain('heldPaths:draft'); + for (const body of bodiesOf(rows)) expect(body).not.toContain(DRAFT); + }); +}); + +describe('[#20790] R2 — a rollback past the move keeps the channel\'s current secret and stores none', () => { + beforeEach(registerStandInRedactor); + + it('restores version 1 (written before the move, with the literal) without putting it back at rest', async () => { + const { engine, rows, historyRows } = makeEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + + // v1 — stored the way every flow was before the channel existed. + await protocol.saveMetaItem({ type: 'flow', name: 'channel_intake', item: inbound(V1) }); + expect(bodiesOf(historyRows).join()).toContain(V1); + + // The channel arrives; v2 moves the secret (rotated) out of the body. + const { channel, held, calls } = makeChannel(); + protocol.registerCredentialChannel('flow', channel); + await protocol.saveMetaItem({ type: 'flow', name: 'channel_intake', item: inbound(V2, 'Moved') }); + expect(held.get('channel_intake|active')).toBe(V2); + const historyBefore = historyRows.length; + calls.length = 0; + + const result: any = await protocol.rollbackMetaItem({ type: 'flow', name: 'channel_intake', toVersion: 1 }); + expect(result?.success ?? true).toBe(true); + + // The restored row is version 1's body WITHOUT its credential … + const stored = JSON.parse(String(rows.find((r) => r.name === 'channel_intake')!.metadata)); + expect(stored.label).toBe('Inbound'); + expect(JSON.stringify(stored)).not.toContain(V1); + // … the history row the restore appended carries none … + expect(historyRows.length).toBe(historyBefore + 1); + expect(String(historyRows[historyRows.length - 1]!.metadata)).not.toContain(V1); + // … the version written before the move keeps what it recorded (append-only, Q1 B) … + expect(String(historyRows[0]!.metadata)).toContain(V1); + // … and the channel keeps its current secret: a restore only strips. + expect(calls).toEqual(['strip']); + expect(held.get('channel_intake|active')).toBe(V2); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index a0c0a98f6a6..3d303b58b49 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -4647,6 +4647,48 @@ export interface MetadataAuthoringGateContext { } export type MetadataAuthoringGate = (ctx: MetadataAuthoringGateContext) => void | Promise; +/** + * [#20790] A metadata type's WRITE-ONLY credential channel — where the + * credentials its bodies carry are stored instead of in the body, on the + * platform's one secret seam (#7799: a `secret`-typed field the engine + * encrypts, masks on every read and dereferences only through + * `resolveSecretField`). Registered per type by the domain plugin that owns + * the type's credential-location table (the automation plugin holds `flow`'s), + * beside its authoring gate. + * + * Every write that lands a body at rest consults it: + * - `saveMetaItem` calls {@link store} immediately before the put, after the + * carry-forward, and persists what it returns; + * - the runtime authoring gate reads {@link heldPaths} as restored positions, + * on an active save and on the draft → active promotion; + * - a restore (rollback, revert) stores {@link strip}'s body. + * + * ⛔ Not a second secret mechanism and not a per-door redaction: the read + * projection stays the type's redactor; this only decides where a credential + * is STORED. + */ +export interface MetadataCredentialChannel { + /** + * Move every explicit credential in `body` into the channel for + * `(name, state)` and return the body without any — what is stored. An + * absent credential means "unchanged". THROWS (with an ADR-0112 + * `code`/`status`) to refuse the save; nothing may have been put then. + */ + store(args: { name: string; state: 'draft' | 'active'; body: unknown }): Promise; + /** + * The positions in `item` (dotted, item-relative — `redactedKeys`' + * spelling) whose credential is withheld from the body and held by the + * channel, so the gate reads them as present. `state: 'draft'` for a draft + * being promoted. + */ + heldPaths(args: { name: string; state: 'draft' | 'active'; item: unknown }): Promise; + /** + * The body a restore stores: every credential removed, and nothing written + * to the channel — it keeps its current credential. + */ + strip(body: unknown): unknown; +} + /** * Which authoring channel a kernel's metadata writes arrive on (#6710). * @@ -4907,6 +4949,9 @@ export class ObjectStackProtocolImplementation implements */ private authoringGates = new Map(); + /** [#20790] Per-type write-only credential channels — see {@link registerCredentialChannel}. */ + private credentialChannels = new Map(); + /** * Once-per-process dedupe for stored-row conversion notices * (`conversionId|type|name`). `getMetaItems`/`getMetaItem` re-read @@ -5154,6 +5199,35 @@ export class ObjectStackProtocolImplementation implements this.authoringGates.set(singular, gate); } + /** + * [#20790] Register the write-only credential channel for a metadata type + * (see {@link MetadataCredentialChannel}). Called by the domain plugin that + * owns the type's credentials — the automation plugin registers `flow`'s. + * Singular or plural type names both resolve; one channel per type, a + * second registration replaces the first (idempotent re-init). + */ + registerCredentialChannel(type: string, channel: MetadataCredentialChannel): void { + const singular = PLURAL_TO_SINGULAR[type] ?? type; + this.credentialChannels.set(singular, channel); + } + + /** [#20790] The registered credential channel of `type`, if any. */ + private credentialChannelFor(type: string): MetadataCredentialChannel | undefined { + return this.credentialChannels.get(PLURAL_TO_SINGULAR[type] ?? type); + } + + /** + * [#20790] R2 — the body-derivation a restore passes to + * `repo.restoreVersion`: the type's channel strip, so a restored version + * that still holds a credential (one written before the move) never puts + * it back at rest, and the channel keeps its current one. `undefined` for a + * type with no channel — the history body is restored byte for byte. + */ + private restoredBodyDerivation(type: string): ((body: unknown) => unknown) | undefined { + const channel = this.credentialChannelFor(type); + return channel ? (body) => channel.strip(body) : undefined; + } + /** * Run the registered authoring gate for an about-to-persist body (#3050). * No-op when no gate is registered for the type. A gate throw PROPAGATES @@ -5258,9 +5332,10 @@ export class ObjectStackProtocolImplementation implements * [#20611] How to learn the positions in `body` that this write's * carry-forward will fill from the stored row — the credentials the read * path withheld, which a body saved back after a read arrives without. - * Stated by `saveMetaItem`, the one door whose body can arrive that way; - * the draft→active promotion judges the stored draft row, which already - * holds what that draft's own save carried forward, so it states nothing. + * Stated by `saveMetaItem`, the one door whose body can arrive that way, + * and [#20790] by the draft→active promotion for a type with a + * write-only credential channel: the stored draft row holds what its own + * save carried forward, but not what that save moved into the channel. * * A function, called only once the gate is known to run (after the * early returns below): a draft save, the package-author channel and @@ -7776,8 +7851,16 @@ export class ObjectStackProtocolImplementation implements state: 'active', packageId: args.packageId, }); - if (!body) return []; - return redactedPathsCarriedForward(args.type, args.item, body); + const carried = body ? redactedPathsCarriedForward(args.type, args.item, body) : []; + // [#20790] A credential the write-only channel holds is withheld from + // the stored body too, so the carry-forward restores nothing there — + // and it is still present: the channel keeps it across this save. + const held = await this.credentialChannelFor(args.type)?.heldPaths({ + name: args.name, + state: 'active', + item: args.item, + }); + return held && held.length > 0 ? [...new Set([...carried, ...held])] : carried; } /** @@ -17732,6 +17815,25 @@ export class ObjectStackProtocolImplementation implements packageId: request.packageId ?? null, item: request.item, }); + // [#20790] …and then OUT of the body: a type with a write-only + // credential channel (`flow`, registered by the automation plugin) + // stores every explicit credential there and persists the body without + // it — the bytes a read serves. Last, after the carry-forward, so a + // credential the stored row still held (one written before the channel + // existed) moves with this save instead of being dropped. A refusal + // (no crypto provider) throws before the put: nothing is written. + // ⚠️ The channel write precedes the put, so a put that then fails (a + // version conflict) leaves the new credential in the channel. + { + const channel = this.credentialChannelFor(singularTypeForRepo); + if (channel) { + request.item = await channel.store({ + name: request.name, + state: mode === 'draft' ? 'draft' : 'active', + body: request.item, + }); + } + } try { const result = await repo.put(ref, request.item, { parentVersion, @@ -18921,6 +19023,20 @@ export class ObjectStackProtocolImplementation implements // different narrowings, both needed for a package to be // judged as a self-consistent unit. ...(request.pending !== undefined ? { pending: request.pending } : {}), + // [#20790] The publish gate's restored-credential read. The + // stored draft holds no credential the write-only channel + // holds — the draft's own save moved it there — so the gate is + // told where one is held (the draft's row, or the live one the + // promotion keeps) and reads it as present. + ...(this.credentialChannelFor(singularType) + ? { + restoredCredentialPaths: () => this.credentialChannelFor(singularType)!.heldPaths({ + name: request.name, + state: 'draft', + item: draftForGate.body, + }), + } + : {}), }) : []; @@ -22038,11 +22154,14 @@ export class ObjectStackProtocolImplementation implements // the shape that ends in a `catch {}` swallowing a real outage // (#4867). Per ITEM, because a batch mixes bindings. const restorePackageId = await this.resolveOverlayPackageBinding(it.type, it.name, itemOrgId); + const restoreDerivation = this.restoredBodyDerivation(it.type); const restored = await repo.restoreVersion(ref, restoreToVersion, { actor, source: 'protocol.revertCommit', message: `revert commit ${request.commitId}`, intent, + // [#20790] R2 — the type's credential-channel strip. + ...(restoreDerivation ? { deriveRestoredBody: restoreDerivation } : {}), }); // [#6621] #4521 — a revert is a live write like any other: the // restored body must be the one the runtime dispatches on @@ -22428,12 +22547,16 @@ export class ObjectStackProtocolImplementation implements // real outage (#4867). const rollbackPackageId = await this.resolveOverlayPackageBinding(singularType, request.name, orgId); try { + const restoreDerivation = this.restoredBodyDerivation(singularType); const result = await repo.restoreVersion(ref, request.toVersion, { // #4556 — NULL, not 'system', for an actor-less rollback. actor: request.actor ?? null, source: 'protocol.rollbackMetaItem', ...(request.message ? { message: request.message } : {}), intent, + // [#20790] R2 — a rollback past the credential move keeps the + // write-only channel's current credential and stores none. + ...(restoreDerivation ? { deriveRestoredBody: restoreDerivation } : {}), }); // #4521 — a rollback is a live write like any other: the restored // body must be the one the runtime dispatches on immediately, not diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 58130dc596d..a644551edfc 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -1023,7 +1023,25 @@ export class SysMetadataRepository implements MetadataRepository { async restoreVersion( ref: MetaRef, targetVersion: number, - opts: { actor: string | null; source?: string; message?: string; intent?: MetadataWriteIntent }, + opts: { + actor: string | null; + source?: string; + message?: string; + intent?: MetadataWriteIntent; + /** + * [#20790] The body the ACTIVE row stores, derived from the history body + * this restore read — the shape {@link promoteDraft}'s `deriveActiveBody` + * has, applied the same way: to the row actually restored, inside the + * same call, before the put hashes it. The protocol passes the type's + * write-only credential channel strip (R2), so restoring a version + * written before a credential moved out of the body never puts the + * credential back at rest, nor appends a history copy of it. Return the + * argument unchanged when there is nothing to derive. + * + * Omitted → the history body is restored byte for byte, as before. + */ + deriveRestoredBody?: (historyBody: unknown) => unknown; + }, ): Promise<{ version: string; seq: number; item: MetadataItem }> { this.assertOpen(); const full = this.fullRef(ref); @@ -1052,7 +1070,8 @@ export class SysMetadataRepository implements MetadataRepository { err.status = 409; throw err; } - const body = typeof raw === 'string' ? JSON.parse(raw) : (raw as Record); + const historyBody = typeof raw === 'string' ? JSON.parse(raw) : (raw as Record); + const body = opts.deriveRestoredBody ? opts.deriveRestoredBody(historyBody) : historyBody; // ADR-0048 / #6215 — read the RAW active row, not just its body, and carry // its `package_id` into the write. `put` upserts exactly ONE row and scopes // its optimistic-lock lookup by package; an unstated `packageId` resolves to diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index c2036afb2a0..ea378969329 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -44,6 +44,7 @@ "@objectstack/driver-sql": "workspace:*", "@objectstack/driver-sqlite-wasm": "workspace:*", "@objectstack/driver-turso": "workspace:*", + "@objectstack/trigger-api": "workspace:*", "@types/node": "^26.6.3", "typescript": "^6.0.3", "vitest": "^4.1.11" diff --git a/packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts b/packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts new file mode 100644 index 00000000000..5ec39524e63 --- /dev/null +++ b/packages/qa/dogfood/test/flow-credential-channel.dogfood.test.ts @@ -0,0 +1,375 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20790] A flow's credentials live in a WRITE-ONLY channel, not in its + * stored definition — on the real composition (CRM app, ObjectQL over SQLite, + * security, REST and the dispatcher, the automation service, the host crypto + * provider the harness wires exactly as `os serve` does), driven through the + * real doors. + * + * Before this, an inbound flow authored through the metadata save door stored + * its hook secret — and an `http` node its outbound signing secret — in + * cleartext in the stored row, every version-history row and the row's + * content hash, and each read exit had to project them away one door at a + * time. Pinned here, each against the door that used to leak or must keep + * working: + * + * 1. no read surface returns a credential — the stored row, the history row, + * an administrator's engine read (the reader the MCP stdio transport + * uses), the generic data door, `/meta`; the channel itself is masked and + * closed to the data door; + * 2. the inbound door verifies with the original secret after the move and + * after an edit-and-republish in the withheld form; + * 3. an explicit rotation replaces it, on the next post; + * 4. a draft save never rotates the live hook; publishing the draft does; + * 5. a flow stored before the move is moved once (history stays + * append-only, a receipt is written) — and a rollback past the move keeps + * the channel's current secret and stores none (R2); + * 6. the clone door refuses a source holding a credential, channel-held or + * literal (C1); + * 7. with no crypto provider a save carrying a credential is refused before + * anything is written; + * 8. deleting the flow drops its credential — a new flow of the same name + * inherits nothing; + * 9. Q3 A: a packaged literal hook verifies on its literal when the + * credential store is unreachable — the channel is asked only for a + * position it holds; + * 10. the control: a HELD hook secret whose store becomes unreachable is + * answered 503, never verified against the literal. + * + * The inbound door is the real `ApiTrigger` (`@objectstack/trigger-api`), + * registered on the real engine with an in-memory queue, so `202` means the + * signature verified and the post was enqueued. Every value is a probe + * sentinel, not a credential. + */ +import { createHmac } from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { stripReadDecorations } from '@objectstack/spec/kernel'; +import { ApiTrigger } from '@objectstack/trigger-api'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const FLOW = 'zz_channel_hook'; +const LEGACY = 'zz_channel_legacy'; +const HOOK_V1 = 'pin-dogfood-hook-v1-2c7d'; +const HOOK_V2 = 'pin-dogfood-hook-v2-9a41'; +const HOOK_DRAFT = 'pin-dogfood-hook-draft-5f08'; +const SIGN_V1 = 'pin-dogfood-sign-v1-e613'; +const LEGACY_V1 = 'pin-dogfood-legacy-v1-41bb'; +const LEGACY_V2 = 'pin-dogfood-legacy-v2-0c95'; +const NO_PROVIDER = 'pin-dogfood-noprov-77a3'; +const PACKAGED_LITERAL = 'pin-dogfood-packaged-3d6a'; +const ALL = [HOOK_V1, HOOK_V2, HOOK_DRAFT, SIGN_V1, LEGACY_V1, LEGACY_V2, NO_PROVIDER, PACKAGED_LITERAL]; +const SYSTEM = { isSystem: true } as const; + +function inbound(name: string, secrets: { hook?: string; sign?: string } = {}, label = 'Inbound probe') { + return { + name, + label, + type: 'api', + status: 'active', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { hookId: 'h1', ...(secrets.hook !== undefined ? { secret: secrets.hook } : {}) } }, + { + id: 'call', + type: 'http', + label: 'Call', + config: { + url: 'https://example.invalid/out', + method: 'POST', + ...(secrets.sign !== undefined ? { signingSecret: secrets.sign } : {}), + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + }; +} + +const sign = (secret: string, body: string) => 'sha256=' + createHmac('sha256', secret).update(body, 'utf8').digest('hex'); +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : Array.isArray(r?.value) ? r.value : []); +const hasAny = (v: unknown, secrets: readonly string[]) => secrets.some((s) => JSON.stringify(v ?? null).includes(s)); + +describe('[#20790] flow credentials live in the write-only channel, not the stored definition', () => { + let stack: VerifyStack; + let token: string; + let ql: any; + let protocol: any; + let automation: any; + let trigger: ApiTrigger; + + beforeAll(async () => { + stack = await bootStack(crmStack as never, { automation: true }); + token = await stack.signIn(); + ql = await stack.kernel.getServiceAsync('objectql'); + protocol = await stack.kernel.getServiceAsync('protocol'); + automation = await stack.kernel.getServiceAsync('automation'); + const queue = { + publish: async () => 'msg', + subscribe: async () => {}, + unsubscribe: async () => {}, + }; + const quiet = { info() {}, warn() {}, debug() {} }; + trigger = new ApiTrigger(() => queue, quiet); + automation.registerTrigger(trigger); + }, 180_000); + + afterAll(async () => { + await stack?.stop(); + }); + + /** Bind the flow from what is STORED — the protocol's execution view, as a reload does. */ + async function arm(name: string): Promise { + const view = await protocol.getMetaItemsForExecution({ type: 'flow' }); + const list = Array.isArray(view) ? view : view?.items ?? []; + const doc = list.map((e: any) => (e && typeof e === 'object' && 'item' in e ? e.item : e)).find((d: any) => d?.name === name); + expect(doc, `${name} is in the stored view`).toBeTruthy(); + automation.registerFlow(name, stripReadDecorations(doc)); + } + + async function post(name: string, secret: string): Promise { + const body = JSON.stringify({ probe: Math.random() }); + const res = await trigger.handleRequest({ flowName: name, hookId: 'h1', rawBody: body, signatureHeader: sign(secret, body) }); + return res.status; + } + + async function stored(name: string, object = 'sys_metadata'): Promise { + return rowsOf(await ql.find(object, { where: { name }, context: SYSTEM })); + } + + async function channelRows(name: string): Promise { + return rowsOf(await ql.find('sys_flow_credential', { where: { flow_name: name }, context: SYSTEM })); + } + + it('1 — a credential saved through the metadata door is in no read surface; the channel holds it masked', async () => { + const put = await stack.apiAs(token, 'PUT', `/meta/flow/${FLOW}`, inbound(FLOW, { hook: HOOK_V1, sign: SIGN_V1 })); + expect(put.status).toBe(200); + + const active = await stored(FLOW); + expect(active).toHaveLength(1); + expect(hasAny(active, ALL)).toBe(false); + const history = await stored(FLOW, 'sys_metadata_history'); + expect(history.length).toBeGreaterThan(0); + expect(hasAny(history, ALL)).toBe(false); + + // The engine-only reader the MCP stdio transport serves from, as an + // administrator: the stored body itself no longer holds it. + const admin = await stack.contextFor(token); + const adminRead = rowsOf(await ql.find('sys_metadata', { where: { name: FLOW } }, { context: admin })); + const adminHistory = rowsOf(await ql.find('sys_metadata_history', { where: { name: FLOW } }, { context: admin })); + expect(adminRead).toHaveLength(1); + expect(hasAny(adminRead, ALL)).toBe(false); + expect(hasAny(adminHistory, ALL)).toBe(false); + + // The generic data door and /meta. + for (const path of [`/data/sys_metadata?filter=${encodeURIComponent(JSON.stringify({ name: FLOW }))}`, `/meta/flow/${FLOW}`]) { + const res = await stack.apiAs(token, 'GET', path); + expect(res.status, path).toBe(200); + expect((await res.text()).includes(HOOK_V1), path).toBe(false); + } + + // The channel: one row per credential, every read masked, no data door. + const rows = await channelRows(FLOW); + expect(rows.map((r) => `${r.node_id}.${r.credential_key}:${r.state}`).sort()).toEqual([ + 'call.signingSecret:active', + 'start.secret:active', + ]); + expect(hasAny(rows, ALL)).toBe(false); + const door = await stack.apiAs(token, 'GET', '/data/sys_flow_credential'); + expect(door.status).toBeGreaterThanOrEqual(400); + expect(hasAny(await door.text(), ALL)).toBe(false); + }); + + it('2 — the inbound door verifies with the original secret after the move, and after an edit-and-republish', async () => { + await arm(FLOW); + expect(await post(FLOW, HOOK_V1)).toBe(202); + expect(await post(FLOW, 'not-the-secret')).toBe(401); + + // The served (withheld) form saved back: the channel keeps the secret. + const republish = await stack.apiAs(token, 'PUT', `/meta/flow/${FLOW}`, inbound(FLOW, {}, 'Edited')); + expect(republish.status).toBe(200); + expect(hasAny(await stored(FLOW), ALL)).toBe(false); + await arm(FLOW); + expect(await post(FLOW, HOOK_V1)).toBe(202); + }); + + it('3 — an explicit rotation replaces it, on the next post with no re-arm', async () => { + const rotate = await stack.apiAs(token, 'PUT', `/meta/flow/${FLOW}`, inbound(FLOW, { hook: HOOK_V2 }, 'Rotated')); + expect(rotate.status).toBe(200); + expect(hasAny(await stored(FLOW), ALL)).toBe(false); + expect(await post(FLOW, HOOK_V2)).toBe(202); + expect(await post(FLOW, HOOK_V1)).toBe(401); + }); + + it('4 — a draft save never rotates the live hook; publishing the draft promotes it', async () => { + const draft = await stack.apiAs(token, 'PUT', `/meta/flow/${FLOW}?mode=draft`, inbound(FLOW, { hook: HOOK_DRAFT }, 'Drafted')); + expect(draft.status).toBe(200); + expect(hasAny(await stored(FLOW), ALL)).toBe(false); + // The live hook still verifies with the published secret. + expect(await post(FLOW, HOOK_V2)).toBe(202); + expect(await post(FLOW, HOOK_DRAFT)).toBe(401); + + // The publish gate reads the draft's withheld secret as present. + const publish = await stack.apiAs(token, 'POST', `/meta/flow/${FLOW}/publish`, {}); + expect(publish.status).toBe(200); + expect(hasAny(await stored(FLOW), ALL)).toBe(false); + expect(await post(FLOW, HOOK_DRAFT)).toBe(202); + expect(await post(FLOW, HOOK_V2)).toBe(401); + expect((await channelRows(FLOW)).filter((r) => r.state === 'draft')).toEqual([]); + }); + + it('5 — a flow stored before the move is moved once; a rollback past the move keeps the channel secret (R2)', async () => { + // A flow stored the way every flow was BEFORE this release: the + // protocol with no credential channel registered. + const channels: Map = protocol.credentialChannels; + const flowChannel = channels.get('flow'); + expect(flowChannel, 'the automation plugin registered the flow channel').toBeTruthy(); + channels.delete('flow'); + try { + const legacy = await stack.apiAs(token, 'PUT', `/meta/flow/${LEGACY}`, inbound(LEGACY, { hook: LEGACY_V1 })); + expect(legacy.status).toBe(200); + } finally { + channels.set('flow', flowChannel); + } + expect(hasAny(await stored(LEGACY), [LEGACY_V1])).toBe(true); + + // A crypto-provider registration runs the one-time move. + ql.setCryptoProvider(ql.cryptoProvider); + const deadline = Date.now() + 15_000; + while (hasAny(await stored(LEGACY), [LEGACY_V1]) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 100)); + } + expect(hasAny(await stored(LEGACY), [LEGACY_V1])).toBe(false); + expect((await channelRows(LEGACY)).map((r) => r.state)).toEqual(['active']); + // Rotate, don't scrub: the version written before the move keeps what it recorded. + const history = await stored(LEGACY, 'sys_metadata_history'); + expect(history.filter((r) => hasAny(r, [LEGACY_V1]))).toHaveLength(1); + // The receipt names the flow, never the value. + const receipt = rowsOf(await ql.find('sys_migration', { where: { id: 'flow-credential-channel' }, context: SYSTEM })); + expect(receipt).toHaveLength(1); + expect(String(receipt[0].details)).toContain(LEGACY); + expect(hasAny(receipt, ALL)).toBe(false); + await arm(LEGACY); + expect(await post(LEGACY, LEGACY_V1)).toBe(202); + + // Rotate, then roll back to version 1 — the version that held the old literal. + const rotate = await stack.apiAs(token, 'PUT', `/meta/flow/${LEGACY}`, inbound(LEGACY, { hook: LEGACY_V2 }, 'Rotated')); + expect(rotate.status).toBe(200); + const before = (await stored(LEGACY, 'sys_metadata_history')).length; + const rollback = await stack.apiAs(token, 'POST', `/meta/flow/${LEGACY}/rollback`, { toVersion: 1 }); + expect(rollback.status).toBe(200); + + const restored = await stored(LEGACY); + expect(JSON.parse(restored[0].metadata).label).toBe('Inbound probe'); + expect(hasAny(restored, ALL)).toBe(false); + const after = await stored(LEGACY, 'sys_metadata_history'); + expect(after.length).toBe(before + 1); + expect(after.filter((r) => hasAny(r, [LEGACY_V1]))).toHaveLength(1); + await arm(LEGACY); + expect(await post(LEGACY, LEGACY_V2)).toBe(202); + expect(await post(LEGACY, LEGACY_V1)).toBe(401); + }); + + it('6 — the clone door refuses a source holding a credential, channel-held or literal (C1)', async () => { + for (const source of [FLOW, 'zz_channel_literal_src']) { + if (source !== FLOW) automation.registerFlow(source, inbound(source, { hook: HOOK_V1 })); + const res = await stack.apiAs(token, 'POST', `/automation/${source}/clone`, { name: `${source}_copy`, label: 'Copy' }); + expect(res.status, source).toBe(409); + const text = await res.text(); + const body = JSON.parse(text); + expect(body.error?.code).toBe('RESOURCE_CONFLICT'); + expect(body.error?.message).toContain('the inbound hook secret'); + expect(hasAny(text, ALL)).toBe(false); + expect(await automation.getFlow(`${source}_copy`)).toBeNull(); + } + // The control: a flow that holds no credential clones as before. + automation.registerFlow('zz_channel_plain_src', { + name: 'zz_channel_plain_src', + label: 'Plain', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }); + const plain = await stack.apiAs(token, 'POST', '/automation/zz_channel_plain_src/clone', { name: 'zz_channel_plain_copy', label: 'Copy' }); + expect(plain.status).toBe(200); + }); + + it('7 — with no crypto provider a save carrying a credential is refused before anything is written', async () => { + const provider = ql.cryptoProvider; + ql.cryptoProvider = undefined; + try { + const res = await stack.apiAs(token, 'PUT', '/meta/flow/zz_channel_noprov', inbound('zz_channel_noprov', { hook: NO_PROVIDER })); + expect(res.status).toBe(503); + const text = await res.text(); + // The metadata door's error body names the code at its top level. + expect(JSON.parse(text).code).toBe('SERVICE_UNAVAILABLE'); + expect(hasAny(text, ALL)).toBe(false); + } finally { + ql.cryptoProvider = provider; + } + expect(await stored('zz_channel_noprov')).toEqual([]); + expect(await stored('zz_channel_noprov', 'sys_metadata_history')).toEqual([]); + expect(await channelRows('zz_channel_noprov')).toEqual([]); + }); + + it('8 — deleting the flow drops its credential; a new flow of the same name inherits nothing', async () => { + const del = await stack.apiAs(token, 'DELETE', `/meta/flow/${FLOW}`); + expect(del.status).toBe(200); + expect(await channelRows(FLOW)).toEqual([]); + // Recreated in the withheld form: nothing is held, so the publish gate refuses it. + const again = await stack.apiAs(token, 'PUT', `/meta/flow/${FLOW}`, inbound(FLOW)); + expect(again.status).toBe(422); + expect(await channelRows(FLOW)).toEqual([]); + }); + + /** + * Tests 9 and 10 swap the engine's credential source for a channel of the + * plugin's own class whose store is unreachable — the composition with no + * data engine — and restore the live one after. + */ + async function withSource(source: unknown, run: () => Promise): Promise { + const live = automation.flowCredentialSource; + automation.setFlowCredentialSource(source); + try { + return await run(); + } finally { + automation.setFlowCredentialSource(live); + } + } + const channelClass = () => automation.flowCredentialSource.constructor as new (resolveEngine: () => unknown) => any; + + it('9 — Q3 A: a packaged literal hook verifies on its literal when the credential store is unreachable', async () => { + const unreachable = new (channelClass())(() => undefined); + await withSource(unreachable, async () => { + automation.registerFlow('zz_channel_packaged', inbound('zz_channel_packaged', { hook: PACKAGED_LITERAL })); + expect(await post('zz_channel_packaged', PACKAGED_LITERAL)).toBe(202); + expect(await post('zz_channel_packaged', HOOK_V1)).toBe(401); + }); + }); + + it('10 — the control: a held hook secret whose store becomes unreachable answers 503, never the literal', async () => { + let reachable: unknown = ql; + const channel = new (channelClass())(() => reachable); + await channel.store({ name: 'zz_channel_heldpack', state: 'active', body: inbound('zz_channel_heldpack', { hook: HOOK_V2 }) }); + expect(channel.holds('zz_channel_heldpack', 'start', 'secret')).toBe(true); + try { + await withSource(channel, async () => { + automation.registerFlow('zz_channel_heldpack', inbound('zz_channel_heldpack', { hook: PACKAGED_LITERAL })); + expect(await post('zz_channel_heldpack', HOOK_V2)).toBe(202); + reachable = undefined; + expect(await post('zz_channel_heldpack', PACKAGED_LITERAL)).toBe(503); + expect(await post('zz_channel_heldpack', HOOK_V2)).toBe(503); + }); + } finally { + reachable = ql; + await channel.prune({ name: 'zz_channel_heldpack', liveStates: new Set() }); + } + expect(await channelRows('zz_channel_heldpack')).toEqual([]); + }); +}); diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index e6b617754fc..582b1148b6c 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -243,6 +243,16 @@ export default defineConfig({ find: /^@objectstack\/trigger-schedule$/, replacement: path.resolve(__dirname, '../../triggers/trigger-schedule/src/index.ts'), }, + // [#20790] `flow-credential-channel.dogfood.test.ts` drives + // `ApiTrigger` itself: the pin's subject is that the inbound door + // verifies against the secret the write-only flow credential + // channel holds, read at verification time. A dist merely behind + // would verify with the trigger's OLD literal-only arming, so the + // verdict is aliased to THIS checkout's source. + { + find: /^@objectstack\/trigger-api$/, + replacement: path.resolve(__dirname, '../../triggers/trigger-api/src/index.ts'), + }, ], }, test: { diff --git a/packages/runtime/src/domains/automation-flow-clone-credential.test.ts b/packages/runtime/src/domains/automation-flow-clone-credential.test.ts new file mode 100644 index 00000000000..b011e6e20c4 --- /dev/null +++ b/packages/runtime/src/domains/automation-flow-clone-credential.test.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20790] C1 — the clone door (`POST /automation/:name/clone`) refuses a + * source that holds a credential at ANY position of the flow + * credential-location table: a literal in its definition (a packaged flow's + * source) or one the write-only flow credential channel holds — the inbound + * hook secret and the outbound signing secret alike (Q4 A). A copy would + * share it, and two flows never share a secret (Q2 A); the refusal names the + * positions by class and prescribes authoring the copy with its own secret. + * + * ⚠️ The accepted cost, pinned as such: a packaged inbound flow (the ADR-0126 + * §7.1 customization path) is no longer cloned in one step. + * + * Each refusal asserts the ADR-0112 envelope (`code` + `status`), that nothing + * was registered, and that the answer carries no value. + */ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { getMetadataTypeRedactor, registerMetadataTypeRedactor } from '@objectstack/spec/kernel'; +import type { MetadataTypeRedactor } from '@objectstack/spec/kernel'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import { + FLOW_CLONE_CREDENTIAL_REFUSAL_CODE, + FLOW_CLONE_CREDENTIAL_REFUSAL_STATUS, + type FlowCloneCredentialHolding, +} from '../flow-clone.js'; + +const CTX = { request: {}, executionContext: { userId: 'user_1', systemPermissions: ['manage_metadata'] } } as any; +const LITERAL = 'pin-clone-literal-6c3e'; + +function inbound(name: string, startConfig: Record = {}) { + return { + name, + label: name, + type: 'api', + status: 'active', + nodes: [ + { id: 'begin', type: 'start', label: 'Start', config: { hookId: 'h1', ...startConfig } }, + { id: 'finish', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'begin', target: 'finish' }], + }; +} + +/** The automation service double: `getFlow` / `registerFlow`, plus (optionally) the engine's credential answer. */ +function makeDispatcher( + seed: Record[], + holdings?: (name: string) => FlowCloneCredentialHolding[], +) { + const flows = new Map>(seed.map((f) => [f.name as string, f])); + const spies: Record = { + getFlow: vi.fn(async (name: string) => flows.get(name) ?? null), + registerFlow: vi.fn((name: string, definition: unknown) => { + flows.set(name, definition as Record); + }), + }; + if (holdings) spies.flowCredentialHoldings = vi.fn(holdings); + const services: Record = { automation: spies }; + const resolve = (name: string) => services[name]; + const kernel: any = { getService: resolve, getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve } }; + return { dispatcher: new HttpDispatcher(kernel), spies }; +} + +const clone = (dispatcher: HttpDispatcher, source: string) => + dispatcher.handleAutomation(`/${source}/clone`, 'POST', { name: `${source}_copy`, label: 'Copy' }, CTX); + +function expectRefused(result: Awaited>, spies: Record, classes: string[]) { + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(FLOW_CLONE_CREDENTIAL_REFUSAL_STATUS); + expect(FLOW_CLONE_CREDENTIAL_REFUSAL_STATUS).toBe(409); + const error = result.response?.body?.error; + expect(result.response?.body?.success).toBe(false); + expect(error?.code).toBe(FLOW_CLONE_CREDENTIAL_REFUSAL_CODE); + expect(error?.code).toBe('RESOURCE_CONFLICT'); + expect(error?.httpStatus).toBe(409); + const message: string = error?.message ?? ''; + for (const c of classes) expect(message).toContain(c); + // Q2 A's prescription: the copy is authored with its own secret. + expect(message).toContain('cannot be cloned in one step'); + expect(message).toContain('create a new flow under a new machine name'); + expect(JSON.stringify(result.response?.body)).not.toContain(LITERAL); + expect(spies.registerFlow).not.toHaveBeenCalled(); +} + +describe('[#20790] C1 — a source that holds a credential is never cloned in one step', () => { + it('refuses a source whose inbound secret the write-only channel holds', async () => { + const { dispatcher, spies } = makeDispatcher([inbound('held_intake')], (name) => + name === 'held_intake' ? [{ key: 'secret', label: 'the inbound hook secret', held: 'channel' }] : [], + ); + const result = await clone(dispatcher, 'held_intake'); + expectRefused(result, spies, ['the inbound hook secret', '`config.secret` on its start node']); + }); + + it('refuses a source whose definition carries a literal — a packaged inbound flow (the accepted cost)', async () => { + const { dispatcher, spies } = makeDispatcher([inbound('packaged_intake', { secret: LITERAL })], (name) => + name === 'packaged_intake' ? [{ key: 'secret', label: 'the inbound hook secret', held: 'literal' }] : [], + ); + expectRefused(await clone(dispatcher, 'packaged_intake'), spies, ['the inbound hook secret']); + }); + + it('refuses a source whose outbound signing secret is held (Q4 A) — a copy would deliver unsigned', async () => { + const { dispatcher, spies } = makeDispatcher([inbound('signed_callout')], () => [ + { key: 'signingSecret', label: 'an outbound signing secret', held: 'channel' }, + ]); + expectRefused(await clone(dispatcher, 'signed_callout'), spies, [ + 'an outbound signing secret', + '`config.signingSecret` on each http node that signs', + ]); + }); + + it('clones a source that holds no credential, as before', async () => { + const { dispatcher, spies } = makeDispatcher([inbound('quiet_flow')], () => []); + const result = await clone(dispatcher, 'quiet_flow'); + expect(result.response?.status).toBe(200); + expect(spies.registerFlow).toHaveBeenCalledTimes(1); + }); + + describe('an automation service that does not report holdings', () => { + let previous: MetadataTypeRedactor | undefined; + beforeAll(() => { + previous = getMetadataTypeRedactor('flow'); + // The table's projection as the automation plugin registers it: + // the start node's `secret` is a credential position. + registerMetadataTypeRedactor('flow', (item) => { + const nodes = item.nodes as any[]; + const redactedKeys: string[] = []; + nodes.forEach((n, i) => { + if (n?.type === 'start' && n.config && 'secret' in n.config && n.config.secret !== '') { + redactedKeys.push(`nodes.${i}.config.secret`); + } + }); + return { item, redactedKeys }; + }); + }); + afterAll(() => { + if (previous) registerMetadataTypeRedactor('flow', previous); + }); + + it('still refuses a literal-held source, read through the registered credential table', async () => { + const { dispatcher, spies } = makeDispatcher([inbound('foreign_engine_flow', { secret: LITERAL })]); + expectRefused(await clone(dispatcher, 'foreign_engine_flow'), spies, ['the credential at `secret`']); + }); + }); +}); diff --git a/packages/runtime/src/domains/automation-flow-credential-projection.test.ts b/packages/runtime/src/domains/automation-flow-credential-projection.test.ts index ae3086b2c88..13c31ae13bf 100644 --- a/packages/runtime/src/domains/automation-flow-credential-projection.test.ts +++ b/packages/runtime/src/domains/automation-flow-credential-projection.test.ts @@ -111,14 +111,17 @@ describe('#20552 — a served definition withholds the hook secret', () => { expect(startOf(spies.registerFlow.mock.calls[0]![1]).config.secret).toBe(SECRET); }); - it('POST /:name/clone — the answer withholds it; the clone carries the whole definition', async () => { - const { dispatcher, flows } = makeDispatcher(); + it('POST /:name/clone — a credential-holding source is refused, and the refusal carries no credential', async () => { + const { dispatcher, flows, spies } = makeDispatcher(); const result = await dispatcher.handleAutomation('/inbound_hook/clone', 'POST', { name: 'inbound_hook_copy', label: 'Copy' }, AUTHOR); - expect(result.response?.status).toBe(200); + // #20790 C1: a copy would share the source's secret, so the clone door + // refuses it (the refusal itself is pinned in + // `automation-flow-clone-credential.test.ts`). + expect(result.response?.status).toBe(409); + expect(result.response?.body?.error?.code).toBe('RESOURCE_CONFLICT'); expect(JSON.stringify(result.response?.body)).not.toContain(SECRET); - // ADR-0126 §7.1's whole-definition copy is unchanged: the registered - // clone still verifies its hook with the source's secret. - expect(startOf(flows.get('inbound_hook_copy'))!.config.secret).toBe(SECRET); + expect(flows.has('inbound_hook_copy')).toBe(false); + expect(spies.registerFlow).not.toHaveBeenCalled(); }); }); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 264e10f6e89..7f2e53b4720 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -47,6 +47,9 @@ import { // not carry forward, the same-name refusal and the references notice. import { cloneFlowDefinition, + flowCloneCredentialRefusal, + literalFlowCredentialHoldings, + type FlowCloneCredentialSource, flowCloneNameTakenMessage, FLOW_CLONE_NAME_TAKEN_STATUS, FLOW_CLONE_NOTICE, @@ -2697,6 +2700,17 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str return { handled: true, response: deps.error(flowNotFoundMessage(name), FLOW_NOT_FOUND_STATUS) }; } + // [#20790] C1 — a source that holds a credential at ANY position + // (a literal, or one the write-only channel holds) is refused, + // with the prescription: a copy would share it. See + // `flowCloneCredentialRefusal`. + const credentialSource = automationService as FlowCloneCredentialSource; + const holdings = typeof credentialSource.flowCredentialHoldings === 'function' + ? credentialSource.flowCredentialHoldings(name) + : literalFlowCredentialHoldings(source); + const credentialRefusal = flowCloneCredentialRefusal(name, holdings); + if (credentialRefusal) return { handled: true, response: deps.errorFromThrown(credentialRefusal) }; + // ⛔ SAME-NAME REFUSAL, loudly, naming the sanctioned path. // Checked against the same probe, so "already exists" means the // same thing here as everywhere else on this domain. This also @@ -2748,9 +2762,9 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // cheapest place for ancestry to reappear, and a UI that reads // one starts displaying a lineage the platform has ruled it // does not track. - // [#20552] The clone carries the source's whole definition into - // the engine (secret included — ADR-0126 §7.1); the ANSWER is a - // served definition like any other and withholds it. + // [#20552] The ANSWER is a served definition like any other. A + // source holding a credential never reaches this line (#20790 + // C1, above), so the clone carries none. return { handled: true, response: deps.success({ flow: servedFlowDefinition(clone), notice: FLOW_CLONE_NOTICE }) }; } } diff --git a/packages/runtime/src/flow-clone.ts b/packages/runtime/src/flow-clone.ts index 6bd17cf9653..4ee496d4048 100644 --- a/packages/runtime/src/flow-clone.ts +++ b/packages/runtime/src/flow-clone.ts @@ -85,7 +85,7 @@ * rather than only in an ADR. */ -import { METADATA_READ_DECORATIONS, MetadataProtectionFields } from '@objectstack/spec/kernel'; +import { getMetadataTypeRedactor, METADATA_READ_DECORATIONS, MetadataProtectionFields } from '@objectstack/spec/kernel'; /** * The deployment status a clone is created with. @@ -245,3 +245,89 @@ export function cloneFlowDefinition( copy.status = FLOW_CLONE_STATUS; return copy; } + +// --------------------------------------------------------------------------- +// [#20790] C1 — a source that holds a credential is never cloned in one step +// --------------------------------------------------------------------------- + +/** + * One credential the clone's source holds, by class — never the value. The + * automation engine answers it (`AutomationEngine.flowCredentialHoldings`): + * a literal in the definition (a packaged flow's source), or one the + * write-only flow credential channel holds for a position of it. + */ +export interface FlowCloneCredentialHolding { + /** The node config key (`secret`, `signingSecret`). */ + readonly key: string; + /** What an administrator is told the credential is; the key's spelling when absent. */ + readonly label?: string; + readonly held: 'literal' | 'channel'; +} + +/** The slice of the automation service the clone door asks about credentials. */ +export interface FlowCloneCredentialSource { + flowCredentialHoldings?(name: string): readonly FlowCloneCredentialHolding[]; +} + +/** ADR-0112 pair for the credential refusal: the source's state forbids the copy. */ +export const FLOW_CLONE_CREDENTIAL_REFUSAL_STATUS = 409; +export const FLOW_CLONE_CREDENTIAL_REFUSAL_CODE = 'RESOURCE_CONFLICT'; + +/** + * The literal credentials a definition carries, through the `flow` redactor + * the automation plugin registers in `@objectstack/spec/kernel` — the + * projection of the platform's one credential-location table. The clone + * door's answer when the automation service does not report holdings itself + * (a host that composes another engine); it cannot see a channel-held one, + * because such a host has no channel. + */ +export function literalFlowCredentialHoldings(source: unknown): FlowCloneCredentialHolding[] { + const redactor = getMetadataTypeRedactor('flow'); + if (!redactor || !source || typeof source !== 'object' || Array.isArray(source)) return []; + return redactor(source as Record).redactedKeys.map((path) => ({ + key: path.slice(path.lastIndexOf('.') + 1), + held: 'literal' as const, + })); +} + +/** + * The clone door's credential refusal (#20790 C1, Q2 A), or `undefined` when + * the source holds none. + * + * A flow's credentials are its own: an inbound hook's secret authenticates + * posts to THAT hook, an `http` node's signing secret proves a delivery came + * from THAT flow. A whole-definition copy (§1 above) would carry a literal + * one across, and the metadata save door would then store it as the copy's + * own — two flows sharing one secret, which is never allowed. A secret the + * write-only channel holds is not in the definition at all, so the copy would + * arrive without it: an inbound copy refused at registration, an outbound + * copy delivering unsigned. Both are refused here instead, with the remedy: + * the administrator authors the copy with its own secret. + * + * ⚠️ So a PACKAGED inbound flow (the ADR-0126 §7.1 customization path) can no + * longer be cloned in one step — a cost the ruling accepted. The positions are + * named by class only, never by value, node or path. + */ +export function flowCloneCredentialRefusal( + sourceName: string, + holdings: readonly FlowCloneCredentialHolding[], +): (Error & { code: string; status: number; statusCode: number }) | undefined { + if (holdings.length === 0) return undefined; + const classes = [...new Set(holdings.map((h) => h.label ?? `the credential at \`${h.key}\``))].sort().join(' and '); + const keys = new Set(holdings.map((h) => h.key)); + const remedies = [ + keys.has('secret') ? 'a new `config.secret` on its start node' : undefined, + keys.has('signingSecret') ? 'a new `config.signingSecret` on each http node that signs' : undefined, + [...keys].some((k) => k !== 'secret' && k !== 'signingSecret') ? 'a new value for each credential' : undefined, + ].filter((s): s is string => s !== undefined); + const err = new Error( + `Flow '${sourceName}' cannot be cloned in one step: it holds ${classes}, and a copy would share it — two ` + + 'flows never share a secret. Author the copy with its own instead: read this flow\'s definition (its ' + + 'credentials are withheld from it), create a new flow under a new machine name with that definition, ' + + `and set ${remedies.join(' and ')}.`, + ) as Error & { code: string; status: number; statusCode: number }; + err.code = FLOW_CLONE_CREDENTIAL_REFUSAL_CODE; + err.status = FLOW_CLONE_CREDENTIAL_REFUSAL_STATUS; + err.statusCode = FLOW_CLONE_CREDENTIAL_REFUSAL_STATUS; + return err; +} diff --git a/packages/services/service-automation/src/activation-ledger-registration.test.ts b/packages/services/service-automation/src/activation-ledger-registration.test.ts index 3667a5e6147..445b08f1e4c 100644 --- a/packages/services/service-automation/src/activation-ledger-registration.test.ts +++ b/packages/services/service-automation/src/activation-ledger-registration.test.ts @@ -60,11 +60,13 @@ async function registeredObjectNames(): Promise { } describe('#12359 — the automation service is NOT the activation ledger\'s registrant', () => { - it('registers its own two objects and nothing else', async () => { + it('registers its own three objects and nothing else', async () => { // Equality, not `not.toContain`: an assertion that only names the - // absent object would stay green while this manifest grew a THIRD + // absent object would stay green while this manifest grew a FOURTH // registration nobody reviewed, which is the same class of drift. - expect(await registeredObjectNames()).toEqual(['sys_automation_run', 'sys_flow_dispatch']); + // [#20790] The third is reviewed: `sys_flow_credential`, the write-only + // flow credential channel this service owns. + expect(await registeredObjectNames()).toEqual(['sys_automation_run', 'sys_flow_dispatch', 'sys_flow_credential']); }); it('does not name sys_metadata_activation in any manifest it registers', async () => { diff --git a/packages/services/service-automation/src/api-trigger-secret-channel.test.ts b/packages/services/service-automation/src/api-trigger-secret-channel.test.ts new file mode 100644 index 00000000000..f0bc7f41427 --- /dev/null +++ b/packages/services/service-automation/src/api-trigger-secret-channel.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20790] The engine's half of the write-only flow credential channel: an + * inbound flow stored through the metadata save door keeps no secret in its + * definition, so the engine registers it on the strength of the channel and + * hands its trigger a binding that reads the secret at VERIFICATION time. + * + * Pinned against a recording `api` trigger and an in-memory channel: the + * binding resolves what the channel holds; the channel's row wins over a + * packaged flow's literal (Q3 A); a rotation in the channel reaches the next + * read without a re-registration; a cleared secret is refused whatever the + * channel still holds; and `flowCredentialHoldings` names every credential a + * flow holds, by class, for the clone door. + * + * Every value is a probe sentinel, not a credential. + */ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { AutomationEngine } from './engine.js'; +import type { FlowCredentialSource, FlowTrigger, FlowTriggerBinding } from './engine.js'; + +const HELD = 'pin-engine-held-61c2'; +const LITERAL = 'pin-engine-literal-9f03'; +const ROTATED = 'pin-engine-rotated-2a7e'; + +function quiet() { + return { debug() {}, info() {}, warn() {}, error() {} } as any; +} + +/** An in-memory channel: `(flow, node, key)` → value. */ +function memoryChannel(initial: Record = {}) { + const rows = new Map(Object.entries(initial)); + const k = (f: string, n: string, key: string) => `${f}/${n}/${key}`; + const source: FlowCredentialSource = { + holds: (f, n, key) => rows.has(k(f, n, key)), + held: (f) => + [...rows.keys()] + .filter((x) => x.startsWith(`${f}/`)) + .map((x) => { + const [, nodeId, key] = x.split('/'); + return { nodeId: nodeId!, key: key! }; + }), + resolve: async (f, n, key) => rows.get(k(f, n, key)), + }; + return { source, rows, key: k }; +} + +function inbound(name: string, startConfig: Record) { + return { + name, + label: name, + type: 'api', + status: 'active', + nodes: [ + { id: 'begin', type: 'start', label: 'Start', config: { hookId: 'h1', ...startConfig } }, + { id: 'call', type: 'http', label: 'Call', config: { url: 'https://example.invalid/x' } }, + { id: 'finish', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'begin', target: 'call' }, + { id: 'e2', source: 'call', target: 'finish' }, + ], + }; +} + +describe('[#20790] an inbound flow whose secret the channel holds', () => { + let engine: AutomationEngine; + let started: FlowTriggerBinding[]; + + beforeEach(() => { + engine = new AutomationEngine(quiet()); + started = []; + const trigger: FlowTrigger = { type: 'api', start: (b) => { started.push(b); }, stop: () => {} }; + engine.registerTrigger(trigger); + }); + + it('registers with no secret in its definition, and its binding reads the held one at verification', async () => { + const channel = memoryChannel({ 'held_flow/begin/secret': HELD }); + engine.setFlowCredentialSource(channel.source); + + engine.registerFlow('held_flow', inbound('held_flow', {})); + + expect(started).toHaveLength(1); + const binding = started[0]!; + // The trigger's config carries no secret — the definition has none. + expect(JSON.stringify(binding.config)).not.toContain(HELD); + expect(typeof binding.resolveSecret).toBe('function'); + expect(await binding.resolveSecret!()).toBe(HELD); + + // A rotation in the channel reaches the next read; nothing re-registers. + channel.rows.set('held_flow/begin/secret', ROTATED); + expect(await binding.resolveSecret!()).toBe(ROTATED); + }); + + it('the channel row wins over a packaged flow\'s literal; the literal is the fallback (Q3 A)', async () => { + const channel = memoryChannel(); + engine.setFlowCredentialSource(channel.source); + engine.registerFlow('packaged_flow', inbound('packaged_flow', { secret: LITERAL })); + const binding = started[0]!; + expect(await binding.resolveSecret!()).toBe(LITERAL); + + channel.rows.set('packaged_flow/begin/secret', HELD); + expect(await binding.resolveSecret!()).toBe(HELD); + }); + + it('refuses a flow the channel holds nothing for — the ADR-0041 refusal, unchanged', () => { + engine.setFlowCredentialSource(memoryChannel().source); + expect(() => engine.registerFlow('bare_flow', inbound('bare_flow', {}))).toThrow(/declares no `config\.secret`/); + expect(started).toEqual([]); + }); + + it('refuses a CLEARED secret whatever the channel still holds', () => { + engine.setFlowCredentialSource(memoryChannel({ 'cleared_flow/begin/secret': HELD }).source); + expect(() => engine.registerFlow('cleared_flow', inbound('cleared_flow', { secret: '' }))).toThrow( + /declares no `config\.secret`/, + ); + expect(started).toEqual([]); + }); + + it('a held secret that does not come back rejects the read — never a silent "no secret"', async () => { + const channel = memoryChannel({ 'broken_flow/begin/secret': HELD }); + channel.source.resolve = async () => { + throw Object.assign(new Error('no crypto provider'), { code: 'SERVICE_UNAVAILABLE', status: 503 }); + }; + engine.setFlowCredentialSource(channel.source); + engine.registerFlow('broken_flow', inbound('broken_flow', {})); + await expect(started[0]!.resolveSecret!()).rejects.toThrow(/no crypto provider/); + }); + + it('names every credential a flow holds by class — literal or held — for the clone door', () => { + const channel = memoryChannel({ 'mixed_flow/call/signingSecret': HELD }); + engine.setFlowCredentialSource(channel.source); + engine.registerFlow('mixed_flow', inbound('mixed_flow', { secret: LITERAL })); + + const holdings = engine.flowCredentialHoldings('mixed_flow'); + expect(holdings).toEqual([ + { nodeId: 'begin', key: 'secret', label: 'the inbound hook secret', held: 'literal' }, + { nodeId: 'call', key: 'signingSecret', label: 'an outbound signing secret', held: 'channel' }, + ]); + for (const s of [HELD, LITERAL]) expect(JSON.stringify(holdings)).not.toContain(s); + expect(engine.flowCredentialHoldings('no_such_flow')).toEqual([]); + }); +}); diff --git a/packages/services/service-automation/src/builtin/http-node-channel-signing.test.ts b/packages/services/service-automation/src/builtin/http-node-channel-signing.test.ts new file mode 100644 index 00000000000..6872ca67171 --- /dev/null +++ b/packages/services/service-automation/src/builtin/http-node-channel-signing.test.ts @@ -0,0 +1,144 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20790] Q4 A — the outbound `http` node's signing secret moves into the + * write-only flow credential channel with the inbound one, so the node reads + * it at EXECUTION time. Pinned against a real local HTTP receiver and an + * in-memory channel: + * + * - a held secret signs the request exactly as the authored literal did; + * - the channel's row wins over a literal (a packaged flow, Q3 A); + * - a rotation in the channel signs the next run; + * - the cleared form `''` sends unsigned on purpose and never asks the channel; + * - a held secret that does not come back refuses the node — the request is + * never sent unsigned. + * + * Every value is a probe sentinel, not a credential. + */ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { signHttpBody } from '@objectstack/core'; + +import { AutomationEngine } from '../engine.js'; +import type { FlowCredentialSource } from '../engine.js'; +import { registerHttpNodes } from './http-nodes.js'; + +const HELD = 'pin-http-held-4b8e'; +const LITERAL = 'pin-http-literal-71d5'; +const ROTATED = 'pin-http-rotated-e30c'; +const SIGNATURE = 'x-objectstack-signature'; + +let server: Server; +let baseUrl: string; +const received: Array<{ headers: Record; body: string }> = []; + +beforeAll(async () => { + server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + received.push({ headers: req.headers, body: Buffer.concat(chunks).toString('utf8') }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +beforeEach(() => { + received.length = 0; +}); + +function quiet(): any { + const l: any = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; + l.child = () => l; + return l; +} + +function channel(initial: Record = {}) { + const rows = new Map(Object.entries(initial)); + let asked = 0; + const source: FlowCredentialSource = { + holds: (f, n, k) => rows.has(`${f}/${n}/${k}`), + held: () => [], + resolve: async (f, n, k) => { + asked += 1; + return rows.get(`${f}/${n}/${k}`); + }, + }; + return { source, rows, asked: () => asked }; +} + +async function run(source: FlowCredentialSource | undefined, config: Record) { + const engine = new AutomationEngine(quiet()); + registerHttpNodes(engine, { logger: quiet(), getService: () => undefined } as any); + engine.setFlowCredentialSource(source); + engine.registerFlow('signed_callout', { + name: 'signed_callout', + label: 'Signed callout', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'call', type: 'http', label: 'Call', config: { url: `${baseUrl}/hook`, method: 'POST', body: { a: 1 }, ...config } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + } as never); + return engine.execute('signed_callout'); +} + +const sentSignature = () => received[0]?.headers[SIGNATURE]; + +describe('[#20790] the http node signs with the secret the channel holds', () => { + it('a held secret signs exactly as the literal did; the definition carries none', async () => { + const result = await run(channel({ 'signed_callout/call/signingSecret': HELD }).source, {}); + expect(result.success).toBe(true); + expect(received).toHaveLength(1); + expect(sentSignature()).toBe(signHttpBody(received[0]!.body, HELD)); + }); + + it('the channel row wins over a literal; the literal is the fallback', async () => { + await run(channel({ 'signed_callout/call/signingSecret': HELD }).source, { signingSecret: LITERAL }); + expect(sentSignature()).toBe(signHttpBody(received[0]!.body, HELD)); + received.length = 0; + await run(channel().source, { signingSecret: LITERAL }); + expect(sentSignature()).toBe(signHttpBody(received[0]!.body, LITERAL)); + }); + + it('a rotation in the channel signs the next run', async () => { + const held = channel({ 'signed_callout/call/signingSecret': HELD }); + await run(held.source, {}); + held.rows.set('signed_callout/call/signingSecret', ROTATED); + received.length = 0; + await run(held.source, {}); + expect(sentSignature()).toBe(signHttpBody(received[0]!.body, ROTATED)); + }); + + it("the cleared form '' sends unsigned on purpose and never asks the channel", async () => { + const held = channel({ 'signed_callout/call/signingSecret': HELD }); + const result = await run(held.source, { signingSecret: '' }); + expect(result.success).toBe(true); + expect(sentSignature()).toBeUndefined(); + expect(held.asked()).toBe(0); + }); + + it('a held secret that does not come back refuses the node; nothing is sent unsigned', async () => { + const held = channel({ 'signed_callout/call/signingSecret': HELD }); + held.source.resolve = async () => { + throw new Error('the ciphertext row is missing'); + }; + const result = await run(held.source, {}); + expect(result.success).toBe(false); + expect(String(result.error)).toContain('signing secret'); + expect(received).toEqual([]); + }); +}); diff --git a/packages/services/service-automation/src/builtin/http-nodes.ts b/packages/services/service-automation/src/builtin/http-nodes.ts index 1c87e69b2fe..d381139d4ae 100644 --- a/packages/services/service-automation/src/builtin/http-nodes.ts +++ b/packages/services/service-automation/src/builtin/http-nodes.ts @@ -9,6 +9,7 @@ import type { AutomationEngine } from '../engine.js'; import { refuseNode } from '../guard-refusal.js'; import { interpolate } from './template.js'; import { parseNodeConfig } from './parse-config.js'; +import { FLOW_CREDENTIAL_CLEARED, HTTP_SIGNING_SECRET_KEY } from '../flow-credential-projection.js'; /** * HTTP built-in node — canonical `http` (ADR-0018 M3). @@ -143,7 +144,35 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext): }, }), async execute(node, variables, context) { - const raw = (node.config ?? {}) as Record; + const authored = (node.config ?? {}) as Record; + // [#20790] A signing secret the write-only credential channel holds + // is not in the definition: it is read now, at execution, and takes + // the place the literal would have had — so everything below (the + // template interpolation, the contract parse, the refusal of a value + // that renders to nothing) treats it exactly as an authored one. The + // channel's row wins where one exists (a packaged flow's literal is + // the fallback). A cleared `''` is the author's "unsigned": the + // channel is not asked. A held secret that does not come back + // refuses the node — it is never sent unsigned. + let raw = authored; + const flowName = context.flowName; + if ( + typeof flowName === 'string' + && authored.signingSecret !== FLOW_CREDENTIAL_CLEARED + && engine.holdsFlowCredential(flowName, node.id, HTTP_SIGNING_SECRET_KEY) + ) { + let held: string | undefined; + try { + held = await engine.resolveFlowCredential(flowName, node.id, HTTP_SIGNING_SECRET_KEY); + } catch (err) { + return refuseNode( + `http '${node.id}': its signing secret is held by the flow credential store and could not be ` + + `read (${(err as Error)?.message ?? String(err)}), so the request cannot carry ` + + `${HTTP_SIGNATURE_HEADER} and was not sent.`, + ); + } + if (held !== undefined) raw = { ...authored, signingSecret: held }; + } // Parsed AFTER interpolation — unique among the contract-carrying // builtins, because this executor reads the interpolated config // wholesale, so that is the shape its contract describes: a `{token}` diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 51f405f82e2..d599cce4fb9 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -19,6 +19,7 @@ import { type ScreenFieldVisibility, } from './screen-input-contract.js'; import type { Logger } from '@objectstack/spec/contracts'; +import { FLOW_HOOK_SECRET_KEY, flowCredentialClassLabel, flowCredentialPositions } from './flow-credential-projection.js'; import { FlowSchema, FLOW_STRUCTURAL_NODE_TYPES, validateControlFlow, collectFlowGraphs, findRegionEntry, defineActionDescriptor, DecisionConfigSchema } from '@objectstack/spec/automation'; // [#14328] The ONE answer to "which trigger kind does this flow ask for?" — // shared with `defineStack`'s trigger-capability refusal and `@objectstack/lint`'s @@ -544,6 +545,16 @@ export interface FlowTriggerBinding { readonly organization?: string; /** The raw start-node `config`, for trigger-specific fields not modeled above. */ readonly config?: Record; + /** + * [#20790] api: the inbound hook's secret, read at VERIFICATION time — + * present whenever the flow has one, whether its definition carries it as + * a literal (a packaged flow) or the write-only credential channel holds it + * (every flow stored through the metadata save door; `config` then carries + * none). The channel's row wins where one exists. Never cached: a rotation + * applies to the next post. Rejects when a held secret does not come back — + * the trigger answers that post as unavailable, never as verified. + */ + readonly resolveSecret?: () => Promise; } /** @@ -2252,6 +2263,40 @@ export interface FlowContender { */ export type PackagedFlowSource = (name: string) => string | undefined; +/** + * [#20790] Where a flow's credentials live once they are out of its + * definition: the write-only flow credential channel (the automation plugin's + * `FlowCredentialChannel`, on the #7799 secret seam). Positions are + * `(flow name, node id, config key)` and refer to the LIVE (active) flow. + * + * `holds` / `held` answer synchronously from what the channel knows is stored + * — the registration check and the binding are synchronous. `resolve` reads + * the value at the moment of use and never caches it: `undefined` means the + * channel holds nothing there; a held credential that does not come back + * THROWS, and is never read as "no credential". With no reachable store + * `resolve` cannot tell held from not, so it throws too — which is why every + * caller with a fallback (a packaged literal) asks `resolve` only for a + * position `holds` reports. + */ +export interface FlowCredentialSource { + holds(flowName: string, nodeId: string, key: string): boolean; + held(flowName: string): Array<{ nodeId: string; key: string }>; + resolve(flowName: string, nodeId: string, key: string): Promise; +} + +/** + * [#20790] One credential a registered flow holds, by class: a literal in its + * definition (a packaged flow's source), or held by the credential channel. + * What the clone door refuses on — never the value. + */ +export interface FlowCredentialHolding { + readonly nodeId: string; + readonly key: string; + /** The class an administrator is told (`flowCredentialClassLabel`). */ + readonly label: string; + readonly held: 'literal' | 'channel'; +} + /** * [#11997] What the ADR-0005 overlay precedence decided for one bare flow name. * @@ -2367,6 +2412,8 @@ export class AutomationEngine implements IAutomationService { * no flow a managed package loaded. */ private packagedFlowSource?: PackagedFlowSource; + /** [#20790] The write-only flow credential channel — see {@link setFlowCredentialSource}. */ + private flowCredentialSource?: FlowCredentialSource; /** * Re-entrancy guard for record-triggered flows (complements the intra-run * {@link MAX_NODE_REENTRIES} back-edge guard, which cannot see a self-trigger @@ -3617,12 +3664,16 @@ export class AutomationEngine implements IAutomationService { // Inbound HTTP (ADR-0041 Tier 1): an `api` flow waits for an external // POST. The concrete trigger (`@objectstack/trigger-api`) mounts the // endpoint and enqueues; the binding's `config` carries the hook - // details (`hookId`, `secret`) from the start node. - case 'api': + // details (`hookId`, and a packaged flow's literal `secret`) from + // the start node. [#20790] A secret the credential channel holds is + // not in `config`: `resolveSecret` reads it at verification time. + case 'api': { + const resolveSecret = this.hookSecretResolver(flowName, startNode?.id, config); return { triggerType: kind, - binding: { flowName, condition, config }, + binding: { flowName, condition, config, ...(resolveSecret ? { resolveSecret } : {}) }, }; + } default: { // [#14328] Exhaustive over `FlowTriggerKind`, and that is the point: @@ -4691,6 +4742,98 @@ export class AutomationEngine implements IAutomationService { return this.packagedFlowOwner(name) !== undefined; } + /** + * [#20790] Attach the write-only flow credential channel. The automation + * plugin calls this at `init()`, before any flow is registered. With none + * attached (a bare engine) every credential is the literal its definition + * carries, as before. + */ + setFlowCredentialSource(source: FlowCredentialSource | undefined): void { + this.flowCredentialSource = source; + } + + /** [#20790] Does the credential channel hold the LIVE credential at this position? */ + holdsFlowCredential(flowName: string, nodeId: string, key: string): boolean { + return this.flowCredentialSource?.holds(flowName, nodeId, key) ?? false; + } + + /** + * [#20790] The credential the channel holds at this position, read now. + * `undefined` when it holds none; throws when it holds one that does not + * come back (see {@link FlowCredentialSource.resolve}). + */ + async resolveFlowCredential(flowName: string, nodeId: string, key: string): Promise { + if (!this.flowCredentialSource) return undefined; + return this.flowCredentialSource.resolve(flowName, nodeId, key); + } + + /** + * [#20790] Every credential the registered flow `name` holds, by class — + * a literal in its definition, or one the channel holds for one of its + * credential positions. `[]` for an unknown name. The clone door's input: + * a flow that holds any credential is never cloned in one step, because a + * copy would share it. + */ + flowCredentialHoldings(name: string): FlowCredentialHolding[] { + const flow = this.flows.get(name); + if (!flow) return []; + const out: FlowCredentialHolding[] = []; + const seen = new Set(); + for (const position of flowCredentialPositions(flow)) { + const id = JSON.stringify([position.nodeId, position.key]); + if (position.form === 'value') { + seen.add(id); + out.push({ nodeId: position.nodeId, key: position.key, label: flowCredentialClassLabel(position.key), held: 'literal' }); + } + } + for (const { nodeId, key } of this.flowCredentialSource?.held(name) ?? []) { + const id = JSON.stringify([nodeId, key]); + if (seen.has(id)) continue; + out.push({ nodeId, key, label: flowCredentialClassLabel(key), held: 'channel' }); + } + return out; + } + + /** + * [#20790] The verification-time reader for an `api` flow's hook secret, or + * `undefined` when the flow has none to verify with — the binding then + * carries no resolver and both registration doors refuse it, exactly as + * before. + * + * Q3 A, as ruled: a packaged flow's literal stays its author's source of + * truth, and at verification the channel's row wins where one exists. So + * a LITERAL start-node secret yields a reader that asks the channel only + * when the channel's index says it holds that position (the `http` node's + * shape — both doors read one rule), and otherwise answers the literal + * without touching the channel; a WITHHELD one (the key absent — every + * flow stored through the metadata save door) yields one only when the + * channel holds it; a cleared or unusable one yields none. + * + * A HELD secret that does not come back still rejects — it is never + * verified against the literal. The index is per process: a row written + * after its last refresh (boot, `kernel:ready`, `metadata:reloaded`, every + * channel write in this process) loses to the literal until the next + * refresh, exactly as at the `http` node. + */ + private hookSecretResolver( + flowName: string, + startNodeId: string | undefined, + config: Record, + ): (() => Promise) | undefined { + const source = this.flowCredentialSource; + const written = Object.prototype.hasOwnProperty.call(config, FLOW_HOOK_SECRET_KEY); + const literal = typeof config.secret === 'string' && config.secret.trim() !== '' ? config.secret : undefined; + if (written && literal === undefined) return undefined; + if (!source || startNodeId === undefined) { + return literal === undefined ? undefined : async () => literal; + } + if (literal === undefined && !source.holds(flowName, startNodeId, FLOW_HOOK_SECRET_KEY)) return undefined; + return async () => { + if (literal !== undefined && !source.holds(flowName, startNodeId, FLOW_HOOK_SECRET_KEY)) return literal; + return (await source.resolve(flowName, startNodeId, FLOW_HOOK_SECRET_KEY)) ?? literal; + }; + } + /** * [ADR-0126 §7.2] Load the ledger into {@link flowLedgerDisabled}. * @@ -10211,6 +10354,13 @@ export class AutomationEngine implements IAutomationService { if (resolved?.triggerType !== 'api') return; const config = (resolved.binding.config ?? {}) as Record; if (typeof config.secret === 'string' && config.secret.trim() !== '') return; + // [#20790] …or the write-only credential channel holds it: a flow stored + // through the metadata save door keeps no secret in its definition, and + // its binding carries the reader instead. Only for the WITHHELD form — + // `hookSecretResolver` yields no reader for a start node that writes + // `secret: ''` (cleared), so that one is refused below whatever the + // channel still holds. + if (resolved.binding.resolveSecret) return; const asks = [ flow.type === 'api' ? "`type: 'api'`" : undefined, config.triggerType === 'api' ? "start-node `config.triggerType: 'api'`" : undefined, diff --git a/packages/services/service-automation/src/flow-credential-channel.test.ts b/packages/services/service-automation/src/flow-credential-channel.test.ts new file mode 100644 index 00000000000..c989f207744 --- /dev/null +++ b/packages/services/service-automation/src/flow-credential-channel.test.ts @@ -0,0 +1,350 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20790] The write-only flow credential channel, on a real ObjectQL engine + * over SQLite — the #7799 seam it reuses (a `secret`-typed field the engine + * encrypts, masks and dereferences) is the engine's, so nothing about it is + * stubbed here. + * + * Pinned: a channel write and its masked reads; the per-position write rule + * (absent keeps, an explicit value rotates, `''` clears, a vanished position + * is dropped); a draft never touches the live credential until it is + * promoted; a restore strip writes nothing; and with no crypto provider a + * save carrying a credential is refused before anything is written. + * + * Every value is a probe sentinel, not a credential. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysSecret } from '@objectstack/platform-objects'; +import { SECRET_MASK } from '@objectstack/spec/data'; +import type { CryptoContext, CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; + +import { + FLOW_CREDENTIAL_OBJECT, + FLOW_CREDENTIAL_UNAVAILABLE_CODE, + FLOW_CREDENTIAL_UNAVAILABLE_STATUS, + FlowCredentialChannel, + FlowCredentialChannelRefusal, + FlowCredentialUnresolvableError, +} from './flow-credential-channel.js'; +import { AutomationEngine } from './engine.js'; +import type { FlowTriggerBinding } from './engine.js'; +import { redactFlowCredentials } from './flow-credential-projection.js'; +import { SysFlowCredential } from './sys-flow-credential.object.js'; + +const HOOK = 'pin-channel-hook-7a1c'; +const SIGN = 'pin-channel-sign-3e9b'; +const NESTED = 'pin-channel-nested-5d20'; +const ROTATED = 'pin-channel-rotated-c4f1'; +const DRAFTED = 'pin-channel-drafted-08aa'; +const PACKAGED = 'pin-channel-packaged-literal-e2b6'; +const ALL = [HOOK, SIGN, NESTED, ROTATED, DRAFTED]; +const SYSTEM = { isSystem: true } as const; + +/** Reversible stand-in cipher — the seam's behaviour is the engine's, not the cipher's. */ +function fakeCrypto(): ICryptoProvider { + let n = 0; + return { + async encrypt(plain: string, _ctx: CryptoContext): Promise { + n += 1; + return { id: `sec_${n}`, kmsKeyId: 'test', alg: 'test-rev', version: 1, ciphertext: [...plain].reverse().join('') }; + }, + async decrypt(handle: CryptoHandle): Promise { + return [...handle.ciphertext].reverse().join(''); + }, + async rotateKey(handle: CryptoHandle): Promise { + return { ...handle, version: handle.version + 1 }; + }, + digest: (plain: string) => `d:${plain.length}`, + keyedDigest: async (plain: string) => `k:${plain.length}`, + }; +} + +const engines: ObjectQL[] = []; +afterEach(async () => { + for (const e of engines.splice(0)) { + try { await (e as any).destroy?.(); } catch { /* noop */ } + } +}); + +async function boot(withCrypto = true): Promise { + const ql = new ObjectQL(); + engines.push(ql); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.connect(); + ql.registerDriver(driver, true); + await ql.init(); + (ql as any).registry.registerObject(SysSecret as never, 'test'); + (ql as any).registry.registerObject(SysFlowCredential as never, 'test'); + await ql.syncSchemas(); + if (withCrypto) ql.setCryptoProvider(fakeCrypto()); + return ql; +} + +/** An inbound flow whose `http` node signs — one more inside a loop body. */ +function inbound(secrets: { hook?: string; sign?: string; nested?: string } = {}) { + const startConfig: Record = { triggerType: 'api', hookId: 'h1' }; + if (secrets.hook !== undefined) startConfig.secret = secrets.hook; + const signConfig: Record = { url: 'https://example.invalid/out', method: 'POST' }; + if (secrets.sign !== undefined) signConfig.signingSecret = secrets.sign; + const nestedConfig: Record = { url: 'https://example.invalid/each', method: 'POST' }; + if (secrets.nested !== undefined) nestedConfig.signingSecret = secrets.nested; + return { + name: 'pin_inbound', + label: 'Pin inbound', + type: 'api', + nodes: [ + { id: 'begin', type: 'start', label: 'Start', config: startConfig }, + { id: 'callout', type: 'http', label: 'Callout', config: signConfig }, + { + id: 'each', + type: 'loop', + label: 'Each', + config: { + collection: '{items}', + body: { nodes: [{ id: 'inner_call', type: 'http', label: 'Inner', config: nestedConfig }], edges: [] }, + }, + }, + { id: 'finish', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'begin', target: 'callout' }, + { id: 'e2', source: 'callout', target: 'each' }, + { id: 'e3', source: 'each', target: 'finish' }, + ], + }; +} + +async function channelRows(ql: ObjectQL): Promise { + return (await ql.find(FLOW_CREDENTIAL_OBJECT, { context: SYSTEM } as never)) as any[]; +} + +const startNodeOf = (flow: any) => flow.nodes.find((n: any) => n.type === 'start'); +const calloutOf = (flow: any) => flow.nodes.find((n: any) => n.id === 'callout'); + +describe('[#20790] a channel write, and its masked reads', () => { + it('moves every credential out of the body, at every depth; stores ciphertext; every read returns the mask', async () => { + const ql = await boot(); + const channel = new FlowCredentialChannel(() => ql as never); + const body = inbound({ hook: HOOK, sign: SIGN, nested: NESTED }); + + const stored = await channel.store({ name: 'pin_inbound', state: 'active', body }); + + // The stored body is exactly what a read serves — no credential in it. + expect(stored).toEqual(redactFlowCredentials(body).item); + for (const s of ALL) expect(JSON.stringify(stored)).not.toContain(s); + // …and the caller's body is untouched (copy-on-write). + expect(startNodeOf(body).config.secret).toBe(HOOK); + + // One row per position; every engine read masks the value. + const rows = await channelRows(ql); + expect(rows.map((r) => `${r.node_id}.${r.credential_key}:${r.state}`).sort()).toEqual([ + 'begin.secret:active', + 'callout.signingSecret:active', + 'inner_call.signingSecret:active', + ]); + for (const row of rows) expect(row.value).toBe(SECRET_MASK); + for (const s of ALL) expect(JSON.stringify(rows)).not.toContain(s); + // The ciphertext is not the cleartext either. + const ciphers = (await ql.find('sys_secret', { context: SYSTEM } as never)) as any[]; + expect(ciphers).toHaveLength(3); + for (const s of ALL) expect(JSON.stringify(ciphers)).not.toContain(s); + + // Only the privileged dereference gets the value back. + expect(channel.holds('pin_inbound', 'begin', 'secret')).toBe(true); + expect(await channel.resolve('pin_inbound', 'begin', 'secret')).toBe(HOOK); + expect(await channel.resolve('pin_inbound', 'inner_call', 'signingSecret')).toBe(NESTED); + expect(await channel.resolve('pin_inbound', 'finish', 'secret')).toBeUndefined(); + }); + + it('a fresh channel reads which positions are held back from the store (the boot index)', async () => { + const ql = await boot(); + await new FlowCredentialChannel(() => ql as never).store({ name: 'pin_inbound', state: 'active', body: inbound({ hook: HOOK }) }); + const fresh = new FlowCredentialChannel(() => ql as never); + expect(fresh.holds('pin_inbound', 'begin', 'secret')).toBe(false); + expect(await fresh.loadIndex()).toBe(true); + expect(fresh.holds('pin_inbound', 'begin', 'secret')).toBe(true); + expect(fresh.held('pin_inbound')).toEqual([{ nodeId: 'begin', key: 'secret' }]); + }); +}); + +describe('[#20790] the per-position write rule', () => { + it('absent keeps; an explicit value rotates; `\'\'` clears; a vanished position is dropped', async () => { + const ql = await boot(); + const channel = new FlowCredentialChannel(() => ql as never); + await channel.store({ name: 'pin_inbound', state: 'active', body: inbound({ hook: HOOK, sign: SIGN }) }); + + // The served (withheld) form round-trips: nothing changes. + await channel.store({ name: 'pin_inbound', state: 'active', body: inbound() }); + expect(await channel.resolve('pin_inbound', 'begin', 'secret')).toBe(HOOK); + expect(await channel.resolve('pin_inbound', 'callout', 'signingSecret')).toBe(SIGN); + + // Only an explicit value rotates. + await channel.store({ name: 'pin_inbound', state: 'active', body: inbound({ hook: ROTATED }) }); + expect(await channel.resolve('pin_inbound', 'begin', 'secret')).toBe(ROTATED); + expect(await channel.resolve('pin_inbound', 'callout', 'signingSecret')).toBe(SIGN); + + // The cleared form removes the row and is stored as written. + const cleared = await channel.store({ name: 'pin_inbound', state: 'active', body: inbound({ sign: '' }) }); + expect(calloutOf(cleared).config.signingSecret).toBe(''); + expect(await channel.resolve('pin_inbound', 'callout', 'signingSecret')).toBeUndefined(); + expect(channel.holds('pin_inbound', 'callout', 'signingSecret')).toBe(false); + + // A node whose kind no longer holds the credential loses its row. + const retyped: any = inbound(); + startNodeOf(retyped).type = 'decision'; + await channel.store({ name: 'pin_inbound', state: 'active', body: retyped }); + expect(await channel.resolve('pin_inbound', 'begin', 'secret')).toBeUndefined(); + expect(await channelRows(ql)).toEqual([]); + }); + + it('the runtime gate is told where a withheld credential is held — never where one is cleared', async () => { + const ql = await boot(); + const channel = new FlowCredentialChannel(() => ql as never); + await channel.store({ name: 'pin_inbound', state: 'active', body: inbound({ hook: HOOK, sign: SIGN }) }); + + expect(await channel.heldPaths({ name: 'pin_inbound', state: 'active', item: inbound() })).toEqual([ + 'nodes.0.config.secret', + 'nodes.1.config.signingSecret', + ]); + expect(await channel.heldPaths({ name: 'pin_inbound', state: 'active', item: inbound({ hook: '' }) })).toEqual([ + 'nodes.1.config.signingSecret', + ]); + expect(await channel.heldPaths({ name: 'other_flow', state: 'active', item: inbound() })).toEqual([]); + }); +}); + +describe('[#20790] a draft-to-active promotion', () => { + it('a draft save never touches the live credential; publishing the draft promotes it', async () => { + const ql = await boot(); + const channel = new FlowCredentialChannel(() => ql as never); + await channel.store({ name: 'pin_inbound', state: 'active', body: inbound({ hook: HOOK, sign: SIGN }) }); + + const draftBody = await channel.store({ name: 'pin_inbound', state: 'draft', body: inbound({ hook: DRAFTED }) }); + expect(JSON.stringify(draftBody)).not.toContain(DRAFTED); + // The live hook still verifies with the published secret. + expect(await channel.resolve('pin_inbound', 'begin', 'secret')).toBe(HOOK); + // The publish gate reads the draft's withheld secret as present. + expect(await channel.heldPaths({ name: 'pin_inbound', state: 'draft', item: draftBody })).toContain('nodes.0.config.secret'); + + const { promoted } = await channel.promote({ name: 'pin_inbound', body: draftBody }); + expect(promoted).toBe(1); + expect(await channel.resolve('pin_inbound', 'begin', 'secret')).toBe(DRAFTED); + // A position the draft left withheld keeps its live credential. + expect(await channel.resolve('pin_inbound', 'callout', 'signingSecret')).toBe(SIGN); + // The draft's rows are consumed. + expect((await channelRows(ql)).filter((r) => r.state === 'draft')).toEqual([]); + }); + + it('a draft that clears a credential removes the live one when published', async () => { + const ql = await boot(); + const channel = new FlowCredentialChannel(() => ql as never); + await channel.store({ name: 'pin_inbound', state: 'active', body: inbound({ hook: HOOK, sign: SIGN }) }); + const draftBody = await channel.store({ name: 'pin_inbound', state: 'draft', body: inbound({ sign: '' }) }); + // Until published, the live callout still signs. + expect(await channel.resolve('pin_inbound', 'callout', 'signingSecret')).toBe(SIGN); + await channel.promote({ name: 'pin_inbound', body: draftBody }); + expect(await channel.resolve('pin_inbound', 'callout', 'signingSecret')).toBeUndefined(); + expect(await channel.resolve('pin_inbound', 'begin', 'secret')).toBe(HOOK); + }); + + it('deleting the stored rows drops the credentials of every state whose row is gone', async () => { + const ql = await boot(); + const channel = new FlowCredentialChannel(() => ql as never); + await channel.store({ name: 'pin_inbound', state: 'active', body: inbound({ hook: HOOK }) }); + await channel.store({ name: 'pin_inbound', state: 'draft', body: inbound({ hook: DRAFTED }) }); + // Only the draft was discarded: the live credential stays. + await channel.prune({ name: 'pin_inbound', liveStates: new Set(['active']) }); + expect((await channelRows(ql)).map((r) => r.state)).toEqual(['active']); + // The flow is gone: so is its credential, and a later flow of the + // same name never inherits it. + await channel.prune({ name: 'pin_inbound', liveStates: new Set() }); + expect(await channelRows(ql)).toEqual([]); + expect(channel.holds('pin_inbound', 'begin', 'secret')).toBe(false); + }); +}); + +describe('[#20790] a restore strips and writes nothing (R2)', () => { + it('the body a rollback stores carries no credential, and the channel keeps its current one', async () => { + const ql = await boot(); + const channel = new FlowCredentialChannel(() => ql as never); + await channel.store({ name: 'pin_inbound', state: 'active', body: inbound({ hook: ROTATED }) }); + const before = await channelRows(ql); + + // A version written before the move still holds the old literal. + const restored = channel.strip(inbound({ hook: HOOK, sign: SIGN })); + for (const s of [HOOK, SIGN]) expect(JSON.stringify(restored)).not.toContain(s); + expect(await channelRows(ql)).toEqual(before); + expect(await channel.resolve('pin_inbound', 'begin', 'secret')).toBe(ROTATED); + }); +}); + +describe('[#20790] no crypto provider ⇒ the save is refused before anything is written', () => { + it('refuses with the ADR-0112 pair, names the credential by class, writes no row and no ciphertext', async () => { + const ql = await boot(false); + const channel = new FlowCredentialChannel(() => ql as never); + + const refusal = await channel + .store({ name: 'pin_inbound', state: 'active', body: inbound({ hook: HOOK, sign: SIGN }) }) + .then(() => undefined, (e: unknown) => e); + expect(refusal).toBeInstanceOf(FlowCredentialChannelRefusal); + expect((refusal as FlowCredentialChannelRefusal).code).toBe(FLOW_CREDENTIAL_UNAVAILABLE_CODE); + expect((refusal as FlowCredentialChannelRefusal).status).toBe(FLOW_CREDENTIAL_UNAVAILABLE_STATUS); + expect((refusal as Error).message).toContain('the inbound hook secret'); + for (const s of ALL) expect((refusal as Error).message).not.toContain(s); + + expect(await channelRows(ql)).toEqual([]); + expect((await ql.find('sys_secret', { context: SYSTEM } as never)) as any[]).toEqual([]); + expect(channel.holds('pin_inbound', 'begin', 'secret')).toBe(false); + + // A body that carries no credential still saves: nothing needs the provider. + await expect(channel.store({ name: 'pin_inbound', state: 'active', body: inbound() })).resolves.toBeTruthy(); + }); +}); + +describe('[#20790] Q3 A at the inbound door: a packaged literal asks the channel only for a held position', () => { + /** An engine whose `api` trigger records the binding it is handed, with `channel` as its credential source. */ + function engineOn(channel: FlowCredentialChannel): { engine: AutomationEngine; started: FlowTriggerBinding[] } { + const engine = new AutomationEngine({ debug() {}, info() {}, warn() {}, error() {} } as never); + const started: FlowTriggerBinding[] = []; + engine.registerTrigger({ type: 'api', start: (b) => { started.push(b); }, stop: () => {} }); + engine.setFlowCredentialSource(channel); + return { engine, started }; + } + const packaged = (secret: string) => ({ + name: 'pin_packaged', + label: 'Pin packaged', + type: 'api', + status: 'active', + nodes: [ + { id: 'begin', type: 'start', label: 'Start', config: { hookId: 'h1', secret } }, + { id: 'finish', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'begin', target: 'finish' }], + }); + + it('with no reachable store, the literal verifies and the channel is never asked', async () => { + const channel = new FlowCredentialChannel(() => undefined); + const { engine, started } = engineOn(channel); + engine.registerFlow('pin_packaged', packaged(PACKAGED)); + expect(started).toHaveLength(1); + expect(await started[0]!.resolveSecret!()).toBe(PACKAGED); + }); + + it('the control: a HELD position whose store becomes unreachable rejects, never answers the literal', async () => { + const ql = await boot(); + let reachable: ObjectQL | undefined = ql; + const channel = new FlowCredentialChannel(() => reachable as never); + await channel.store({ name: 'pin_packaged', state: 'active', body: packaged(HOOK) }); + expect(channel.holds('pin_packaged', 'begin', 'secret')).toBe(true); + const { engine, started } = engineOn(channel); + engine.registerFlow('pin_packaged', packaged(PACKAGED)); + // Held and readable: the channel row wins over the literal. + expect(await started[0]!.resolveSecret!()).toBe(HOOK); + + reachable = undefined; + await expect(started[0]!.resolveSecret!()).rejects.toBeInstanceOf(FlowCredentialUnresolvableError); + }); +}); diff --git a/packages/services/service-automation/src/flow-credential-channel.ts b/packages/services/service-automation/src/flow-credential-channel.ts new file mode 100644 index 00000000000..06f7e65e07b --- /dev/null +++ b/packages/services/service-automation/src/flow-credential-channel.ts @@ -0,0 +1,514 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # The flow credential channel — write-only, on the #7799 seam (#20790) + * + * A flow's two credentials — the inbound hook's secret on its start node and + * an `http` node's signing secret — used to live in the flow DEFINITION, so + * the stored metadata row, every version-history row and the row's content + * hash carried them in cleartext, and every read exit had to withhold them one + * door at a time. This module moves them out: + * + * authored literal → the metadata save door (`store`, registered on the + * protocol as the `flow` credential channel) + * → `sys_flow_credential.value` (`type: 'secret'`) + * → the engine encrypts it → `sys_secret` ciphertext row + * stored definition → the same definition WITHOUT it — exactly the form a + * read serves (`stripFlowCredentialValues`) + * + * and the engine recovers the plaintext server-side, only at verification + * (an inbound post) and execution (an `http` node signing), through + * `engine.resolveSecretField()`. ⛔ No second secret mechanism: the cipher, + * the masking and the fail-closed posture are the engine's own. + * + * ## The write rule, per position (`flowCredentialPositions`) + * + * - an explicit value → written to the channel (replacing what it held) and + * removed from the definition — the only way a credential rotates; + * - absent (the withheld form a read serves) → the channel keeps what it + * holds — #20552's round-trip rule, unchanged; + * - the cleared form `''` → the channel's row is removed and `''` is stored, + * so "cleared" stays distinguishable from "withheld"; + * - a row whose position the definition no longer has (the node removed, or + * its kind changed) → removed: a credential never outlives its position. + * + * Every write of a VALUE comes first: with no crypto provider the engine + * refuses the first one before anything — channel row or stored row — is + * written, and the save is refused with {@link FlowCredentialChannelRefusal}. + * + * ## Lifecycle state + * + * A row belongs to one lifecycle state of the stored row: a DRAFT save writes + * `state: 'draft'` rows, so it never rotates the live hook; publishing the + * draft promotes them (`promote`). A restore (rollback / revert) writes + * nothing here: the channel keeps its current credential (`strip`). + * + * ## The presence index + * + * The engine's registration check and binding decisions are synchronous, so + * which ACTIVE positions the channel holds is kept in memory, loaded at boot + * and kept current by every write this process makes. The value itself is + * never cached: `resolve` reads the row at the moment of use, so a rotation + * takes effect on the next post. + */ + +import { createHash } from 'node:crypto'; +import type { FlowCredentialSource } from './engine.js'; +import { + FLOW_METADATA_TYPE, + flowCredentialClassList, + flowCredentialPositions, + stripFlowCredentialValues, + type FlowCredentialPosition, +} from './flow-credential-projection.js'; + +/** The object a flow's credentials live in (`sys-flow-credential.object.ts`). */ +export const FLOW_CREDENTIAL_OBJECT = 'sys_flow_credential'; + +/** Its `type: 'secret'` column. */ +export const FLOW_CREDENTIAL_VALUE_FIELD = 'value'; + +/** The lifecycle states a row belongs to — those of the stored flow row. */ +export type FlowCredentialState = 'draft' | 'active'; + +/** + * ADR-0112 pair for a refusal this channel raises: the deployment cannot hold + * a credential safely right now (no crypto provider, no data engine, a stored + * credential that does not resolve). A standard-catalog member, so a consumer + * branches on `code`; `503` because the condition belongs to the deployment, + * not to the request, and clears when the deployment is fixed. + */ +export const FLOW_CREDENTIAL_UNAVAILABLE_CODE = 'SERVICE_UNAVAILABLE'; +export const FLOW_CREDENTIAL_UNAVAILABLE_STATUS = 503; + +/** System context — the channel is engine-owned; no caller's grants apply to it. */ +const SYSTEM_CONTEXT = { isSystem: true, positions: [], permissions: [] } as const; + +/** + * The slice of the data engine the channel uses — ObjectQL's, declared + * structurally so this package keeps no build dependency on it. + */ +export interface FlowCredentialEngine { + find(object: string, query?: Record): Promise; + insert(object: string, data: Record, options?: Record): Promise; + update(object: string, data: Record, options?: Record): Promise; + delete(object: string, options?: Record): Promise; + /** The privileged dereference of one row's `secret`-typed field (ObjectQL ≥ #7799). */ + resolveSecretField?(object: string, recordId: string, field: string): Promise; +} + + +/** + * The save door's refusal: a credential cannot be stored safely, so the save + * is refused and NOTHING is written — not the channel row, not the stored + * definition. Carries the ADR-0112 pair as fields. + */ +export class FlowCredentialChannelRefusal extends Error { + readonly code = FLOW_CREDENTIAL_UNAVAILABLE_CODE; + readonly status = FLOW_CREDENTIAL_UNAVAILABLE_STATUS; + readonly statusCode = FLOW_CREDENTIAL_UNAVAILABLE_STATUS; + constructor(message: string) { + super(message); + this.name = 'FlowCredentialChannelRefusal'; + } +} + +/** + * A credential the channel HOLDS did not come back. Never read as "no + * credential": an inbound post is then answered as unavailable rather than + * verified against nothing, and an `http` node is refused rather than sent + * unsigned. + */ +export class FlowCredentialUnresolvableError extends Error { + readonly code = FLOW_CREDENTIAL_UNAVAILABLE_CODE; + readonly status = FLOW_CREDENTIAL_UNAVAILABLE_STATUS; + constructor(message: string) { + super(message); + this.name = 'FlowCredentialUnresolvableError'; + } +} + +/** True when `err` is the engine's fail-closed refusal to persist a `secret` field. */ +function isSecretProtectionFailure(err: unknown): boolean { + return /Cannot persist secret field/i.test(String((err as Error)?.message ?? err ?? '')); +} + +/** One stored channel row, as far as the channel reads it (never the value). */ +interface ChannelRow { + id: string; + node_id: string; + credential_key: string; + state: FlowCredentialState; +} + +/** The identity of a position within one flow and state. */ +function positionKey(nodeId: string, key: string): string { + return JSON.stringify([nodeId, key]); +} + +/** The bounded column the unique index keys on: SHA-256 hex of {@link positionKey}. */ +function positionDigest(nodeId: string, key: string): string { + return createHash('sha256').update(positionKey(nodeId, key), 'utf8').digest('hex'); +} + +function rowsOf(found: unknown): Record[] { + if (Array.isArray(found)) return found as Record[]; + const env = found as { data?: unknown; records?: unknown; value?: unknown } | null | undefined; + for (const candidate of [env?.data, env?.records, env?.value]) { + if (Array.isArray(candidate)) return candidate as Record[]; + } + return []; +} + +/** + * The flow credential channel. One per automation plugin; it implements the + * engine's {@link FlowCredentialSource} and backs the protocol's `flow` + * credential channel, the publish promotion and the delete projection. + */ +export class FlowCredentialChannel implements FlowCredentialSource { + /** flow name → the ACTIVE positions the channel holds. */ + private readonly activeIndex = new Map>(); + + constructor(private readonly resolveEngine: () => FlowCredentialEngine | undefined) {} + + // ── the presence index ───────────────────────────────────────────────── + + /** + * (Re)load which active positions the channel holds. Throws when the read + * fails — the caller decides how loud that is; an index that silently read + * as empty would refuse every flow whose credential is held here. + * Returns `false` when there is no data engine to read. + */ + async loadIndex(): Promise { + const engine = this.resolveEngine(); + if (!engine) return false; + const found = await engine.find(FLOW_CREDENTIAL_OBJECT, { + where: { state: 'active' }, + fields: ['id', 'flow_name', 'node_id', 'credential_key'], + context: SYSTEM_CONTEXT, + }); + this.activeIndex.clear(); + for (const row of rowsOf(found)) { + const flow = row.flow_name; + if (typeof flow !== 'string' || typeof row.node_id !== 'string' || typeof row.credential_key !== 'string') continue; + this.indexOf(flow).add(positionKey(row.node_id, row.credential_key)); + } + return true; + } + + private indexOf(flowName: string): Set { + let set = this.activeIndex.get(flowName); + if (!set) { + set = new Set(); + this.activeIndex.set(flowName, set); + } + return set; + } + + private setIndexed(flowName: string, keys: Iterable): void { + const set = new Set(keys); + if (set.size === 0) this.activeIndex.delete(flowName); + else this.activeIndex.set(flowName, set); + } + + /** {@link FlowCredentialSource.holds}: does the channel hold this ACTIVE position? */ + holds(flowName: string, nodeId: string, key: string): boolean { + return this.activeIndex.get(flowName)?.has(positionKey(nodeId, key)) ?? false; + } + + /** {@link FlowCredentialSource.held}: every ACTIVE position the channel holds for a flow. */ + held(flowName: string): Array<{ nodeId: string; key: string }> { + return [...(this.activeIndex.get(flowName) ?? [])].map((k) => { + const [nodeId, key] = JSON.parse(k) as [string, string]; + return { nodeId, key }; + }); + } + + // ── reads ────────────────────────────────────────────────────────────── + + private requireEngine(flowName: string): FlowCredentialEngine { + const engine = this.resolveEngine(); + if (engine) return engine; + throw new FlowCredentialChannelRefusal( + `Flow '${flowName}' cannot have its credentials stored: no data engine is available to the automation ` + + 'service, and a flow credential is kept only in the encrypted flow credential store. Nothing was ' + + 'written. Compose the ObjectQL engine with the automation service.', + ); + } + + private async readRows( + engine: FlowCredentialEngine, + flowName: string, + state: FlowCredentialState, + ): Promise> { + const found = await engine.find(FLOW_CREDENTIAL_OBJECT, { + where: { flow_name: flowName, state }, + fields: ['id', 'node_id', 'credential_key', 'state'], + context: SYSTEM_CONTEXT, + }); + const out = new Map(); + for (const row of rowsOf(found)) { + if (typeof row.node_id !== 'string' || typeof row.credential_key !== 'string') continue; + if (typeof row.id !== 'string' && typeof row.id !== 'number') continue; + out.set(positionKey(row.node_id, row.credential_key), { + id: String(row.id), + node_id: row.node_id, + credential_key: row.credential_key, + state, + }); + } + return out; + } + + /** + * {@link FlowCredentialSource.resolve}: the plaintext of one ACTIVE + * position, read at the moment of use. `undefined` for exactly one fact — + * the channel holds no row there. Throws {@link FlowCredentialUnresolvableError} + * when a row exists and does not come back (no crypto provider, a missing + * ciphertext row, an engine without the privileged dereference). + */ + async resolve(flowName: string, nodeId: string, key: string): Promise { + const engine = this.resolveEngine(); + if (!engine) { + throw new FlowCredentialUnresolvableError( + `Flow '${flowName}': ${flowCredentialClassList([key])} cannot be read — no data engine is available.`, + ); + } + const rows = await this.readRows(engine, flowName, 'active'); + const k = positionKey(nodeId, key); + const row = rows.get(k); + // Keep the index honest with what the store says right now. + this.setIndexed(flowName, rows.keys()); + if (!row) return undefined; + if (typeof engine.resolveSecretField !== 'function') { + throw new FlowCredentialUnresolvableError( + `Flow '${flowName}': ${flowCredentialClassList([key])} is stored encrypted, but this data engine ` + + 'cannot dereference an encrypted field, so it cannot be read.', + ); + } + let plain: string | null; + try { + plain = await engine.resolveSecretField(FLOW_CREDENTIAL_OBJECT, row.id, FLOW_CREDENTIAL_VALUE_FIELD); + } catch (err) { + throw new FlowCredentialUnresolvableError( + `Flow '${flowName}': ${flowCredentialClassList([key])} is stored but could not be decrypted ` + + `(${(err as Error)?.message ?? String(err)}). Register the crypto provider it was written with.`, + ); + } + if (typeof plain === 'string' && plain.length > 0) return plain; + throw new FlowCredentialUnresolvableError( + `Flow '${flowName}': ${flowCredentialClassList([key])} is stored but resolved to nothing — its ` + + 'ciphertext row is missing. Set a new one by saving the flow with an explicit value.', + ); + } + + // ── the write door ───────────────────────────────────────────────────── + + /** + * The metadata save door's channel step, run immediately before the put: + * move every explicit credential of `body` into the channel for + * `(name, state)`, reconcile the rows the body no longer positions, and + * return the body WITHOUT any credential — what is stored. + * + * Throws {@link FlowCredentialChannelRefusal} (503) when a value cannot be + * stored safely; nothing is written in that case. + */ + async store(args: { name: string; state: FlowCredentialState; body: unknown }): Promise { + const positions = flowCredentialPositions(args.body); + const explicit = positions.filter((p) => p.form === 'value'); + const engine = this.resolveEngine(); + if (!engine) { + // Nothing to write and nothing held: a body with no credential keeps + // the composition's old behaviour; one WITH a credential is refused. + if (explicit.length > 0) this.requireEngine(args.name); + return stripFlowCredentialValues(args.body); + } + const rows = await this.readRows(engine, args.name, args.state); + + // 1. Every explicit value FIRST. With no crypto provider the engine + // refuses the first write before any row exists, so a refused save + // leaves the channel exactly as it was. + for (const position of explicit) { + const existing = rows.get(positionKey(position.nodeId, position.key)); + try { + if (existing) { + await engine.update( + FLOW_CREDENTIAL_OBJECT, + { id: existing.id, [FLOW_CREDENTIAL_VALUE_FIELD]: position.value }, + { context: SYSTEM_CONTEXT }, + ); + } else { + await engine.insert( + FLOW_CREDENTIAL_OBJECT, + { + flow_name: args.name, + state: args.state, + node_id: position.nodeId, + credential_key: position.key, + position: positionDigest(position.nodeId, position.key), + [FLOW_CREDENTIAL_VALUE_FIELD]: position.value, + }, + { context: SYSTEM_CONTEXT }, + ); + } + } catch (err) { + if (isSecretProtectionFailure(err)) { + throw new FlowCredentialChannelRefusal( + `Flow '${args.name}' was not saved: it carries ${flowCredentialClassList(explicit.map((p) => p.key))}, ` + + 'and a flow credential is kept only in the encrypted flow credential store, which needs a ' + + 'crypto provider this deployment has not registered. Nothing was written. Register one ' + + "on the data engine (setCryptoProvider — LocalCryptoProvider in development, a KMS-backed " + + 'provider in production) and save the flow again.', + ); + } + throw err; + } + } + + // 2. Rows whose position no longer holds a credential the channel + // keeps: cleared on purpose, replaced by something unusable, or gone. + const live = new Set( + positions + .filter((p) => p.form === 'absent' || p.form === 'value') + .map((p) => positionKey(p.nodeId, p.key)), + ); + for (const [k, row] of rows) { + if (live.has(k)) continue; + await engine.delete(FLOW_CREDENTIAL_OBJECT, { where: { id: row.id }, context: SYSTEM_CONTEXT }); + rows.delete(k); + } + + if (args.state === 'active') { + const held = new Set(rows.keys()); + for (const p of explicit) held.add(positionKey(p.nodeId, p.key)); + this.setIndexed(args.name, held); + } + return stripFlowCredentialValues(args.body); + } + + /** + * The runtime authoring gate's half: the positions of `item` whose + * credential is withheld (absent) and HELD by the channel — read as present, + * exactly as the carry-forward's restored positions are. For a draft being + * published (`state: 'draft'`) a draft row or the active row it would keep + * both count. Path spelling: `redactedKeys`' (`nodes.0.config.secret`). + */ + async heldPaths(args: { name: string; state: FlowCredentialState; item: unknown }): Promise { + const absent = flowCredentialPositions(args.item).filter((p) => p.form === 'absent'); + if (absent.length === 0) return []; + const engine = this.resolveEngine(); + if (!engine) return []; + const active = await this.readRows(engine, args.name, 'active'); + const draft = args.state === 'draft' ? await this.readRows(engine, args.name, 'draft') : undefined; + return absent + .filter((p) => { + const k = positionKey(p.nodeId, p.key); + return active.has(k) || (draft?.has(k) ?? false); + }) + .map((p) => p.path); + } + + /** + * R2 — the body a restore (rollback, revert) stores: every credential + * removed, and NOTHING written here. The channel keeps its current + * credential, so a restore never puts an exposed value back at rest and + * never appends a history copy of one. + */ + strip(body: unknown): unknown { + return stripFlowCredentialValues(body); + } + + /** + * Publish: promote the draft's rows into the live ones, for the body just + * promoted to active. Per position: a draft row replaces the active one; an + * absent position with no draft row keeps the active one; a cleared, + * unusable or vanished position loses its active row. The draft rows are + * consumed either way. + */ + async promote(args: { name: string; body: unknown }): Promise<{ promoted: number; removed: number }> { + const engine = this.requireEngine(args.name); + const positions = flowCredentialPositions(args.body); + const draft = await this.readRows(engine, args.name, 'draft'); + const active = await this.readRows(engine, args.name, 'active'); + let promoted = 0; + let removed = 0; + for (const position of positions) { + if (position.form !== 'absent') continue; + const k = positionKey(position.nodeId, position.key); + const pending = draft.get(k); + if (!pending) continue; + const live = active.get(k); + if (live) await engine.delete(FLOW_CREDENTIAL_OBJECT, { where: { id: live.id }, context: SYSTEM_CONTEXT }); + await engine.update(FLOW_CREDENTIAL_OBJECT, { id: pending.id, state: 'active' }, { context: SYSTEM_CONTEXT }); + draft.delete(k); + active.set(k, { ...pending, state: 'active' }); + promoted++; + } + const kept = new Set( + positions + .filter((p) => p.form === 'absent' || p.form === 'value') + .map((p) => positionKey(p.nodeId, p.key)), + ); + for (const [k, row] of active) { + if (kept.has(k)) continue; + await engine.delete(FLOW_CREDENTIAL_OBJECT, { where: { id: row.id }, context: SYSTEM_CONTEXT }); + active.delete(k); + removed++; + } + for (const row of draft.values()) { + await engine.delete(FLOW_CREDENTIAL_OBJECT, { where: { id: row.id }, context: SYSTEM_CONTEXT }); + } + this.setIndexed(args.name, active.keys()); + return { promoted, removed }; + } + + /** + * A stored row was deleted: drop the channel rows of every state whose + * stored row no longer exists, so a credential never outlives the flow it + * belonged to — and a later flow of the same name never inherits it. + */ + async prune(args: { name: string; liveStates: ReadonlySet }): Promise { + const engine = this.resolveEngine(); + if (!engine) return 0; + let removed = 0; + for (const state of ['draft', 'active'] as const) { + if (args.liveStates.has(state)) continue; + const rows = await this.readRows(engine, args.name, state); + for (const row of rows.values()) { + await engine.delete(FLOW_CREDENTIAL_OBJECT, { where: { id: row.id }, context: SYSTEM_CONTEXT }); + removed++; + } + if (state === 'active') this.setIndexed(args.name, []); + } + return removed; + } +} + +/** + * Which lifecycle states of the flow `name` still have a stored row — read + * after a delete, so {@link FlowCredentialChannel.prune} drops only the rows + * whose stored row is gone. Reads the state column alone, never a body. + */ +export async function storedFlowStates( + engine: Pick, + name: string, +): Promise> { + const found = await engine.find('sys_metadata', { + where: { type: FLOW_METADATA_TYPE, name }, + fields: ['state'], + context: SYSTEM_CONTEXT, + }); + const out = new Set(); + for (const row of rowsOf(found)) { + if (row.state === 'draft' || row.state === 'active') out.add(row.state); + } + return out; +} + +/** The positions of a definition that hold an explicit credential — what a migration moves. */ +export function explicitFlowCredentials(definition: unknown): FlowCredentialPosition[] { + return flowCredentialPositions(definition).filter((p) => p.form === 'value'); +} + +/** Re-exported so a caller reaches the channel's metadata type through one import. */ +export { FLOW_METADATA_TYPE }; diff --git a/packages/services/service-automation/src/flow-credential-migration.test.ts b/packages/services/service-automation/src/flow-credential-migration.test.ts new file mode 100644 index 00000000000..3909c767751 --- /dev/null +++ b/packages/services/service-automation/src/flow-credential-migration.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20790] The one-time move of stored flow credentials into the write-only + * channel — its decisions, against a recording protocol and engine: + * + * - only a stored row that still carries an explicit credential is re-saved, + * through the protocol's own save door, at its own state and package, as + * the server-stated `migrate-stored` rewrite; + * - each moved row gets the loud rotation notice (Q1 B), naming the flow and + * the credential's class, never the value; + * - with no crypto provider the first refusal defers the whole run with + * nothing written and no receipt; + * - an applied run leaves its receipt in `sys_migration` with + * `verified_at: null` and `blocking: 0`, naming flows, never values. + * + * The end-to-end half — the real door, the real channel, history kept + * append-only — is the dogfood pin `flow-credential-channel.dogfood.test.ts`. + */ +import { describe, expect, it } from 'vitest'; +import { assertEngineFindOnePredicate, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; + +import { + FLOW_CREDENTIAL_MIGRATION_ID, + flowCredentialRotationNotice, + migrateFlowCredentialsIntoChannel, +} from './flow-credential-migration.js'; + +const HOOK = 'pin-migrate-hook-0d4f'; +const SIGN = 'pin-migrate-sign-8a21'; + +const body = (name: string, hook?: string, sign?: string) => ({ + name, + label: name, + type: 'api', + nodes: [ + { id: 'begin', type: 'start', label: 'Start', config: { hookId: 'h', ...(hook !== undefined ? { secret: hook } : {}) } }, + { id: 'call', type: 'http', label: 'Call', config: { url: 'https://example.invalid', ...(sign !== undefined ? { signingSecret: sign } : {}) } }, + ], + edges: [], +}); + +function fakes(rows: Array>, refuse?: (name: string) => unknown) { + const saves: Array> = []; + const receipts: Array> = []; + const logs: Array<{ level: string; msg: string }> = []; + const engine = { + async find(object: string) { + return object === 'sys_metadata' ? rows : []; + }, + async findOne(object: string, query?: Record) { + assertEngineFindOnePredicate(object, query as never); + return receipts[0] ?? null; + }, + async insert(_o: string, data: Record) { + receipts.push(data); + return data; + }, + async update(_o: string, data: Record, options?: Record) { + assertEngineUpdateDispatch(data, options as never); + receipts[0] = { ...receipts[0], ...data }; + return data; + }, + getObject: () => ({}), + }; + const protocol = { + async saveMetaItem(request: Record) { + const refusal = refuse?.(request.name as string); + if (refusal) throw refusal; + saves.push(request); + return { success: true }; + }, + }; + const logger = { + info: (msg: string) => logs.push({ level: 'info', msg }), + warn: (msg: string) => logs.push({ level: 'warn', msg }), + error: (msg: string) => logs.push({ level: 'error', msg }), + }; + return { engine, protocol, logger, saves, receipts, logs }; +} + +describe('[#20790] the stored flow credential move', () => { + it('re-saves only rows that carry a credential, through the door, at their own state and package', async () => { + const f = fakes([ + { name: 'legacy_active', state: 'active', package_id: 'app.crm', metadata: JSON.stringify(body('legacy_active', HOOK, SIGN)) }, + { name: 'legacy_draft', state: 'draft', package_id: null, metadata: body('legacy_draft', HOOK) }, + { name: 'already_moved', state: 'active', package_id: null, metadata: JSON.stringify(body('already_moved')) }, + { name: 'cleared', state: 'active', package_id: null, metadata: JSON.stringify(body('cleared', undefined, '')) }, + ]); + const result = await migrateFlowCredentialsIntoChannel(f); + + expect(result.status).toBe('applied'); + expect(result.found).toBe(2); + expect(result.migrated).toEqual(['legacy_active (active)', 'legacy_draft (draft)']); + expect(f.saves.map((s) => [s.name, s.mode, s.packageId, s.source, s.type])).toEqual([ + ['legacy_active', 'publish', 'app.crm', 'migrate-stored', 'flow'], + ['legacy_draft', 'draft', null, 'migrate-stored', 'flow'], + ]); + // The body handed to the door is the stored one: the door moves the value. + expect(JSON.stringify(f.saves[0]!.item)).toContain(HOOK); + + // One loud rotation notice per moved row, by class, never the value. + const notices = f.logs.filter((l) => l.level === 'warn' && l.msg.includes('ROTATE:')); + expect(notices.map((n) => n.msg)).toEqual([ + flowCredentialRotationNotice('legacy_active', 'active', ['secret', 'signingSecret']), + flowCredentialRotationNotice('legacy_draft', 'draft', ['secret']), + ]); + expect(notices[0]!.msg).toContain('an outbound signing secret and the inbound hook secret'); + for (const log of f.logs) for (const s of [HOOK, SIGN]) expect(log.msg).not.toContain(s); + + // The receipt: one row, gating nothing, naming flows and never values. + expect(f.receipts).toHaveLength(1); + const receipt = f.receipts[0]!; + expect(receipt.id).toBe(FLOW_CREDENTIAL_MIGRATION_ID); + expect(receipt.verified_at).toBeNull(); + expect(receipt.blocking).toBe(0); + expect(JSON.parse(receipt.details as string).migrated).toEqual(result.migrated); + for (const s of [HOOK, SIGN]) expect(JSON.stringify(receipt)).not.toContain(s); + }); + + it('defers the whole run, writing nothing, when the first save finds no crypto provider', async () => { + const noProvider = Object.assign(new Error('no crypto provider'), { code: 'SERVICE_UNAVAILABLE', status: 503 }); + const f = fakes( + [ + { name: 'a', state: 'active', metadata: JSON.stringify(body('a', HOOK)) }, + { name: 'b', state: 'active', metadata: JSON.stringify(body('b', HOOK)) }, + ], + () => noProvider, + ); + const result = await migrateFlowCredentialsIntoChannel(f); + expect(result.status).toBe('deferred'); + expect(f.saves).toEqual([]); + expect(f.receipts).toEqual([]); + expect(f.logs.some((l) => l.msg.includes('ROTATE:'))).toBe(false); + }); + + it('a row the door refuses for another reason is reported by flow and code, and the rest still move', async () => { + const locked = Object.assign(new Error('locked'), { code: 'ITEM_LOCKED', status: 403 }); + const f = fakes( + [ + { name: 'locked_one', state: 'active', metadata: JSON.stringify(body('locked_one', HOOK)) }, + { name: 'free_one', state: 'active', metadata: JSON.stringify(body('free_one', HOOK)) }, + ], + (name) => (name === 'locked_one' ? locked : undefined), + ); + const result = await migrateFlowCredentialsIntoChannel(f); + expect(result.status).toBe('applied'); + expect(result.failed).toEqual([{ flow: 'locked_one', state: 'active', code: 'ITEM_LOCKED' }]); + expect(result.migrated).toEqual(['free_one (active)']); + expect(JSON.parse(f.receipts[0]!.details as string).failed).toEqual(result.failed); + expect(f.receipts[0]!.advisory).toBe(1); + }); + + it('finds nothing to move on a store with no credential left — and writes no receipt', async () => { + const f = fakes([{ name: 'clean', state: 'active', metadata: JSON.stringify(body('clean')) }]); + const result = await migrateFlowCredentialsIntoChannel(f); + expect(result).toEqual({ status: 'nothing-to-move', found: 0, migrated: [], failed: [] }); + expect(f.saves).toEqual([]); + expect(f.receipts).toEqual([]); + }); +}); diff --git a/packages/services/service-automation/src/flow-credential-migration.ts b/packages/services/service-automation/src/flow-credential-migration.ts new file mode 100644 index 00000000000..13bdb11f3e2 --- /dev/null +++ b/packages/services/service-automation/src/flow-credential-migration.ts @@ -0,0 +1,261 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # The one-time move of stored flow credentials into the write-only channel (#20790) + * + * From this release the metadata save door stores a flow's credentials in the + * write-only flow credential channel (`flow-credential-channel.ts`) and keeps + * none in the stored definition. Flows stored BEFORE it still carry their + * inbound hook secret and their `http` nodes' signing secrets in cleartext in + * their stored row. This moves them, once: + * + * - **Through the same door.** Each stored flow row that still carries an + * explicit credential is re-saved through the protocol's `saveMetaItem` at + * its own state and package binding, with the server-stated + * `source: 'migrate-stored'` (the platform healing its own storage, not an + * author publishing). The door's channel step moves the value and stores + * the definition without it — no second write path, and no new copy: the + * history row the re-save appends carries no credential. + * - **Rotate, don't scrub (Q1 B).** The version-history rows and audit + * snapshots written before this move keep what they recorded — both are + * append-only, and a scrub would not un-expose a value an administrator + * could already read. Each moved flow gets a loud notice, per flow, that its + * credential was exposed at rest before the move and must be rotated. The + * notice names the flow and the credential's CLASS, never the value. + * - **Packaged flows are not moved (Q3 A).** Only stored rows are read; a + * packaged flow's literal stays its author's source of truth. + * - **Fail-closed and idempotent.** With no crypto provider the first save is + * refused before anything is written, the run stops and reports itself + * deferred, and the next crypto-provider registration runs it again. A row + * with no explicit credential is skipped, so a later run finds nothing. + * - **A receipt.** A run that moved or failed to move anything records itself + * in the `sys_migration` deployment ledger, the seed-tenancy repair's + * receipt convention: `verified_at: null` (no self-check certifies it) and + * `blocking: 0` (it gates nothing). Its details name flows, never values. + */ + +import { DATA_MIGRATION_FLAG_OBJECT, type DataMigrationFlag } from '@objectstack/spec/system'; +import { explicitFlowCredentials, FLOW_CREDENTIAL_UNAVAILABLE_CODE } from './flow-credential-channel.js'; +import { FLOW_METADATA_TYPE, flowCredentialClassList } from './flow-credential-projection.js'; + +/** The receipt's id in `sys_migration` — read by an operator, gated on by nothing. */ +export const FLOW_CREDENTIAL_MIGRATION_ID = 'flow-credential-channel'; + +/** The stored-row table the migration reads (ADR-0008's one repository table). */ +const STORED_ROW_OBJECT = 'sys_metadata'; + +const SYSTEM_CONTEXT = { isSystem: true, positions: [], permissions: [] } as const; + +/** The slice of the data engine the migration reads stored rows and writes its receipt through. */ +export interface FlowCredentialMigrationEngine { + find(object: string, query?: Record): Promise; + findOne(object: string, query?: Record): Promise | null>; + insert(object: string, data: Record, options?: Record): Promise; + update(object: string, data: Record, options?: Record): Promise; + getObject?(name: string): unknown; +} + +/** The slice of the metadata protocol the migration re-saves through. */ +export interface FlowCredentialMigrationProtocol { + saveMetaItem(request: { + type: string; + name: string; + item: unknown; + mode: 'draft' | 'publish'; + packageId: string | null; + organizationId?: string; + source: string; + }): Promise; +} + +interface MigrationLogger { + info(msg: string, meta?: unknown): void; + warn(msg: string, meta?: unknown): void; + error(msg: string, error?: Error, meta?: unknown): void; +} + +export interface FlowCredentialMigrationResult { + /** + * `applied` — every row that carried a credential was attempted; + * `nothing-to-move` — no stored flow row carries one; + * `deferred` — no crypto provider yet: nothing was written, and the next + * provider registration runs it again. + */ + status: 'applied' | 'nothing-to-move' | 'deferred'; + /** Stored rows that carried an explicit credential. */ + found: number; + /** `name (state)` of every row moved. */ + migrated: string[]; + /** Rows left as they were, with the refusal's code. */ + failed: Array<{ flow: string; state: string; code: string }>; +} + +function rowsOf(found: unknown): Record[] { + if (Array.isArray(found)) return found as Record[]; + const env = found as { data?: unknown; records?: unknown; value?: unknown } | null | undefined; + for (const candidate of [env?.data, env?.records, env?.value]) { + if (Array.isArray(candidate)) return candidate as Record[]; + } + return []; +} + +function parseBody(raw: unknown): unknown { + if (typeof raw !== 'string') return raw; + try { + return JSON.parse(raw); + } catch { + return undefined; + } +} + +/** + * The rotation notice for one moved row (Q1 B). Exported so the pin reads the + * one sentence the log carries. Names the flow and the credential classes — + * never a value, a node id or a path. + */ +export function flowCredentialRotationNotice(flow: string, state: string, keys: Iterable): string { + const distinct = new Set(keys); + const classes = flowCredentialClassList(distinct); + const plural = distinct.size > 1; + return ( + `[Automation] flow '${flow}' (${state}): ${classes} ${plural ? 'were' : 'was'} stored in cleartext in the flow ` + + `definition before this release — in its stored row and its version history, where an administrator could read ` + + `${plural ? 'them' : 'it'}. ${plural ? 'They are' : 'It is'} now held by the write-only flow credential store and ` + + 'no longer stored in the definition; the copies already written stay in the append-only version history and ' + + 'audit trail. ROTATE: save the flow with a new value for each, and give the new secret to whoever signs posts ' + + 'to this hook or verifies these deliveries.' + ); +} + +/** The receipt row for one run — pure. See the module header for the field reading. */ +export function buildFlowCredentialReceipt(result: FlowCredentialMigrationResult, now: string): DataMigrationFlag { + return { + id: FLOW_CREDENTIAL_MIGRATION_ID, + last_run_at: now, + applied_at: now, + verified_at: null, + blocking: 0, + advisory: result.failed.length, + details: JSON.stringify({ + status: result.status, + found: result.found, + migrated: result.migrated, + failed: result.failed, + }), + }; +} + +async function persistReceipt(engine: FlowCredentialMigrationEngine, flag: DataMigrationFlag): Promise { + const existing = await engine.findOne(DATA_MIGRATION_FLAG_OBJECT, { where: { id: flag.id }, context: SYSTEM_CONTEXT }); + const row: Record = { ...flag, updated_at: flag.last_run_at }; + if (existing?.id === flag.id) { + await engine.update(DATA_MIGRATION_FLAG_OBJECT, row, { context: SYSTEM_CONTEXT }); + return; + } + await engine.insert(DATA_MIGRATION_FLAG_OBJECT, { ...row, created_at: flag.last_run_at }, { context: SYSTEM_CONTEXT }); +} + +/** + * Move every stored flow credential into the channel. Never throws: a boot + * hook and a crypto-provider listener call it, and neither may be broken by + * it. What it could not do it reports — per flow, and in the receipt. + */ +export async function migrateFlowCredentialsIntoChannel(deps: { + engine: FlowCredentialMigrationEngine; + protocol: FlowCredentialMigrationProtocol; + logger: MigrationLogger; +}): Promise { + const { engine, protocol, logger } = deps; + const result: FlowCredentialMigrationResult = { status: 'nothing-to-move', found: 0, migrated: [], failed: [] }; + + let rows: Record[]; + try { + rows = rowsOf( + await engine.find(STORED_ROW_OBJECT, { where: { type: FLOW_METADATA_TYPE }, context: SYSTEM_CONTEXT }), + ); + } catch (err) { + logger.warn( + '[Automation] the stored flow credential move could not read the stored flow rows; it runs again at the ' + + 'next boot or crypto-provider registration.', + { error: (err as Error)?.message ?? String(err) }, + ); + return result; + } + + const notices: Array<{ flow: string; state: string; keys: string[] }> = []; + for (const row of rows) { + const name = row.name; + if (typeof name !== 'string' || name === '') continue; + const body = parseBody(row.metadata); + const explicit = explicitFlowCredentials(body); + if (explicit.length === 0) continue; + const state = row.state === 'draft' ? 'draft' : 'active'; + result.found += 1; + const organizationId = typeof row.organization_id === 'string' && row.organization_id !== '' + ? row.organization_id + : undefined; + try { + await protocol.saveMetaItem({ + type: FLOW_METADATA_TYPE, + name, + item: body, + mode: state === 'draft' ? 'draft' : 'publish', + packageId: typeof row.package_id === 'string' && row.package_id !== '' ? row.package_id : null, + ...(organizationId ? { organizationId } : {}), + source: 'migrate-stored', + }); + result.migrated.push(`${name} (${state})`); + notices.push({ flow: name, state, keys: explicit.map((p) => p.key) }); + } catch (err) { + const code = typeof (err as { code?: unknown })?.code === 'string' ? (err as { code: string }).code : 'ERROR'; + if (code === FLOW_CREDENTIAL_UNAVAILABLE_CODE && result.migrated.length === 0 && notices.length === 0) { + // No crypto provider (yet): the door refused before writing + // anything, and every other row would be refused the same way. + result.status = 'deferred'; + // Functional, not a loss: nothing was written, and the move + // runs when the provider registers — `info`, every boot. + logger.info( + '[Automation] stored flow credentials wait for a crypto provider: their move into the write-only ' + + 'flow credential store runs when one is registered.', + ); + return result; + } + result.failed.push({ flow: name, state, code }); + // A security property the platform claims — no flow credential in a + // stored definition — does not hold for this row, and nothing else + // looks wrong: `error`, with the consequence and the fix. + logger.error( + `[Automation] flow '${name}' (${state}): its credential could not be moved into the write-only flow ` + + `credential store (${code}), so its stored definition STILL CARRIES IT IN CLEARTEXT, readable ` + + 'wherever the stored row is. Fix the cause and restart, or save the flow with a new value — and ' + + 'rotate it.', + err instanceof Error ? err : undefined, + { flow: name, state, code }, + ); + } + } + + if (result.found === 0) return result; + result.status = 'applied'; + for (const notice of notices) logger.warn(flowCredentialRotationNotice(notice.flow, notice.state, notice.keys)); + + if (typeof engine.getObject === 'function' && !engine.getObject(DATA_MIGRATION_FLAG_OBJECT)) { + logger.warn( + `[Automation] the stored flow credential move ran, but ${DATA_MIGRATION_FLAG_OBJECT} is not registered on this ` + + 'kernel, so the deployment ledger holds no receipt of it. Compose PlatformObjectsPlugin, or keep this ' + + "boot's log: the rotation notices above are the only record.", + ); + return result; + } + try { + await persistReceipt(engine, buildFlowCredentialReceipt(result, new Date().toISOString())); + } catch (e: unknown) { + const detail = e instanceof Error ? e.message : String(e); + const message = + `[Automation] the stored flow credential move ran, but writing its receipt to ${DATA_MIGRATION_FLAG_OBJECT} ` + + `failed (${detail}). The move itself is not retried — the rows no longer carry the credentials — so the ` + + "rotation notices above are the only record of which flows must rotate. Keep this boot's log."; + logger.error(message, e instanceof Error ? e : new Error(detail)); + } + return result; +} diff --git a/packages/services/service-automation/src/flow-credential-projection.ts b/packages/services/service-automation/src/flow-credential-projection.ts index b616a937d89..7bc6d37eed0 100644 --- a/packages/services/service-automation/src/flow-credential-projection.ts +++ b/packages/services/service-automation/src/flow-credential-projection.ts @@ -50,14 +50,21 @@ * exits reach the same registry entry through `redactMetadataItem('flow', …)`. * One helper, applied where each surface's definition leaves the process. * - * It is NOT applied to anything the engine EXECUTES. The flow map the engine - * arms triggers from keeps the stored secrets, and so does the in-process - * `getFlow` (the clone door copies a whole definition through it, ADR-0126 - * §7.1): redaction is a serving act, and a raw-record consumer keeps reading - * the stored body (`spec/kernel/metadata-type-redaction.ts`). That is also why - * this plugin binds flows from the protocol's EXECUTION read - * (`getMetaItemsForExecution`) rather than the served one — a binder reading - * the served view would register every `api` flow without its secret. + * It is NOT applied to anything the engine EXECUTES: redaction is a serving + * act, and a raw-record consumer keeps reading the stored body + * (`spec/kernel/metadata-type-redaction.ts`). That is why this plugin binds + * flows from the protocol's EXECUTION read (`getMetaItemsForExecution`) rather + * than the served one — a packaged flow's literal reaches the engine only + * through it. + * + * [#20790] And a STORED flow no longer carries its credentials at all: the + * metadata save door moves every explicit value into the write-only flow + * credential channel (`flow-credential-channel.ts`), using + * {@link flowCredentialPositions} and {@link stripFlowCredentialValues} below, + * so the stored row, its history and its hash hold exactly what a read serves. + * The engine reads a channel-held credential only at verification (an inbound + * post) and execution (an `http` node signing); the clone door refuses a + * source that holds one, literal or channel-held. * * ## Dropped, not masked * @@ -130,6 +137,28 @@ export const FLOW_NODE_CREDENTIAL_KEYS: ReadonlyMap = ['http', [HTTP_SIGNING_SECRET_KEY]], ]); +/** + * [#20790] What each credential key in {@link FLOW_NODE_CREDENTIAL_KEYS} is, + * in an administrator's words — the CLASS a refusal or a rotation notice names + * instead of a node id, a path or a value. Beside the table, so a key added to + * it gets its label in the same place; a key with no label is named by its + * spelling. + */ +export const FLOW_CREDENTIAL_CLASS_LABELS: ReadonlyMap = new Map([ + [FLOW_HOOK_SECRET_KEY, 'the inbound hook secret'], + [HTTP_SIGNING_SECRET_KEY, 'an outbound signing secret'], +]); + +/** The class label of a credential key — {@link FLOW_CREDENTIAL_CLASS_LABELS}, else the key's spelling. */ +export function flowCredentialClassLabel(key: string): string { + return FLOW_CREDENTIAL_CLASS_LABELS.get(key) ?? `the credential at \`${key}\``; +} + +/** The class labels of a set of credential keys, deduplicated and joined for a sentence. */ +export function flowCredentialClassList(keys: Iterable): string { + return [...new Set([...keys].map(flowCredentialClassLabel))].sort().join(' and '); +} + /** * The explicit clearing value: a credential key set to it holds no credential, * so it is served as written rather than withheld — the one unambiguous way to @@ -142,6 +171,99 @@ function isPlainRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } +/** + * [#20790] One credential POSITION of a flow definition: a node of a kind + * {@link FLOW_NODE_CREDENTIAL_KEYS} lists, one of its keys, and what the + * definition carries there. + * + * - `absent` — the key is not written: the served (withheld) form, so it + * means "unchanged" on a write, and on a stored row it means + * the credential, if any, is held by the write-only channel; + * - `cleared` — {@link FLOW_CREDENTIAL_CLEARED}: no credential, on purpose; + * - `value` — a non-empty string: an explicit credential (a literal); + * - `unusable` — anything else: no credential a door can use, and the engine + * refuses it where one is required. + */ +export interface FlowCredentialPosition { + /** Dotted, item-relative (`nodes.0.config.secret`) — the path spelling `redactedKeys` and the runtime gate use. */ + readonly path: string; + readonly nodeId: string; + readonly nodeType: string; + readonly key: string; + readonly form: 'absent' | 'cleared' | 'value' | 'unusable'; + /** The literal, for `form: 'value'` only. */ + readonly value?: string; +} + +function collectNodePositions(nodes: readonly unknown[], path: string, out: FlowCredentialPosition[]): void { + nodes.forEach((node, index) => collectNodePosition(node, `${path}.${index}`, out)); +} + +function collectRegionPositions(region: unknown, path: string, out: FlowCredentialPosition[]): void { + if (isPlainRecord(region) && Array.isArray(region.nodes)) collectNodePositions(region.nodes, `${path}.nodes`, out); +} + +function collectNodePosition(node: unknown, path: string, out: FlowCredentialPosition[]): void { + if (!isPlainRecord(node) || typeof node.type !== 'string') return; + const config = isPlainRecord(node.config) ? node.config : undefined; + // `FlowNodeSchema` requires a string id, and every write reaches the + // channel after the schema gate; a node without one is not a position. + if (typeof node.id === 'string') { + for (const key of FLOW_NODE_CREDENTIAL_KEYS.get(node.type) ?? []) { + const has = !!config && Object.prototype.hasOwnProperty.call(config, key); + const raw = has ? config![key] : undefined; + const form: FlowCredentialPosition['form'] = !has + ? 'absent' + : raw === FLOW_CREDENTIAL_CLEARED + ? 'cleared' + : typeof raw === 'string' + ? 'value' + : 'unusable'; + out.push({ + path: `${path}.config.${key}`, + nodeId: node.id, + nodeType: node.type, + key, + form, + ...(form === 'value' ? { value: raw as string } : {}), + }); + } + } + if (!config) return; + for (const slot of FLOW_REGION_SLOTS_BY_TYPE.get(node.type) ?? []) { + const value = config[slot.key]; + const slotPath = `${path}.config.${slot.key}`; + if (slot.arity === 'one') collectRegionPositions(value, slotPath, out); + else if (Array.isArray(value)) value.forEach((region, i) => collectRegionPositions(region, `${slotPath}.${i}`, out)); + } +} + +/** + * [#20790] Every credential position of a flow definition, at every depth a + * node can sit — the same table and the same region walk the projection below + * uses, so the write-only channel and the read projection cannot disagree + * about where a credential is. Pure. + */ +export function flowCredentialPositions(definition: unknown): FlowCredentialPosition[] { + const out: FlowCredentialPosition[] = []; + if (isPlainRecord(definition) && Array.isArray(definition.nodes)) collectNodePositions(definition.nodes, 'nodes', out); + return out; +} + +/** + * [#20790] `definition` with every credential key that is not the cleared + * form removed — the `value` and `unusable` positions of + * {@link flowCredentialPositions}; `absent` and `cleared` are left as written. + * Copy-on-write: the input is never mutated, and it is returned by reference + * when there is nothing to remove. It IS the projection's removal, so a + * stripped definition is byte-identical to what a read serves, and a stored + * definition never carries a credential key other than the cleared form. + */ +export function stripFlowCredentialValues(definition: T): T { + if (!isPlainRecord(definition)) return definition; + return redactFlowCredentials(definition).item as T; +} + /** Project a list of nodes; `undefined` when nothing in it was withheld. */ function projectNodes(nodes: readonly unknown[], path: string, redactedKeys: string[]): unknown[] | undefined { let out: unknown[] | undefined; diff --git a/packages/services/service-automation/src/index.ts b/packages/services/service-automation/src/index.ts index 4e3ea0cb941..1fa7bdbbbd8 100644 --- a/packages/services/service-automation/src/index.ts +++ b/packages/services/service-automation/src/index.ts @@ -74,6 +74,11 @@ export type { // method is barrel-reachable, so a host building a custom composition // needs the name to hand it the loader's set. PackagedFlowSource, + // [#20790] The write-only flow credential channel's engine port, and what + // `AutomationEngine.flowCredentialHoldings` answers — both + // barrel-reachable through `setFlowCredentialSource` and that method. + FlowCredentialSource, + FlowCredentialHolding, } from './engine.js'; // [#11997] ADR-0005 overlay precedence for same-named flow definitions. The boot @@ -130,6 +135,25 @@ export { InMemoryFlowDispatchStore, ObjectStoreFlowDispatchStore } from './flow- export type { FlowDispatchStoreEngine } from './flow-dispatch-store.js'; export { SysFlowDispatch } from './sys-flow-dispatch.object.js'; +// [#20790] The write-only flow credential channel: where a flow's inbound hook +// secret and http signing secrets live once they are out of its definition — +// the object, the channel the plugin registers on the metadata save door, and +// the one-time move of credentials stored before it. +export { SysFlowCredential } from './sys-flow-credential.object.js'; +export { + FlowCredentialChannel, + FlowCredentialChannelRefusal, + FlowCredentialUnresolvableError, + FLOW_CREDENTIAL_OBJECT, + FLOW_CREDENTIAL_VALUE_FIELD, +} from './flow-credential-channel.js'; +export type { FlowCredentialEngine, FlowCredentialState } from './flow-credential-channel.js'; +export { + migrateFlowCredentialsIntoChannel, + FLOW_CREDENTIAL_MIGRATION_ID, +} from './flow-credential-migration.js'; +export type { FlowCredentialMigrationResult } from './flow-credential-migration.js'; + // [ADR-0126 §4/§7.2] Packaged-flow enable/disable. The durable ledger behind // `AutomationEngine.toggleFlow` — the in-memory store is for tests and hosts // with no ObjectQL; the ObjectQL-backed store writes `sys_metadata_activation` diff --git a/packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts b/packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts index a42c3f76266..11d67d80df1 100644 --- a/packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts +++ b/packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts @@ -253,8 +253,10 @@ describe('durable suspended-run wiring (#4420)', () => { const h = await runLifecycle({ manifestPhase: 'init', data, suspendedRunStore: 'memory' }); // An explicitly ephemeral engine is a legitimate mode, not a - // degradation — it must not register the object nor complain. - expect(h.manifest.registered).toEqual([]); + // degradation — it must not register the run objects nor complain. + // [#20790] The one object it does register is the write-only flow + // credential channel's, which every composition needs. + expect(h.registeredObjects()).toEqual(['sys_flow_credential']); expect((h.engine as any).store).toBeUndefined(); expect(h.errors()).toBe(''); }); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index b183c234322..30d4b604b0a 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -21,6 +21,18 @@ import { installBuiltinNodes, rearmSuspendedWaitTimers } from './builtin/index.j import { resolveRunDataContext } from './runtime-identity.js'; import { SysAutomationRun } from './sys-automation-run.object.js'; import { SysFlowDispatch } from './sys-flow-dispatch.object.js'; +import { SysFlowCredential } from './sys-flow-credential.object.js'; +import { + FlowCredentialChannel, + storedFlowStates, + type FlowCredentialEngine, + type FlowCredentialState, +} from './flow-credential-channel.js'; +import { + migrateFlowCredentialsIntoChannel, + type FlowCredentialMigrationEngine, + type FlowCredentialMigrationProtocol, +} from './flow-credential-migration.js'; import { ObjectStoreSuspendedRunStore, DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW, @@ -603,6 +615,15 @@ export class AutomationServicePlugin implements Plugin { private runObjectRegistered = false; /** The context `init()` received — what {@link pullConnectorSource} resolves its services through. */ private ctx?: PluginContext; + /** + * [#20790] The write-only flow credential channel: the engine's credential + * source, and the `flow` credential channel of the metadata save door. + */ + private credentialChannel?: FlowCredentialChannel; + /** [#20790] Serializes the one-time credential move — see {@link scheduleCredentialMigration}. */ + private credentialMigration: Promise = Promise.resolve(); + /** [#20790] The crypto-provider subscription that re-runs the move; dropped at destroy. */ + private unsubscribeCryptoProvider?: () => void; constructor(options: AutomationServicePluginOptions = {}) { this.options = options; @@ -639,7 +660,10 @@ export class AutomationServicePlugin implements Plugin { /** * Register {@link SysAutomationRun} and {@link SysFlowDispatch} with the * `manifest` service so the suspended-run and dispatch-ledger tables - * migrate like every other `sys_*` object (ADR-0019, #10220). + * migrate like every other `sys_*` object (ADR-0019, #10220) — and + * [#20790] {@link SysFlowCredential}, the write-only flow credential + * channel, in the same registration, so one manifest answer (and at most + * one warning) covers all three. * * Returns whether it landed. Callers must honour a `false` — a durable * store attached over an unregistered object writes to a table that does @@ -655,7 +679,7 @@ export class AutomationServicePlugin implements Plugin { scope: 'system', defaultDatasource: 'cloud', namespace: 'sys', - objects: [SysAutomationRun, SysFlowDispatch], + objects: [SysAutomationRun, SysFlowDispatch, SysFlowCredential], }); return true; } catch (err) { @@ -674,6 +698,150 @@ export class AutomationServicePlugin implements Plugin { } } + /** + * [#20790] The data engine the credential channel, its index and the + * one-time move read and write through — ObjectQL, resolved at call time + * (it may register after this plugin inits). + */ + private resolveDataEngine(ctx: PluginContext): (FlowCredentialEngine & FlowCredentialMigrationEngine) | undefined { + for (const name of ['objectql', 'data']) { + try { + const engine = ctx.getService(name); + if (engine && typeof engine.find === 'function' && typeof engine.insert === 'function') return engine; + } catch { + /* not registered under this name */ + } + } + return undefined; + } + + /** + * [#20790] Register {@link SysFlowCredential} ALONE with the `manifest` + * service — the `suspendedRunStore: 'memory'` composition, which registers + * no run object: a flow credential has nowhere else to go, so the channel + * needs its table on every composition. Every other composition registers + * it with the run objects ({@link registerRunObject}). + */ + private registerCredentialObject(ctx: PluginContext): boolean { + try { + ctx.getService<{ register(m: unknown): void }>('manifest').register({ + id: 'com.objectstack.service-automation.flow-credentials', + name: 'Automation Flow Credentials', + version: '1.0.0', + type: 'plugin', + scope: 'system', + defaultDatasource: 'cloud', + namespace: 'sys', + objects: [SysFlowCredential], + }); + return true; + } catch (err) { + ctx.logger.warn( + '[Automation] manifest service unavailable; sys_flow_credential not registered yet.', + describeThrownForLog(err), + ); + return false; + } + } + + /** + * [#20790] Make the channel the `flow` credential channel of the metadata + * save door, its publish promotion and its delete — on the protocol, which + * every metadata write reaches. Resolved at `start()`, once every plugin + * has inited, so a protocol that registers after this plugin is seen. + */ + private registerCredentialChannelOnProtocol(ctx: PluginContext): void { + const channel = this.credentialChannel; + if (!channel) return; + let protocol: { + registerCredentialChannel?(type: string, channel: unknown): void; + registerPublishMaterializer?(type: string, materializer: (args: { body: unknown }) => Promise): void; + registerMutationProjector?(type: string, projector: (evt: { name?: unknown; state?: unknown }) => Promise): void; + } | undefined; + try { + protocol = ctx.getService('protocol'); + } catch { + protocol = undefined; + } + if (!protocol) { + // No metadata store: flows come from code only, and nothing stores one. + ctx.logger.debug('[Automation] no metadata protocol — no flow credential channel to register'); + return; + } + if (typeof protocol.registerCredentialChannel !== 'function') { + ctx.logger.warn( + '[Automation] the metadata protocol offers no credential channel registration — a flow saved through it ' + + 'is stored WITH its credentials in the definition. Run a metadata protocol that registers credential channels.', + ); + return; + } + protocol.registerCredentialChannel('flow', { + store: (args: { name: string; state: FlowCredentialState; body: unknown }) => channel.store(args), + heldPaths: (args: { name: string; state: FlowCredentialState; item: unknown }) => channel.heldPaths(args), + strip: (body: unknown) => channel.strip(body), + }); + // Publishing a draft promotes the draft's credentials into the live ones. + protocol.registerPublishMaterializer?.('flow', async ({ body }) => { + const name = (body as { name?: unknown } | null)?.name; + if (typeof name !== 'string' || name === '') { + return { success: false, inserted: 0, updated: 0, error: 'the published flow body names no flow' }; + } + const { promoted } = await channel.promote({ name, body }); + return { success: true, inserted: 0, updated: promoted }; + }); + // Deleting a stored row drops the credentials of every state whose row is gone. + protocol.registerMutationProjector?.('flow', async (evt) => { + if (evt?.state !== 'deleted' || typeof evt.name !== 'string') return; + const engine = this.resolveDataEngine(ctx); + if (!engine) return; + await channel.prune({ name: evt.name, liveStates: await storedFlowStates(engine, evt.name) }); + }); + } + + /** + * [#20790] (Re)load which live credentials the channel holds, before flows + * are registered — a flow stored through the save door registers on the + * strength of it. A failed read is loud: every such flow is refused until + * the next load. + */ + private async loadCredentialIndex(ctx: PluginContext, moment: string): Promise { + if (!this.credentialChannel) return; + try { + await this.credentialChannel.loadIndex(); + } catch (err) { + ctx.logger.warn( + `[Automation] the flow credential store could not be read at ${moment} — an inbound flow whose secret it ` + + 'holds is refused at registration until it can be.', + describeThrownForLog(err), + ); + } + } + + /** + * [#20790] Run the one-time move of stored flow credentials into the + * channel — at `kernel:ready`, and again whenever a crypto provider + * registers (the host injects one only after `kernel:ready`, so the first + * attempt usually defers). Serialized, idempotent and never fatal. + */ + private scheduleCredentialMigration(ctx: PluginContext): void { + this.credentialMigration = this.credentialMigration.then(async () => { + if (this.destroyed) return; + const engine = this.resolveDataEngine(ctx); + let protocol: FlowCredentialMigrationProtocol | undefined; + try { + protocol = ctx.getService('protocol'); + } catch { + protocol = undefined; + } + if (!engine || typeof protocol?.saveMetaItem !== 'function') return; + try { + await migrateFlowCredentialsIntoChannel({ engine, protocol, logger: ctx.logger }); + } catch (err) { + ctx.logger.warn('[Automation] the stored flow credential move failed', describeThrownForLog(err)); + } + }); + } + async init(ctx: PluginContext): Promise { this.ctx = ctx; this.engine = new AutomationEngine(ctx.logger, undefined, { @@ -682,6 +850,12 @@ export class AutomationServicePlugin implements Plugin { scheduledWorkPolicy: this.options.scheduledWorkPolicy, }); + // [#20790] The write-only flow credential channel — the engine reads a + // flow's credentials from it at verification and execution, and the + // metadata save door stores them in it (registered at `start()`). + this.credentialChannel = new FlowCredentialChannel(() => this.resolveDataEngine(ctx)); + this.engine.setFlowCredentialSource(this.credentialChannel); + // Register as global service — other plugins access via ctx.getService('automation') ctx.registerService('automation', this.engine); @@ -702,7 +876,10 @@ export class AutomationServicePlugin implements Plugin { // like other sys_* tables (ADR-0019). Best-effort: a host without the // manifest service still runs in-memory. Skipped when persistence is off. if ((this.options.suspendedRunStore ?? 'auto') !== 'memory') { + // [#20790] The channel's table rides the same registration. this.runObjectRegistered = this.registerRunObject(ctx); + } else { + this.registerCredentialObject(ctx); } // Seed the platform's built-in node executors. A bare @@ -733,6 +910,12 @@ export class AutomationServicePlugin implements Plugin { `[Automation] Engine started with ${nodeTypes.length} node types: ${nodeTypes.join(', ') || '(none)'}`, ); + // [#20790] The flow credential channel joins the metadata save door + // BEFORE the inert-mode return below: a one-shot tool that rewrites + // stored rows (`os migrate meta --stored`) must not store a flow + // credential back into a definition either. Registering it arms nothing. + this.registerCredentialChannelOnProtocol(ctx); + // ── Inert mode (#4454) — an engine, and nothing armed ───────────────── // A one-shot tool (`os migrate meta --stored`) needs this engine for one // read-only thing: `reservedNodeTypes`, the live executor registry that @@ -1031,6 +1214,10 @@ export class AutomationServicePlugin implements Plugin { ctx.logger.debug(`[Automation] runAs:user grant resolver not wired: ${(err as Error).message}`); } + // [#20790] Which live credentials the channel holds, before any flow is + // registered from a stored row that no longer carries its own. + await this.loadCredentialIndex(ctx, 'start'); + // Pull flow definitions from the ObjectQL schema registry. AppPlugin.init() // calls manifest.register(payload), which routes to ql.registerApp() and // stores each inline flow under type 'flow'. By the time start() runs, @@ -1162,6 +1349,8 @@ export class AutomationServicePlugin implements Plugin { // idempotently — ScheduleTrigger.start cancels + reschedules) and unregister // flows that vanished so their jobs stop. ctx.hook('metadata:reloaded', async (payload?: unknown) => { + // [#20790] A publish may have promoted credentials on another replica. + await this.loadCredentialIndex(ctx, 'metadata:reloaded'); await this.resyncFlowsFromProtocol(ctx); // #7742 — take the connector collection off the payload FIRST. The // reconcile below used to read `listItems('connector')` alone, and @@ -1203,11 +1392,25 @@ export class AutomationServicePlugin implements Plugin { // [#20913] …and it arms what the boot pull armed: both resolve through // the one precedence decision ({@link resolveFlowContenders}). ctx.hook('kernel:ready', async () => { + // [#20790] Reloaded first: the protocol's view binds stored rows, + // whose credentials the channel holds. + await this.loadCredentialIndex(ctx, 'kernel:ready'); await this.syncFlowsFromProtocol(ctx); // Every plugin's init()/start() has completed here, so connector // plugins have registered their runtime connectors — the earliest // point the declared-vs-registered comparison is meaningful. await this.auditDeclaredConnectors(ctx); + // [#20790] Move stored flow credentials into the channel, once — and + // again on every crypto-provider registration, since the host + // injects the provider only after this hook (the first attempt then + // defers without writing anything). + this.scheduleCredentialMigration(ctx); + const dataEngine = this.resolveDataEngine(ctx) as + | { onCryptoProviderChange?(listener: () => void): () => void } + | undefined; + if (!this.unsubscribeCryptoProvider && typeof dataEngine?.onCryptoProviderChange === 'function') { + this.unsubscribeCryptoProvider = dataEngine.onCryptoProviderChange(() => this.scheduleCredentialMigration(ctx)); + } }); // ── Silent-miss audit: unbound triggered flows (2026-07-17 eval) ────── @@ -2225,6 +2428,10 @@ export class AutomationServicePlugin implements Plugin { // Stop the degraded-instance retry loop first (#3017): mark destroyed so // an already-queued reconcile no-ops, and cancel any armed timer. this.destroyed = true; + // [#20790] No credential move after shutdown, and none left in flight. + this.unsubscribeCryptoProvider?.(); + this.unsubscribeCryptoProvider = undefined; + await this.credentialMigration.catch(() => undefined); this.clearDeclarativeRetryTimer(); this.degradedInstances.clear(); // Tear down materialized provider-bound connectors (ADR-0097) — e.g. an diff --git a/packages/services/service-automation/src/sys-flow-credential.object.ts b/packages/services/service-automation/src/sys-flow-credential.object.ts new file mode 100644 index 00000000000..8fadd21af27 --- /dev/null +++ b/packages/services/service-automation/src/sys-flow-credential.object.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_flow_credential — the WRITE-ONLY channel a flow's credentials live in + * (#20790, on the #7799 seam). + * + * A flow definition holds two credentials, each a literal in one node kind's + * `config` (`flow-credential-projection.ts`, `FLOW_NODE_CREDENTIAL_KEYS`): the + * inbound hook's HMAC secret on the start node, and an `http` node's outbound + * signing secret. Before this object the stored definition carried both in + * cleartext — in the stored metadata row, in every version-history row, and in + * the row's content hash — so each read exit had to project them away one + * door at a time (#20552, #21086, #21228). A projection hides the value from + * one class of reads; this object removes the class: the metadata save door + * moves every explicit value here and stores the definition without it. + * + * One row per credential POSITION of one flow in one lifecycle state: + * `(flow_name, state, node_id, credential_key)`. `node_id` because a flow's + * node ids are one space across every region (the carry-forward walks them the + * same way), and `state` because a DRAFT save must not rotate the live hook — + * a draft's row is promoted when the draft is published. The unique index + * keys on `position`, a fixed-width digest of the `(node_id, credential_key)` + * pair, because a node id is author text with no declared bound and an index + * key must have one (MySQL refuses an unbounded keyed column). + * + * `value` is `type: 'secret'` — the #7799 seam, unchanged: the engine encrypts + * it on write through the host's `ICryptoProvider` (fail-closed with no + * provider), masks it on every read path, and only the privileged + * `resolveSecretField()` dereferences it, at verification and execution time. + * + * Env-wide, like the engine's flow map, which keys flows by bare name: a flow + * declares `allowOrgOverride: false`, so its stored rows are env-wide too. + * + * Writers: the automation plugin's credential channel (the metadata save door, + * the publish promotion, a delete, the one-time migration), under a system + * context. Readers: the same channel and the engine's verification and `http` + * execution — never the generic data door, which is closed below. + * + * @namespace sys + */ +export const SysFlowCredential = ObjectSchema.create({ + name: 'sys_flow_credential', + label: 'Flow Credential', + pluralLabel: 'Flow Credentials', + icon: 'key', + isSystem: true, + managedBy: 'engine-owned', + // ADR-0066 secure-by-default: a credential store is not covered by the + // wildcard grant. Everything reads and writes it through the engine under a + // system context. + access: { default: 'private' }, + description: + "Write-only store of flow credentials (an inbound hook's secret, an http node's signing secret), one encrypted row per credential position of a flow in one lifecycle state. The flow definition keeps no copy.", + displayNameField: 'flow_name', + nameField: 'flow_name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) + highlightFields: ['flow_name', 'state', 'node_id', 'credential_key'], + + fields: { + flow_name: Field.text({ + label: 'Flow', + required: true, + readonly: true, + // The producer is the stored flow row's name — `sys_metadata.name`, 255. + maxLength: 255, + description: 'The machine name of the flow this credential belongs to.', + }), + + state: Field.select(['draft', 'active'], { + label: 'State', + required: true, + description: + "The lifecycle state of the stored flow row this credential belongs to. A draft's credential is promoted when the draft is published, so a draft save never rotates the live one.", + }), + + node_id: Field.text({ + label: 'Node', + required: true, + readonly: true, + description: "The id of the flow node whose config holds this credential.", + }), + + credential_key: Field.text({ + label: 'Credential Key', + required: true, + readonly: true, + description: "The node config key that holds this credential (`secret` on a start node, `signingSecret` on an http node).", + }), + + position: Field.text({ + label: 'Position', + required: true, + readonly: true, + // The producer is the channel: a SHA-256 hex digest, 64 characters. + maxLength: 64, + description: 'SHA-256 of the (node id, credential key) pair, computed by the channel — the bounded key the unique index carries.', + }), + + value: Field.secret({ + label: 'Value', + required: true, + description: + 'The credential, encrypted at rest by the host crypto provider and masked on every read. Resolved only server-side, when a hook post is verified or an http node signs a delivery.', + }), + + created_at: Field.datetime({ + label: 'Created At', + required: true, + defaultValue: 'NOW()', + readonly: true, + }), + }, + + indexes: [ + // One credential per position per state — the channel's upsert key. + { fields: ['flow_name', 'state', 'position'], unique: true }, + ], + + enable: { + // Engine-owned and write-only: no generic data door at all. The value is + // masked on every engine read regardless; closing the door also keeps the + // positions out of reach. + trackHistory: false, + searchable: false, + apiEnabled: false, + apiMethods: [], + }, +}); diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index 96001fe2059..32422747860 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -116,8 +116,8 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly { expect(trigger.listHooks()).toHaveLength(0); }); }); + +/** + * [#20790] A flow stored through the metadata save door keeps its secret in the + * write-only flow credential store, not in its start node, so the engine's + * binding hands this trigger a reader instead: the hook arms on it, every post + * is verified against what it reads at that moment, and a secret that cannot + * be read refuses the post rather than verifying it against nothing. + */ +describe('ApiTrigger — a secret read at verification time', () => { + const HELD = 'pin-trigger-held-5e1a'; + const ROTATED = 'pin-trigger-rotated-b82d'; + + function armWith(resolveSecret: () => Promise, config: Record = {}) { + const queue = makeFakeQueue(); + const t = new ApiTrigger(() => queue, logger as any); + t.start({ flowName: 'held_intake', config, resolveSecret }, async () => {}); + const post = (secret: string) => + t.handleRequest({ flowName: 'held_intake', hookId: 'default', rawBody: '{"a":1}', signatureHeader: sig(secret, '{"a":1}') }); + return { t, queue, post }; + } + + it('arms with no secret in its config, and verifies against what the reader holds', async () => { + let current = HELD; + const { t, queue, post } = armWith(async () => current); + expect(t.listHooks()).toEqual([{ flowName: 'held_intake', hookId: 'default', signed: true }]); + expect((await post(HELD)).status).toBe(202); + expect((await post('not-the-secret')).status).toBe(401); + expect(queue.published).toHaveLength(1); + + // A rotation applies to the next post — nothing re-arms. + current = ROTATED; + expect((await post(HELD)).status).toBe(401); + expect((await post(ROTATED)).status).toBe(202); + }); + + it('the reader is read as the literal was: trimmed', async () => { + const { post } = armWith(async () => ` ${HELD} `); + expect((await post(HELD)).status).toBe(202); + }); + + it('a secret the reader cannot produce refuses the post with 503 — never verified, never enqueued', async () => { + for (const reader of [ + async () => { throw new Error('no crypto provider'); }, + async () => undefined, + async () => ' ', + ]) { + const { queue, post } = armWith(reader as () => Promise); + const res = await post(HELD); + expect(res.status).toBe(503); + expect((res.body as any).error.code).toBe('SERVICE_UNAVAILABLE'); + expect(queue.published).toEqual([]); + } + }); + + it('an unknown hook still answers 404 before any secret is read (no oracle)', async () => { + let reads = 0; + const { t } = armWith(async () => { reads += 1; return HELD; }); + const res = await t.handleRequest({ flowName: 'held_intake', hookId: 'wrong', rawBody: '{}', signatureHeader: sig(HELD, '{}') }); + expect(res.status).toBe(404); + expect(reads).toBe(0); + }); +}); diff --git a/packages/triggers/trigger-api/src/api-trigger.ts b/packages/triggers/trigger-api/src/api-trigger.ts index 7e60a8ab5c6..0b845e08bc4 100644 --- a/packages/triggers/trigger-api/src/api-trigger.ts +++ b/packages/triggers/trigger-api/src/api-trigger.ts @@ -15,6 +15,14 @@ export interface FlowTriggerBinding { readonly condition?: string | { dialect?: string; source?: string; ast?: unknown }; readonly schedule?: unknown; readonly config?: Record; + /** + * The hook's secret, read at VERIFICATION time. The automation engine sets + * it whenever the flow has a secret — a literal in its start node, or one + * the write-only flow credential channel holds, in which case `config` + * carries none. Rejects when a held secret does not come back. A host that + * binds without that engine leaves it unset and arms from `config.secret`. + */ + readonly resolveSecret?: () => Promise; } /** Structural mirror of the engine's `FlowTrigger` extension point. */ @@ -48,7 +56,15 @@ export interface TriggerLogger { const QUEUE_PREFIX = 'flow-api'; /** - * One armed inbound hook. `secret` is required by the type, not only by + * Where an armed hook's secret comes from: the start node's literal, or a + * reader the engine handed over that reads it at verification time. + */ +type HookSecret = + | { readonly kind: 'literal'; readonly secret: string } + | { readonly kind: 'resolved'; readonly resolve: () => Promise }; + +/** + * One armed inbound hook. A secret is required by the type, not only by * {@link ApiTrigger.start}'s check: ADR-0041's `trigger-api` acceptance * criteria name a per-flow secret and HMAC verification, so a hook without * one has no legal shape to be stored in, and {@link ApiTrigger.handleRequest} @@ -57,11 +73,16 @@ const QUEUE_PREFIX = 'flow-api'; interface ArmedHook { flowName: string; hookId: string; - secret: string; + secret: HookSecret; queue: string; callback: (ctx: AutomationContext) => Promise; } +/** The trimmed, non-blank string `value` holds, else `undefined`. */ +function usableSecret(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + /** Constant-time string compare (length leak only). */ function safeEqual(a: string, b: string): boolean { const ab = Buffer.from(a, 'utf8'); @@ -104,11 +125,16 @@ export function verifySignature(secret: string, rawBody: string, header: string * - `hookId` — URL path token (default `'default'`); rotate it to revoke * old URLs without renaming the flow. * - `secret` — HMAC-SHA256 shared secret. **Required** (ADR-0041): a - * binding with no non-blank `secret` is refused — `start()` - * throws naming the flow, and nothing is armed or subscribed. - * The automation engine refuses the same flow earlier, at - * registration, so an author learns before deploying; this - * refusal is what holds for a host that binds without it. + * binding with no non-blank `secret` and no + * `resolveSecret` is refused — `start()` throws naming the + * flow, and nothing is armed or subscribed. The automation + * engine refuses the same flow earlier, at registration, so an + * author learns before deploying; this refusal is what holds + * for a host that binds without it. A flow stored through the + * metadata save door keeps its secret in the write-only flow + * credential store instead, and the engine's binding reads it + * per post through `resolveSecret`; a post whose secret cannot + * be read is answered `503`, never verified against nothing. */ export class ApiTrigger implements FlowTrigger { readonly type = 'api'; @@ -122,20 +148,22 @@ export class ApiTrigger implements FlowTrigger { /** Currently armed hooks (for diagnostics/tests). */ listHooks(): Array<{ flowName: string; hookId: string; signed: boolean }> { + // Every armed hook is signed: the type admits no unsigned one. return [...this.hooks.values()].map(h => ({ - flowName: h.flowName, hookId: h.hookId, signed: !!h.secret, + flowName: h.flowName, hookId: h.hookId, signed: true, })); } start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise): void { const cfg = (binding.config ?? {}) as Record; const hookId = typeof cfg.hookId === 'string' && cfg.hookId.trim() ? cfg.hookId.trim() : 'default'; - const secret = typeof cfg.secret === 'string' && cfg.secret.trim() ? cfg.secret.trim() : undefined; + const literal = usableSecret(cfg.secret); + const resolve = typeof binding.resolveSecret === 'function' ? binding.resolveSecret : undefined; // ADR-0041 (`trigger-api` acceptance criteria): a per-flow secret and // HMAC verification. Refused BEFORE anything is stored or subscribed, // so a refused flow leaves no hook behind; the engine's bind catch // reports the throw and its binding audit lists the flow as unbound. - if (!secret) { + if (!literal && !resolve) { throw new Error( `[trigger-api] flow '${binding.flowName}' not armed: its start node declares no \`config.secret\`. ` + `An inbound hook is armed only with a per-flow secret that every post is HMAC-verified ` + @@ -144,6 +172,7 @@ export class ApiTrigger implements FlowTrigger { } const queue = `${QUEUE_PREFIX}:${binding.flowName}`; + const secret: HookSecret = resolve ? { kind: 'resolved', resolve } : { kind: 'literal', secret: literal! }; const hook: ArmedHook = { flowName: binding.flowName, hookId, secret, queue, callback }; this.hooks.set(binding.flowName, hook); @@ -199,7 +228,23 @@ export class ApiTrigger implements FlowTrigger { if (!hook || !safeEqual(hook.hookId, input.hookId)) { return { status: 404, body: { success: false, error: { code: 'RESOURCE_NOT_FOUND', message: 'No such hook.' } } }; } - if (!verifySignature(hook.secret, input.rawBody, input.signatureHeader)) { + // The secret, read now: a rotation applies to the next post. One that + // cannot be read is the deployment's fault, not the sender's, and is + // never read as "no secret" — the post is refused without verifying. + let secret: string | undefined; + try { + secret = hook.secret.kind === 'literal' ? hook.secret.secret : usableSecret(await hook.secret.resolve()); + } catch (err: any) { + this.logger.warn(`[trigger-api] the secret of flow '${hook.flowName}' could not be read: ${err?.message ?? err}`); + secret = undefined; + } + if (!secret) { + return { + status: 503, + body: { success: false, error: { code: 'SERVICE_UNAVAILABLE', message: 'The hook secret is unavailable.' } }, + }; + } + if (!verifySignature(secret, input.rawBody, input.signatureHeader)) { return { status: 401, body: { success: false, error: { code: 'INVALID_SIGNATURE', message: 'Signature verification failed.' } } }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87bfa669105..b69758e4e0b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2100,6 +2100,9 @@ importers: '@objectstack/driver-turso': specifier: workspace:* version: link:../../drivers/driver-turso + '@objectstack/trigger-api': + specifier: workspace:* + version: link:../../triggers/trigger-api '@types/node': specifier: ^26.6.3 version: 26.6.3 diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 027bd5bd36e..5f90bd3b1f8 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -456,6 +456,21 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.credential-channel.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.credential-channel.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.credential-channel.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.dashboard-dataset-publish-gate.test.ts", "verb": "delete", @@ -3791,6 +3806,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/services/service-automation/src/flow-credential-migration.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/services/service-automation/src/flow-credential-migration.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/services/service-automation/src/flow-dispatch.test.ts", "verb": "update", diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index 679b642eaf2..809ebaa32dd 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -24,8 +24,8 @@ "predicate": "resolveTenantFieldName(registered schema) !== null, where the registered schema is the authored schema plus the columns resolveInjectedSystemColumns says the registration injects (the applySystemFields pass). Both functions are loaded from source and executed; neither is re-spelled here.", "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { - "registered": 82, - "inReach": 56, + "registered": 83, + "inReach": 57, "outOfReach": 26 }, "reasonTotals": { @@ -182,6 +182,13 @@ "tenantField": "organization_id", "reasons": [] }, + { + "name": "sys_flow_credential", + "file": "packages/services/service-automation/src/sys-flow-credential.object.ts", + "reach": "in", + "tenantField": "organization_id", + "reasons": [] + }, { "name": "sys_flow_dispatch", "file": "packages/services/service-automation/src/sys-flow-dispatch.object.ts",