diff --git a/.changeset/23225-d2b-anonymous-elevated-flow-refused.md b/.changeset/23225-d2b-anonymous-elevated-flow-refused.md new file mode 100644 index 00000000000..7431e9b8a17 --- /dev/null +++ b/.changeset/23225-d2b-anonymous-elevated-flow-refused.md @@ -0,0 +1,43 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata': minor +'@objectstack/metadata-protocol': minor +--- + +feat(spec)!: publish refuses an anonymous flow endpoint whose target flow runs as `system`, in both directions, with the prescription (ADR-0138 D2b) + +Clause-②: yes (narrowing) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + +ADR-0138 D2b decides that no anonymous door triggers an elevated flow directly. An `authRequired: false` endpoint of `type: 'flow'` whose `target` flow declares `runAs: 'system'` ran every anonymous request's data steps as the system principal, past row-level security, with the endpoint's rate limit as the only brake. The runtime's own elevated-start refusal covers only self-triggered flow types (`autolaunched`, `record_change`, `schedule`), so a `system` flow of type `api` or `screen` behind such an endpoint executed elevated. Publish now refuses the pair, in both directions, because either item can be the last one edited: the endpoint's publish reads its target's `runAs`, and a flow's publish that sets `runAs: 'system'` reads the anonymous endpoints that target it. Each refusal names the endpoint and the flow and carries the prescription. + +**What is refused, and where.** Every publish door that holds the pair calls one judge, `anonymousElevatedFlowPairs` in `@objectstack/spec/api`: + +- **The stack parse** (`defineStack`, `objectstack validate`, `objectstack build`, the artifact load): a stack whose anonymous flow endpoint names one of its own flows declaring `runAs: 'system'`. One zod issue per pair, `code: 'custom'` at `apis[i].authRequired`; `defineStack` throws its schema refusal, `STACK_SCHEMA_INVALID` (422), carrying the issue. +- **The package publish** (`MetadataManager.publishPackage`, `POST /api/v1/packages/:id/publish`): an `api` item of the package naming a `system` flow anywhere in the manager's index, reported at the endpoint; and a `flow` item of the package declaring `runAs: 'system'` that an anonymous endpoint outside the package names, reported at the flow. The package publishes nothing (`success: false`, the pair in `validationErrors`), and `validate: false` does not turn it off. +- **A flow published through the metadata API** (`saveMetaItem` in publish mode, so `PUT /api/v1/meta/flow/:name`; a draft's publish, `POST /api/v1/meta/flow/:name/publish`; and `POST /api/v1/packages/:id/publish-drafts`): a flow going `active` with `runAs: 'system'` while a declared anonymous flow endpoint names it. `INVALID_METADATA` (422), `issues[0].path: 'runAs'`, nothing written; a publish-drafts batch aborts on it. A draft save is not judged (a draft does not execute; its publish is), and neither is a `migrate-stored` re-save of a row that already exists. + +**Unchanged.** Each item alone publishes as before: an authenticated endpoint on a `system` flow, an anonymous endpoint on a `user` flow (or one that leaves `runAs` at its default), and an anonymous `object_operation` endpoint are all accepted. A stored flow row that already pairs this way keeps its bytes and keeps executing as published; its next publish is refused. A guest-written row that fires a record-change flow is outside the refusal: the record leaves that indirect path undecided, because its trigger is the write, not the door. + +## FROM → TO + +| door | FROM | TO | +|:--|:--|:--| +| `defineStack` / `objectstack validate` | `apis: [{ name: 'acme_intake_api', type: 'flow', target: 'acme_intake', authRequired: false, rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, … }]` beside `flows: [{ name: 'acme_intake', runAs: 'system', … }]` accepted; every anonymous request ran `acme_intake` as the system principal | refused, `STACK_SCHEMA_INVALID` 422, the issue at `apis[0].authRequired` naming both items and the prescription | +| package publish | a package carrying either half of the pair, with the other half anywhere in the manager's index, published | refused, `success: false`, the pair reported at the package's own item | +| metadata API flow publish | `PUT /api/v1/meta/flow/acme_intake` with `runAs: 'system'` while an anonymous endpoint names it saved and armed it | refused, `INVALID_METADATA` 422, the issue at `runAs`, nothing written | + +**The one-line fix: serve the caller another way — an authenticated endpoint (`authRequired: true`) for a partner that can hold a credential, the flow's signed inbound hook (ADR-0041: a start node with `triggerType: 'api'`, a `hookId` and a `secret`, which may keep `runAs: 'system'`) for a partner webhook, or a public form (a form view whose `sharing` sets `allowAnonymous: true`) for anonymous intake.** `runAs: 'automation'` joins that list when ADR-0073 M2 adds the value. + +No D2 conversion rewrites it: which channel replaces the anonymous endpoint depends on who the caller is, and whether the flow needs elevation is a judgment about its data steps, so no conversion can choose a posture for the author. Its D3 record is the semantic entry `api-endpoint-anonymous-elevated-flow-refused` (protocol 18). + +**`@objectstack/spec`.** The judge and its two refusal sentences join `@objectstack/spec/api`: `anonymousElevatedFlowPairs`, `anonymousFlowDoorTarget`, `anonymousEndpointElevatedFlowMessage` (the endpoint direction), `elevatedFlowAnonymousEndpointsMessage` (the flow direction) and `validateAnonymousElevatedFlows` (the stack's issues), with the types `AnonymousFlowDoorCandidate`, `ElevatedFlowCandidate` and `AnonymousElevatedFlowPair`. `ObjectStackDefinitionSchema` runs the judge beside the per-endpoint gates. The `runAs` description on the flow schema, which prescribed `system` for every user-less trigger, `api` included, now names the exception and what serves that caller. + +**`@objectstack/metadata`.** `MetadataManager.publishPackage` runs the judge over the package's `api` and `flow` items against the index the endpoint matcher serves from. + +**`@objectstack/metadata-protocol`.** The flow publish doors run the judge against the declared endpoints: the engine registry's `api` items and the `metadata` service's. + +**Who is affected, measured.** No in-repository stack pairs an anonymous flow endpoint with a `system` flow: `examples/` declares no `authRequired: false` endpoint, and every anonymous flow endpoint in the test fixtures names a `runAs: 'user'` flow. The partner-webhook example, `showcase_inbound_task_webhook`, takes the signed inbound hook with `runAs: 'system'` and is untouched. diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 75a2db85a60..2144840b05b 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -120,7 +120,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -244,7 +244,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -375,7 +375,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -683,7 +683,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -748,7 +748,7 @@ const result = AutomationApiErrorCode.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/content/docs/references/automation/flow.mdx b/content/docs/references/automation/flow.mdx index 2bd0350b40f..f21a90bb837 100644 --- a/content/docs/references/automation/flow.mdx +++ b/content/docs/references/automation/flow.mdx @@ -41,7 +41,7 @@ const result = FlowSchema.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -262,7 +262,7 @@ const result = FlowSchema.parse(data); | **nodes** | `{ id: string; type: string; label: string; config?: Record; … }[]` | ✅ | Flow nodes | | **edges** | `{ id: string; source: string; target: string; condition?: string \| object; … }[]` | ✅ | Flow connections | | **active** | `never` | optional | [REMOVED] `flow.active` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never had an effect: the engine arms flows from `status`, and `active: false` did NOT stop a flow (worse, the default read as disabled while the engine treated unset as enabled). Delete the key. Use `status: 'obsolete'` (or 'invalid') to unbind and disable a flow, `status: 'active'` to arm it. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | -| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. | +| **runAs** | `Enum<'system' \| 'user'>` | optional (default: `"user"`) | Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller through an authenticated endpoint, the flow's signed inbound hook, or a public form. | | **errorHandling** | `{ strategy?: Enum<'fail' \| 'retry' \| 'continue'>; maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number; … }` | optional | Flow-level error handling configuration. A durable pause ends the retry-governed segment: strategy: 'retry' describes one synchronous dispatch, so a run that parks on an approval/screen/wait node and later resumes gets one attempt for anything that fails after the pause. Protect the post-pause half with its own failure handling in the flow — a try_catch node's retry around the post-resume work, or fault edges to a handler node. | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this flow. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | diff --git a/packages/metadata-protocol/src/protocol.item-name-every-door.test.ts b/packages/metadata-protocol/src/protocol.item-name-every-door.test.ts index eb696f213b2..e433bc757d3 100644 --- a/packages/metadata-protocol/src/protocol.item-name-every-door.test.ts +++ b/packages/metadata-protocol/src/protocol.item-name-every-door.test.ts @@ -12,6 +12,13 @@ * (ADR-0086 D3, `permissionSetOwnerBindingRefusal`). It shares this file's * double and its staging of a stored body. * + * Section 5 pins ADR-0138 D2b's flow half at the same publish doors, on the + * same double: a flow published with `runAs: 'system'` while an anonymous + * endpoint of `type: 'flow'` names it (`anonymousElevatedFlowRefusal`). The + * double's registry lists `api` items, which is one of the two places the + * protocol reads the declared endpoints from; the other, the `metadata` + * service, is handed in per case. + * * --------------------------------------------------------------------------- * The defect, measured on `origin/main` `e9dec3dab` before this change * --------------------------------------------------------------------------- @@ -810,3 +817,194 @@ describe('[ADR-0086 D3] duplicatePackage copies a set that declares its source p ]); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. A flow published as `system` while an anonymous endpoint names it (ADR-0138 D2b) +// ═══════════════════════════════════════════════════════════════════════════ +// +// The record refuses the anonymous flow endpoint × `runAs: 'system'` flow pair +// "in both directions, because either item can be the last one edited". An +// endpoint is code-only, so this door holds the FLOW direction: the flow is the +// item being published, and the endpoints that name it are read from the engine +// registry and the `metadata` service. Every door a flow body reaches `active` +// through is pinned — the publish-mode save, the per-item publish of a draft, +// the package's publish-drafts — each with its control, and with the envelope +// (`INVALID_METADATA` / 422, the issue located at `runAs`) and the prescription. + +const D2B_FLOW = 'acme_partner_intake'; +const D2B_ENDPOINT = 'acme_partner_intake_api'; + +/** + * A start → end flow. `autolaunched`, so the shared authoring rules have + * nothing to say about its trigger (a `type: 'api'` flow owes its start node a + * signed secret) and every control below isolates the D2b verdict; the pair + * is judged on `runAs` and the endpoint's `target`, never on the flow's type. + */ +const d2bFlow = (runAs: 'system' | 'user') => ({ + name: D2B_FLOW, + label: 'Partner intake', + type: 'autolaunched', + runAs, + nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}); + +/** A code-declared flow endpoint naming {@link D2B_FLOW}, as `registerApp` files it. */ +const d2bEndpoint = (authRequired: boolean) => ({ + name: D2B_ENDPOINT, + path: '/api/v1/apps/acme/partner/intake', + method: 'POST', + type: 'flow', + target: D2B_FLOW, + authRequired, + ...(authRequired ? {} : { rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 100 } }), +}); + +/** A protocol over this file's double, with an optional `metadata` service answering `list('api')`. */ +function makeD2bProtocol(metadataList?: (type: string) => Promise, installedPackage?: string) { + const h = makeStubEngine(); + // A flow saved into a package needs that package installed (the named-base + // rule); the double's registry installs the one a case names. + if (installedPackage) { + h.engine.registry.getPackage = (id: string) => + (id === installedPackage ? { manifest: { id, scope: 'project' } } : undefined); + } + const services = new Map(); + if (metadataList) services.set('metadata', { list: metadataList }); + const protocol = new ObjectStackProtocolImplementation(h.engine, () => services as Map, undefined) as any; + ObjectStackProtocolImplementation.resetEnvWritableCache(); + return { protocol, ...h }; +} + +/** The refusal this section pins: the envelope, the key located, both items and the prescription named. */ +function expectElevatedRefusal(error: any) { + expect(error).toBeInstanceOf(Error); + expect(error.code).toBe('INVALID_METADATA'); + expect(error.status).toBe(422); + expect(error.issues).toEqual([expect.objectContaining({ path: 'runAs', code: 'custom' })]); + expect(error.message).toContain(`Flow '${D2B_FLOW}'`); + expect(error.message).toContain(`'${D2B_ENDPOINT}'`); + expect(error.message).toContain('authenticated endpoint'); + expect(error.message).toContain('signed inbound hook'); + expect(error.message).toContain('public form'); +} + +const storedFlows = (h: { rows: Map }) => Array.from(h.rows.values()) + .filter((r) => r.type === 'flow') + .map((r) => ({ row: r.name, state: r.state, runAs: JSON.parse(r.metadata).runAs })); + +describe('[ADR-0138 D2b] the save door refuses a flow published as `system` while an anonymous endpoint names it', () => { + it('publish mode: refused with the envelope; nothing stored', async () => { + const h = makeD2bProtocol(); + h.engine.registry.registerItem('api', d2bEndpoint(false)); + + const { error } = await attempt(() => h.protocol.saveMetaItem({ type: 'flow', name: D2B_FLOW, item: d2bFlow('system') })); + + expectElevatedRefusal(error); + expect(storedFlows(h)).toEqual([]); + }); + + it('CONTROL: the same flow under `user` beside the anonymous endpoint saves', async () => { + const h = makeD2bProtocol(); + h.engine.registry.registerItem('api', d2bEndpoint(false)); + + const res = await h.protocol.saveMetaItem({ type: 'flow', name: D2B_FLOW, item: d2bFlow('user') }); + + expect(res.success).toBe(true); + expect(storedFlows(h)).toEqual([{ row: D2B_FLOW, state: 'active', runAs: 'user' }]); + }); + + it('CONTROL: the flow under `system` behind an AUTHENTICATED endpoint saves', async () => { + const h = makeD2bProtocol(); + h.engine.registry.registerItem('api', d2bEndpoint(true)); + + const res = await h.protocol.saveMetaItem({ type: 'flow', name: D2B_FLOW, item: d2bFlow('system') }); + + expect(res.success).toBe(true); + expect(storedFlows(h)).toEqual([{ row: D2B_FLOW, state: 'active', runAs: 'system' }]); + }); + + it('the endpoint is read from the `metadata` service too — the index the endpoint matcher serves from', async () => { + const h = makeD2bProtocol(async (type) => (type === 'api' ? [d2bEndpoint(false)] : [])); + + const { error } = await attempt(() => h.protocol.saveMetaItem({ type: 'flow', name: D2B_FLOW, item: d2bFlow('system') })); + + expectElevatedRefusal(error); + expect(storedFlows(h)).toEqual([]); + }); + + it('a `metadata` service whose read fails fails the publish: "nothing names it" is never answered from a read that did not happen', async () => { + const outage = Object.assign(new Error('metadata store unreachable'), { code: 'METADATA_STORE_UNAVAILABLE' }); + const h = makeD2bProtocol(async () => { throw outage; }); + + const { error } = await attempt(() => h.protocol.saveMetaItem({ type: 'flow', name: D2B_FLOW, item: d2bFlow('system') })); + + expect(error).toBe(outage); + expect(storedFlows(h)).toEqual([]); + }); + + it('draft mode is let through (a draft does not execute); a `migrate-stored` re-save is not judged', async () => { + const h = makeD2bProtocol(); + h.engine.registry.registerItem('api', d2bEndpoint(false)); + + const draft = await h.protocol.saveMetaItem({ type: 'flow', name: D2B_FLOW, item: d2bFlow('system'), mode: 'draft' }); + expect(draft.success).toBe(true); + + const migrated = await h.protocol.saveMetaItem({ + type: 'flow', name: D2B_FLOW, item: d2bFlow('system'), source: 'migrate-stored', + }); + expect(migrated.success).toBe(true); + expect(storedFlows(h)).toEqual(expect.arrayContaining([{ row: D2B_FLOW, state: 'active', runAs: 'system' }])); + }); +}); + +describe('[ADR-0138 D2b] the draft promotion refuses a `system` flow draft an anonymous endpoint names', () => { + it('publishMetaItem: refused with the envelope; the draft is kept, nothing goes live', async () => { + const h = makeD2bProtocol(); + h.engine.registry.registerItem('api', d2bEndpoint(false)); + await h.protocol.saveMetaItem({ type: 'flow', name: D2B_FLOW, item: d2bFlow('system'), mode: 'draft' }); + + const { error } = await attempt(() => h.protocol.publishMetaItem({ type: 'flow', name: D2B_FLOW })); + + expectElevatedRefusal(error); + expect(storedFlows(h)).toEqual([{ row: D2B_FLOW, state: 'draft', runAs: 'system' }]); + }); + + it('publishMetaItem CONTROL: a `user` draft beside the anonymous endpoint publishes', async () => { + const h = makeD2bProtocol(); + h.engine.registry.registerItem('api', d2bEndpoint(false)); + await h.protocol.saveMetaItem({ type: 'flow', name: D2B_FLOW, item: d2bFlow('user'), mode: 'draft' }); + + const res = await h.protocol.publishMetaItem({ type: 'flow', name: D2B_FLOW }); + + expect(res.success).toBe(true); + expect(storedFlows(h)).toEqual([{ row: D2B_FLOW, state: 'active', runAs: 'user' }]); + }); + + it('publishPackageDrafts: the refused flow draft aborts the batch with the envelope\'s code; nothing goes live', async () => { + const PKG = 'app.intake'; + const h = makeD2bProtocol(undefined, PKG); + h.engine.registry.registerItem('api', d2bEndpoint(false)); + await h.protocol.saveMetaItem({ type: 'flow', name: D2B_FLOW, item: d2bFlow('system'), mode: 'draft', packageId: PKG }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const res = await h.protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res.success).toBe(false); + expect(res.publishedCount).toBe(0); + expect(res.failed).toEqual([expect.objectContaining({ type: 'flow', name: D2B_FLOW, code: 'INVALID_METADATA' })]); + expect(storedFlows(h)).toEqual([{ row: D2B_FLOW, state: 'draft', runAs: 'system' }]); + }); + + it('publishPackageDrafts CONTROL: the same draft behind an authenticated endpoint publishes', async () => { + const PKG = 'app.intake'; + const h = makeD2bProtocol(undefined, PKG); + h.engine.registry.registerItem('api', d2bEndpoint(true)); + await h.protocol.saveMetaItem({ type: 'flow', name: D2B_FLOW, item: d2bFlow('system'), mode: 'draft', packageId: PKG }); + + const res = await h.protocol.publishPackageDrafts({ packageId: PKG }); + + expect(res.outcome).toBe('published'); + expect(res.publishedCount).toBe(1); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 16c97de7c53..a882b160d11 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -3,6 +3,14 @@ import type { DataProtocol, FlowRegistration, MetadataProtocol, PackageProtocol, } from '@objectstack/spec/api'; +// [ADR-0138 D2b] The one cross-item judge of the anonymous flow endpoint × +// `runAs: 'system'` flow pair, and the flow-direction refusal it words — see +// `anonymousElevatedFlowRefusal`. +import { + anonymousElevatedFlowPairs, + elevatedFlowAnonymousEndpointsMessage, + type AnonymousFlowDoorCandidate, +} from '@objectstack/spec/api'; import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError, relationConditionSites } from '@objectstack/core'; import { declaredUserMessage, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types'; // [#6285] ADR-0105 D1's authority on "does this deployment wall organizations?". @@ -20982,6 +20990,93 @@ export class ObjectStackProtocolImplementation implements return err; } + /** + * [ADR-0138 D2b] The flow half of the anonymous door × elevated flow + * refusal: a flow published with `runAs: 'system'` while an + * `authRequired: false` endpoint of `type: 'flow'` names it. + * + * The record refuses the pair "in both directions, because either item can + * be the last one edited". An endpoint is code-only (`api` is + * `allowRuntimeCreate: false`; the code-only inlet refuses its write), so its + * own publish is the stack parse and the metadata layer's package publish, + * where the judge runs over both items. A flow is authorable here, so this + * door is where the OTHER direction is read: the flow is the item being + * published, and the endpoints that name it are read from what declares + * them on this host. Both judges are the one in + * `@objectstack/spec/api` (`anonymousElevatedFlowPairs`), so the stack and + * this door cannot grow two opinions of the pair. + * + * Asked at every door a flow body reaches `active` through: the save in + * publish mode ({@link saveMetaItem}) and the draft promotion + * ({@link promoteDraftForPublish}) the per-item publish and + * `publishPackageDrafts` share. A draft save is let through: a draft does + * not execute, and its publish is judged here. `migrate-stored` re-saves + * (the credential move, `os migrate meta --stored`) are not judged: they + * rewrite a row that already exists, and refusing one would leave a + * credential the move exists to take out of the row in cleartext. Such a + * row keeps executing as published until its next publish, which this + * refuses (the semantic entry `api-endpoint-anonymous-elevated-flow-refused` + * says so to an upgrading author). + * + * Where the endpoints are read, both, because each is a home a code + * stack's `apis` has on a real boot: the engine registry's `api` items + * (`registerApp` files every stack's `apis` there) and the `metadata` + * service's `list('api')`, the manager whose index the endpoint matcher + * serves from. Their items are read as bodies, as {@link getMetaItems} + * reads the same two sources. ⛔ `sys_metadata` is not read: an `api` row + * there is never served (the matcher does not read the store) and none can + * be written any more. A read that fails propagates: answering "no + * endpoint names it" from a read that never happened would publish the + * very pair this refuses. + * + * `INVALID_METADATA` / 422 with `issues[]` at `runAs` — the envelope this + * door's other publish-time contract refusals carry (the schema gate and + * the runtime authoring gate), so Studio highlights the key the refusal is + * about. ⛔ No new code. The message prescribes before it explains (a 4xx + * message crosses the REST boundary with its tail truncated) and carries no + * tracker number. + */ + private async anonymousElevatedFlowRefusal( + singularType: string, + item: unknown, + name: string, + ): Promise<(Error & { code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[] }) | undefined> { + if (singularType !== 'flow') return undefined; + if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined; + if ((item as { runAs?: unknown }).runAs !== 'system') return undefined; + + const endpoints: unknown[] = []; + const registry = this.engine.registry; + if (typeof registry?.listItems === 'function') { + endpoints.push(...registry.listItems('api')); + } + const metadataService = this.getServicesRegistry?.()?.get('metadata'); + if (metadataService && typeof metadataService.list === 'function') { + const listed = await metadataService.list('api'); + if (Array.isArray(listed)) endpoints.push(...listed); + } + + const pairs = anonymousElevatedFlowPairs( + endpoints as AnonymousFlowDoorCandidate[], + [{ name, runAs: 'system' }], + ); + if (pairs.length === 0) return undefined; + + const names = [...new Set(pairs.map((pair) => pair.endpoint))]; + const err = new Error(elevatedFlowAnonymousEndpointsMessage(name, names)) as Error & { + code: 'INVALID_METADATA'; status: 422; issues: MetadataIssueEntry[]; + }; + err.code = 'INVALID_METADATA'; + err.status = 422; + err.issues = [{ + path: 'runAs', + message: `An anonymous endpoint (\`authRequired: false\`) triggers this flow: ${names.map((n) => `'${n}'`).join(', ')}. ` + + "No anonymous door may start a `runAs: 'system'` flow (ADR-0138 D2b).", + code: 'custom', + }]; + return err; + } + /** * [#21639] The save door's ONE collision predicate for a view container: * a container is refused when its save name, or any name its expansion @@ -22233,6 +22328,17 @@ export class ObjectStackProtocolImplementation implements if (defaultPageRefusal) throw defaultPageRefusal; } + // [ADR-0138 D2b] A flow published with `runAs: 'system'` while an + // anonymous endpoint of `type: 'flow'` names it is refused, in publish + // mode only (a draft does not execute; its publish is judged at the + // promotion), before the authoring gate and every write. A + // `migrate-stored` re-save rewrites an existing row and is not judged. + // See {@link anonymousElevatedFlowRefusal}. + if (mode !== 'draft' && writeSource !== 'migrate-stored') { + const elevatedRefusal = await this.anonymousElevatedFlowRefusal(singularType, request.item, request.name); + if (elevatedRefusal) throw elevatedRefusal; + } + // The #4463 runtime authoring gate — the shared author-time rule // registry, on the write path. `active` saves only (D1): this is the // publish verb, and it is the same table `os build` gates on. Placed @@ -23854,6 +23960,17 @@ export class ObjectStackProtocolImplementation implements const defaultPageRefusal = await this.defaultRecordPageSaveRefusal(singularType, draftForGate.body, request.name, 'published'); if (defaultPageRefusal) throw defaultPageRefusal; } + // [ADR-0138 D2b] …and a flow draft that would go `active` with + // `runAs: 'system'` while an anonymous endpoint of `type: 'flow'` names + // it: the draft save let it through (a draft does not execute), so its + // publish is where the pair is judged — on the draft body, with the + // save door's predicate and envelope, before `repo.promoteDraft` writes, + // so a batch publish aborts on it too. See + // {@link anonymousElevatedFlowRefusal}. + if (draftForGate) { + const elevatedRefusal = await this.anonymousElevatedFlowRefusal(singularType, draftForGate.body, request.name); + if (elevatedRefusal) throw elevatedRefusal; + } // [#9176] The gate's return is its advisory half (#4717): captured and // handed out so `publishMetaItem` can attach it to the 2xx this // promotion is about to earn, exactly as `saveMetaItem` attaches its diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index cce8343b6b0..95a1f8c3446 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -55,6 +55,15 @@ import { validateApiEndpointDeclarations, type ApiEndpoint, } from '@objectstack/spec/api'; +// [ADR-0138 D2b] The cross-item judge beside them, and the refusal it words for +// each direction — see `gateAnonymousElevatedFlowsForPublish`. +import { + anonymousElevatedFlowPairs, + anonymousEndpointElevatedFlowMessage, + elevatedFlowAnonymousEndpointsMessage, + type AnonymousFlowDoorCandidate, + type ElevatedFlowCandidate, +} from '@objectstack/spec/api'; import { assertMetadataRegisterContract, canonicalMetadataServiceType, @@ -215,6 +224,22 @@ const PUBLISH_NAMESPACE_REMEDY = * environments without WebCrypto working while still satisfying * `MetadataEventSchema`'s `z.string().uuid()`. */ +/** + * [ADR-0138 D2b] The names a package item answers to in the anonymous-flow + * pair gate: its registry key, and the `name` its peeled body declares (the + * name the judge reports a pair under). The two agree for every item the + * register contract admits; both are kept so a disagreement cannot hide the + * item from its own package. + */ +function packageNamesOf(item: { name: string; data: unknown }): string[] { + const names = [item.name]; + const body = peelStoredEnvelope(item.data).body as { name?: unknown } | null | undefined; + if (body && typeof body === 'object' && typeof body.name === 'string' && body.name !== '' && body.name !== item.name) { + names.push(body.name); + } + return names; +} + function generateEventUuid(): string { const c = globalThis.crypto; if (c && typeof c.randomUUID === 'function') { @@ -533,6 +558,8 @@ export class MetadataManager implements IMetadataService { // which registers with `notify:false`, and — since #5109 — cluster peer // replay) and the `subscribe('api', …)` registration below. private static readonly ENDPOINT_METADATA_TYPE = 'api'; + /** [ADR-0138 D2b] The type an endpoint of `type: 'flow'` names in its `target`. */ + private static readonly FLOW_METADATA_TYPE = 'flow'; private readonly endpointMatcher: EndpointMatcher; constructor(config: MetadataManagerOptions) { @@ -1899,6 +1926,11 @@ export class MetadataManager implements IMetadataService { // slips through. A flag that turns them off would be exactly the bypass // #5189 filed. validationErrors.push(...this.gateApiItemsForPublish(packageItems, options?.namespace)); + // [ADR-0138 D2b] …and the one cross-item gate, ALWAYS for the same reason: + // an anonymous endpoint of `type: 'flow'` that names a `runAs: 'system'` + // flow is refused, in both directions, whichever of the two this package + // carries. See `gateAnonymousElevatedFlowsForPublish`. + validationErrors.push(...await this.gateAnonymousElevatedFlowsForPublish(packageItems)); // Validation pass if (shouldValidate) { @@ -2090,6 +2122,83 @@ export class MetadataManager implements IMetadataService { return errors; } + /** + * [ADR-0138 D2b] The anonymous door × elevated flow refusal, on the package + * publish — the metadata layer's door for both of its items. + * + * The record refuses an `authRequired: false` endpoint of `type: 'flow'` + * whose target flow declares `runAs: 'system'`, "in both directions, because + * either item can be the last one edited". This door publishes both kinds of + * item, so it holds both directions: + * + * - an `api` item of the package that names such a flow — wherever the flow + * is, in this package or anywhere else in the index — is refused with the + * endpoint's message; + * - a `flow` item of the package declaring `runAs: 'system'` that an + * anonymous endpoint OUTSIDE the package names is refused with the flow's + * message (a pair inside the package is reported once, at the endpoint). + * + * The other item is read from the index the endpoint matcher serves from + * ({@link listForIndex}: the registry and every loader), so the pair is + * judged against what is declared, not only against this package. Bodies + * are peeled ({@link peelStoredEnvelope}) as the endpoint gate above peels + * them, and read structurally by the one judge in `@objectstack/spec/api`: + * the stack parse and the metadata protocol's flow publish call the same + * function, so the three doors cannot disagree about the pair. A read that + * fails propagates (`listForIndex` rethrows), so the publish fails instead + * of answering "nothing names it" from a read that never happened. + * + * Not governed by `options.validate`, like the endpoint gates: a security + * refusal with an opt-out flag is no refusal. + */ + private async gateAnonymousElevatedFlowsForPublish( + packageItems: Array<{ type: string; name: string; data: any }>, + ): Promise> { + /** The package's own `api` and `flow` names: the registry key, and the body's `name`. */ + const packageEndpointNames = new Set(); + const packageFlowNames = new Set(); + for (const item of packageItems) { + const names = packageNamesOf(item); + if (item.type === MetadataManager.ENDPOINT_METADATA_TYPE) names.forEach((n) => packageEndpointNames.add(n)); + else if (item.type === MetadataManager.FLOW_METADATA_TYPE) names.forEach((n) => packageFlowNames.add(n)); + } + if (packageEndpointNames.size === 0 && packageFlowNames.size === 0) return []; + + const bodies = (items: unknown[]) => items.map((item) => peelStoredEnvelope(item).body); + const endpoints = bodies(await this.listForIndex(MetadataManager.ENDPOINT_METADATA_TYPE)) as AnonymousFlowDoorCandidate[]; + const flows = bodies(await this.listForIndex(MetadataManager.FLOW_METADATA_TYPE)) as ElevatedFlowCandidate[]; + + const errors: Array<{ type: string; name: string; message: string }> = []; + /** flow name → the outside endpoints that name it, for the flow-direction message. */ + const outsideEndpointsByFlow = new Map>(); + const reportedEndpoints = new Set(); + for (const pair of anonymousElevatedFlowPairs(endpoints, flows)) { + if (packageEndpointNames.has(pair.endpoint)) { + const key = `${pair.endpoint} -> ${pair.flow}`; + if (reportedEndpoints.has(key)) continue; + reportedEndpoints.add(key); + errors.push({ + type: MetadataManager.ENDPOINT_METADATA_TYPE, + name: pair.endpoint, + message: anonymousEndpointElevatedFlowMessage(pair.endpoint, pair.flow), + }); + continue; + } + if (!packageFlowNames.has(pair.flow)) continue; + const named = outsideEndpointsByFlow.get(pair.flow) ?? new Set(); + named.add(pair.endpoint); + outsideEndpointsByFlow.set(pair.flow, named); + } + for (const [flow, named] of outsideEndpointsByFlow) { + errors.push({ + type: MetadataManager.FLOW_METADATA_TYPE, + name: flow, + message: elevatedFlowAnonymousEndpointsMessage(flow, [...named]), + }); + } + return errors; + } + /** * Revert entire package to last published state. * Restores all metadata definitions from their published snapshots. diff --git a/packages/metadata/src/publish-anonymous-elevated-flow-gate.test.ts b/packages/metadata/src/publish-anonymous-elevated-flow-gate.test.ts new file mode 100644 index 00000000000..d8ae8d1b8b5 --- /dev/null +++ b/packages/metadata/src/publish-anonymous-elevated-flow-gate.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0138 D2b on `publishPackage` — the metadata layer's door for both items. + * + * The record refuses an `authRequired: false` endpoint of `type: 'flow'` whose + * target flow declares `runAs: 'system'`, "in both directions, because either + * item can be the last one edited". This door publishes `api` and `flow` items + * alike, minted item by item (`metadata.register()`) and never parsed as a + * stack — the path #5189 pinned the per-endpoint gates on + * (`publish-endpoint-gate.test.ts`). So both directions are pinned here: + * + * - ENDPOINT: an `api` item of the package names a `system` flow (in the + * package, or anywhere in the manager's index) — refused at the endpoint; + * - FLOW: a `flow` item of the package declares `runAs: 'system'` while an + * anonymous endpoint OUTSIDE the package names it — refused at the flow. + * + * Each refusal is paired with the control the record leaves open (an + * authenticated endpoint on a `system` flow; an anonymous endpoint on a `user` + * flow), asserts that NOTHING was published, and reads the prescription the + * author is given. The judge itself is `@objectstack/spec/api`'s, pinned in + * `anonymous-elevated-flow-gate.test.ts` there. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MetadataManager } from './metadata-manager.js'; +import { MemoryLoader } from './loaders/memory-loader.js'; + +vi.mock('@objectstack/core', async (orig) => ({ + // Spread the REAL module: MetadataManager imports the shared register + // contract guard from @objectstack/core, and a mock naming only + // createLogger breaks on every export the class gains. + ...((await orig()) as object), + createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), +})); + +const PKG = 'com.acme.intake'; +const OTHER = 'com.acme.partner'; +const NS = 'acme'; +const FLOW = 'acme_partner_intake'; +const ENDPOINT = 'acme_partner_intake_api'; +const ARMED = { enabled: true, windowMs: 60_000, maxRequests: 100 }; + +/** A `type: 'api'` flow — the type the runtime's elevated-start refusal does not cover. */ +function flowItem(runAs: 'system' | 'user', packageId = PKG): Record { + return { + name: FLOW, + label: 'Partner intake', + type: 'api', + runAs, + nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + packageId, + state: 'draft', + }; +} + +/** The AUTHORED endpoint body: a flow endpoint naming {@link FLOW}. */ +function endpointBody(authRequired: boolean): Record { + return { + name: ENDPOINT, + path: `/api/v1/apps/${NS}/partner/intake`, + method: 'POST', + type: 'flow', + target: FLOW, + authRequired, + ...(authRequired ? {} : { rateLimit: ARMED }), + }; +} + +/** The stored flat shape: body plus the layer's bookkeeping at one level. */ +function apiItem(authRequired: boolean, packageId = PKG): Record { + return { ...endpointBody(authRequired), packageId, state: 'draft' }; +} + +describe('ADR-0138 D2b — publishPackage refuses the anonymous endpoint × system flow pair', () => { + let manager: MetadataManager; + + beforeEach(() => { + manager = new MetadataManager({ formats: ['json'], loaders: [new MemoryLoader()] }); + }); + + it('control: an AUTHENTICATED endpoint on a `system` flow, both in the package, publishes', async () => { + await manager.register('api', ENDPOINT, apiItem(true)); + await manager.register('flow', FLOW, flowItem('system')); + + const result = await manager.publishPackage(PKG, { namespace: NS }); + + expect(result.validationErrors ?? []).toEqual([]); + expect(result.success).toBe(true); + expect(result.itemsPublished).toBe(2); + }); + + it('control: an ANONYMOUS endpoint on a `user` flow publishes', async () => { + await manager.register('api', ENDPOINT, apiItem(false)); + await manager.register('flow', FLOW, flowItem('user')); + + const result = await manager.publishPackage(PKG, { namespace: NS }); + + expect(result.validationErrors ?? []).toEqual([]); + expect(result.success).toBe(true); + }); + + it('ENDPOINT direction, the pair inside the package: refused once, at the endpoint; nothing published', async () => { + await manager.register('api', ENDPOINT, apiItem(false)); + await manager.register('flow', FLOW, flowItem('system')); + + const result = await manager.publishPackage(PKG, { namespace: NS }); + + expect(result.success).toBe(false); + expect(result.itemsPublished).toBe(0); + const d2b = (result.validationErrors ?? []).filter((e) => e.message.includes('ADR-0138 D2b')); + expect(d2b.map(({ type, name }) => ({ type, name }))).toEqual([{ type: 'api', name: ENDPOINT }]); + expect(d2b[0]!.message).toContain(`flow '${FLOW}'`); + expect(d2b[0]!.message).toContain('authenticated endpoint'); + expect(d2b[0]!.message).toContain('signed inbound hook'); + expect(d2b[0]!.message).toContain('public form'); + expect((await manager.get('api', ENDPOINT) as any)?.state).toBe('draft'); + }); + + it('ENDPOINT direction, the flow in ANOTHER package: the endpoint\'s publish reads its target wherever it is', async () => { + await manager.register('api', ENDPOINT, apiItem(false)); + await manager.register('flow', FLOW, flowItem('system', OTHER)); + + const result = await manager.publishPackage(PKG, { namespace: NS }); + + expect(result.success).toBe(false); + expect((result.validationErrors ?? []).map(({ type, name }) => ({ type, name }))) + .toEqual([{ type: 'api', name: ENDPOINT }]); + }); + + it('FLOW direction: a `system` flow of the package that an anonymous endpoint elsewhere names is refused, at the flow', async () => { + await manager.register('api', ENDPOINT, apiItem(false, OTHER)); + await manager.register('flow', FLOW, flowItem('system')); + + const result = await manager.publishPackage(PKG); + + expect(result.success).toBe(false); + expect(result.itemsPublished).toBe(0); + const errors = result.validationErrors ?? []; + expect(errors.map(({ type, name }) => ({ type, name }))).toEqual([{ type: 'flow', name: FLOW }]); + expect(errors[0]!.message).toContain(`Flow '${FLOW}'`); + expect(errors[0]!.message).toContain(`'${ENDPOINT}'`); + expect(errors[0]!.message).toContain('signed inbound hook'); + expect((await manager.get('flow', FLOW) as any)?.state).toBe('draft'); + }); + + it('FLOW direction control: the same flow under `user` publishes beside the anonymous endpoint', async () => { + await manager.register('api', ENDPOINT, apiItem(false, OTHER)); + await manager.register('flow', FLOW, flowItem('user')); + + const result = await manager.publishPackage(PKG); + + expect(result.validationErrors ?? []).toEqual([]); + expect(result.success).toBe(true); + }); + + it('`validate: false` does not turn it off — a security refusal has no opt-out flag', async () => { + await manager.register('api', ENDPOINT, apiItem(false)); + await manager.register('flow', FLOW, flowItem('system')); + + const result = await manager.publishPackage(PKG, { namespace: NS, validate: false }); + + expect(result.success).toBe(false); + expect((result.validationErrors ?? []).some((e) => e.type === 'api' && e.message.includes('ADR-0138 D2b'))).toBe(true); + }); + + it('the publish envelope shape is peeled first: a body under `metadata` is judged, not missed', async () => { + await manager.register('api', ENDPOINT, { + name: ENDPOINT, + packageId: PKG, + state: 'draft', + metadata: endpointBody(false), + }); + await manager.register('flow', FLOW, flowItem('system')); + + const result = await manager.publishPackage(PKG, { namespace: NS }); + + expect(result.success).toBe(false); + expect((result.validationErrors ?? []).filter((e) => e.message.includes('ADR-0138 D2b')).map((e) => e.name)) + .toEqual([ENDPOINT]); + }); +}); diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index f464b19a2aa..26368c465fb 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -44,6 +44,8 @@ "AnalyticsSqlResponse (type)", "AnalyticsSqlResponseParsed (type)", "AnalyticsSqlResponseSchema (const)", + "AnonymousElevatedFlowPair (interface)", + "AnonymousFlowDoorCandidate (interface)", "ApiChangelogEntry (type)", "ApiChangelogEntryParsed (type)", "ApiChangelogEntrySchema (const)", @@ -331,6 +333,7 @@ "EditOperationSchema (const)", "EditOperationType (const)", "EditOperationType (type)", + "ElevatedFlowCandidate (interface)", "EmailPasswordConfigPublic (type)", "EmailPasswordConfigPublicSchema (const)", "EnablePackageRequest (type)", @@ -1086,7 +1089,11 @@ "WebSocketServerConfigSchema (const)", "WellKnownCapabilities (type)", "WellKnownCapabilitiesSchema (const)", + "anonymousElevatedFlowPairs (function)", + "anonymousEndpointElevatedFlowMessage (function)", + "anonymousFlowDoorTarget (function)", "datasetCompareKindRefusalMessage (function)", + "elevatedFlowAnonymousEndpointsMessage (function)", "envelopeViolations (function)", "getAuthEndpointUrl (function)", "getDefaultRouteRegistrations (function)", @@ -1102,6 +1109,7 @@ "standardErrorCodeForHttpStatus (function)", "standardSynonymOf (function)", "standardSynonymViolations (function)", + "validateAnonymousElevatedFlows (function)", "validateApiEndpointDeclarations (function)", "zodIssuesToFields (function)" ] diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index e4b2e9433a4..6c44350c154 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -43,6 +43,8 @@ "AnalyticsSqlResponse": "src/api/analytics.zod.ts#AnalyticsSqlResponse (type)", "AnalyticsSqlResponseParsed": "src/api/analytics.zod.ts#AnalyticsSqlResponseParsed (type)", "AnalyticsSqlResponseSchema": "src/api/analytics.zod.ts#AnalyticsSqlResponseSchema (const)", + "AnonymousElevatedFlowPair": "src/api/endpoint-publish-gate.ts#AnonymousElevatedFlowPair (interface)", + "AnonymousFlowDoorCandidate": "src/api/endpoint-publish-gate.ts#AnonymousFlowDoorCandidate (interface)", "ApiChangelogEntry": "src/api/documentation.zod.ts#ApiChangelogEntry (type)", "ApiChangelogEntryParsed": "src/api/documentation.zod.ts#ApiChangelogEntryParsed (type)", "ApiChangelogEntrySchema": "src/api/documentation.zod.ts#ApiChangelogEntrySchema (const)", @@ -314,6 +316,7 @@ "EditOperation": "src/api/websocket.zod.ts#EditOperation (type)", "EditOperationSchema": "src/api/websocket.zod.ts#EditOperationSchema (const)", "EditOperationType": "src/api/websocket.zod.ts#EditOperationType (type)", + "ElevatedFlowCandidate": "src/api/endpoint-publish-gate.ts#ElevatedFlowCandidate (interface)", "EmailPasswordConfigPublic": "src/api/auth-endpoints.zod.ts#EmailPasswordConfigPublic (type)", "EmailPasswordConfigPublicSchema": "src/api/auth-endpoints.zod.ts#EmailPasswordConfigPublicSchema (const)", "EnablePackageRequest": "src/kernel/package-registry.zod.ts#EnablePackageRequest (type)", @@ -1039,7 +1042,11 @@ "WebSocketServerConfigSchema": "src/api/websocket.zod.ts#WebSocketServerConfigSchema (const)", "WellKnownCapabilities": "src/api/discovery.zod.ts#WellKnownCapabilities (type)", "WellKnownCapabilitiesSchema": "src/api/discovery.zod.ts#WellKnownCapabilitiesSchema (const)", + "anonymousElevatedFlowPairs": "src/api/endpoint-publish-gate.ts#anonymousElevatedFlowPairs (function)", + "anonymousEndpointElevatedFlowMessage": "src/api/endpoint-publish-gate.ts#anonymousEndpointElevatedFlowMessage (function)", + "anonymousFlowDoorTarget": "src/api/endpoint-publish-gate.ts#anonymousFlowDoorTarget (function)", "datasetCompareKindRefusalMessage": "src/api/analytics.zod.ts#datasetCompareKindRefusalMessage (function)", + "elevatedFlowAnonymousEndpointsMessage": "src/api/endpoint-publish-gate.ts#elevatedFlowAnonymousEndpointsMessage (function)", "envelopeViolations": "src/api/contract.zod.ts#envelopeViolations (function)", "getAuthEndpointUrl": "src/api/auth-endpoints.zod.ts#getAuthEndpointUrl (function)", "getDefaultRouteRegistrations": "src/api/plugin-rest-api.zod.ts#getDefaultRouteRegistrations (function)", @@ -1055,6 +1062,7 @@ "standardErrorCodeForHttpStatus": "src/api/errors.zod.ts#standardErrorCodeForHttpStatus (function)", "standardSynonymOf": "src/api/error-code-ledger.zod.ts#standardSynonymOf (function)", "standardSynonymViolations": "src/api/error-code-ledger.zod.ts#standardSynonymViolations (function)", + "validateAnonymousElevatedFlows": "src/api/endpoint-publish-gate.ts#validateAnonymousElevatedFlows (function)", "validateApiEndpointDeclarations": "src/api/endpoint-publish-gate.ts#validateApiEndpointDeclarations (function)", "zodIssuesToFields": "src/api/zod-issues-to-fields.ts#zodIssuesToFields (function)" } diff --git a/packages/spec/src/api/anonymous-elevated-flow-gate.test.ts b/packages/spec/src/api/anonymous-elevated-flow-gate.test.ts new file mode 100644 index 00000000000..3538465e592 --- /dev/null +++ b/packages/spec/src/api/anonymous-elevated-flow-gate.test.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0138 D2b — the anonymous door × elevated flow publish refusal, the spec + * half: the one cross-item judge (`anonymousElevatedFlowPairs`) and the stack + * parse's leg (`applyApiEndpointGates` → `validateAnonymousElevatedFlows`). + * + * The record: "Publish refuses an `authRequired: false` endpoint of + * `type: 'flow'` whose target flow declares `runAs: 'system'`, in both + * directions, because either item can be the last one edited." At the stack + * door both items are in one input, so each direction is pinned as the edit + * that completes the pair from an ACCEPTED stack: an endpoint flipped to + * anonymous while its flow runs as `system`, and a flow flipped to `system` + * while an anonymous endpoint names it. Each starting stack is the negative + * control of its direction, and each refusal is asserted on its envelope (the + * issue's `code` and `path` at the parse, `code` + `status` at `defineStack`) + * and on the prescription it carries. + * + * The metadata layer's two doors are pinned in their own packages: + * `publish-anonymous-elevated-flow-gate.test.ts` (`@objectstack/metadata`, + * package publish) and `protocol.item-name-every-door.test.ts` section 5 + * (`@objectstack/metadata-protocol`, flow publish). + */ + +import { describe, it, expect } from 'vitest'; + +import { ObjectStackDefinitionSchema, defineStack } from '../stack.zod'; +import { + anonymousElevatedFlowPairs, + anonymousEndpointElevatedFlowMessage, + anonymousFlowDoorTarget, + elevatedFlowAnonymousEndpointsMessage, + validateAnonymousElevatedFlows, +} from './endpoint-publish-gate'; + +const manifest = { + id: 'com.example.d2b', + name: 'd2b-test', + version: '1.0.0', + type: 'app' as const, + namespace: 'showcase', +}; + +/** ADR-0121 D6: an anonymous endpoint publishes only with an armed budget. */ +const ARMED = { enabled: true, windowMs: 60_000, maxRequests: 100 }; + +const FLOW = 'showcase_partner_intake'; + +/** A `type: 'api'` flow — the shape the runtime's elevated-start refusal does NOT cover. */ +const flow = (runAs?: 'system' | 'user') => ({ + name: FLOW, + label: 'Partner intake', + type: 'api' as const, + ...(runAs !== undefined ? { runAs } : {}), + nodes: [ + { id: 'start', type: 'start' as const, label: 'Start' }, + { id: 'end', type: 'end' as const, label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], +}); + +/** A flow endpoint naming {@link FLOW}; anonymous endpoints arm their budget. */ +const endpoint = (authRequired: boolean, over: Record = {}) => ({ + name: 'showcase_partner_intake_api', + path: '/api/v1/apps/showcase/partner/intake', + method: 'POST' as const, + type: 'flow' as const, + target: FLOW, + authRequired, + ...(authRequired ? {} : { rateLimit: ARMED }), + ...over, +}); + +const stack = (apis: unknown[], flows: unknown[]) => ({ manifest, apis, flows }); + +/** The issues the real seam raises, or `[]` when the stack parses clean. */ +function issuesOf(input: Record) { + const result = ObjectStackDefinitionSchema.safeParse(input); + return result.success ? [] : result.error.issues; +} + +/** The record's prescription, scenario by scenario — each serving channel named. */ +function expectPrescription(message: string) { + expect(message).toContain('authenticated endpoint'); + expect(message).toContain('signed inbound hook (ADR-0041)'); + expect(message).toContain('public form'); + expect(message).toContain('ADR-0138 D2b'); + // ⛔ Not prescribed until ADR-0073 M2 adds it to the enum: an author + // following it today would write a value the schema refuses. + expect(message).not.toContain("'automation'"); +} + +describe('ADR-0138 D2b — the stack parse refuses the pair, whichever item completes it', () => { + it('control: an AUTHENTICATED endpoint on a `system` flow publishes', () => { + expect(issuesOf(stack([endpoint(true)], [flow('system')]))).toEqual([]); + }); + + it('control: an ANONYMOUS endpoint on a `user` flow publishes, and on a flow that leaves `runAs` at its default', () => { + expect(issuesOf(stack([endpoint(false)], [flow('user')]))).toEqual([]); + expect(issuesOf(stack([endpoint(false)], [flow()]))).toEqual([]); + }); + + it('endpoint direction: flipping the endpoint to `authRequired: false` while its flow runs as `system` is refused at the endpoint', () => { + const issues = issuesOf(stack([endpoint(false)], [flow('system')])); + expect(issues).toHaveLength(1); + expect(issues[0]!.code).toBe('custom'); + expect(issues[0]!.path).toEqual(['apis', 0, 'authRequired']); + expect(issues[0]!.message).toContain("Endpoint 'showcase_partner_intake_api'"); + expect(issues[0]!.message).toContain(`flow '${FLOW}'`); + expectPrescription(issues[0]!.message); + }); + + it("flow direction: flipping the flow to `runAs: 'system'` while an anonymous endpoint names it is refused, the same issue", () => { + const before = issuesOf(stack([endpoint(false)], [flow('user')])); + expect(before).toEqual([]); + const after = issuesOf(stack([endpoint(false)], [flow('system')])); + expect(after.map((i) => ({ code: i.code, path: i.path }))).toEqual([ + { code: 'custom', path: ['apis', 0, 'authRequired'] }, + ]); + }); + + it('defineStack refuses it in the stack envelope: STACK_SCHEMA_INVALID, 422, the issue located at the endpoint', () => { + let thrown: any; + try { + defineStack(stack([endpoint(false)], [flow('system')]) as any); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect(thrown.code).toBe('STACK_SCHEMA_INVALID'); + expect(thrown.status).toBe(422); + expect(thrown.issues.map((i: any) => i.path)).toContainEqual(['apis', 0, 'authRequired']); + }); + + it('one issue per anonymous endpoint naming the flow; an authenticated sibling on the same flow is not reported', () => { + const issues = issuesOf(stack( + [ + endpoint(false), + endpoint(true, { name: 'showcase_partner_intake_signed', path: '/api/v1/apps/showcase/partner/signed' }), + endpoint(false, { name: 'showcase_partner_intake_two', path: '/api/v1/apps/showcase/partner/two' }), + ], + [flow('system')], + )); + expect(issues.map((i) => i.path)).toEqual([ + ['apis', 0, 'authRequired'], + ['apis', 2, 'authRequired'], + ]); + }); + + it('an anonymous endpoint that also lacks its armed budget is reported for both (D2b is not a cascade of D6)', () => { + const { rateLimit: _armed, ...unmetered } = endpoint(false); + const issues = issuesOf(stack([unmetered], [flow('system')])); + expect(issues.map((i) => i.path)).toEqual([ + ['apis', 0, 'authRequired'], + ['apis', 0, 'rateLimit'], + ]); + }); +}); + +describe('ADR-0138 D2b — the judge reads only the pair', () => { + it('an anonymous `object_operation` endpoint is not a flow door, whatever flow shares its name', () => { + expect(anonymousFlowDoorTarget({ type: 'object_operation', authRequired: false, target: FLOW })).toBeUndefined(); + expect(issuesOf(stack( + [{ + name: 'showcase_feed', + path: '/api/v1/apps/showcase/feed', + method: 'GET', + type: 'object_operation', + objectParams: { object: 'showcase_task', operation: 'find' }, + target: FLOW, + authRequired: false, + rateLimit: ARMED, + }], + [flow('system')], + ))).toEqual([]); + }); + + it('an endpoint whose `authRequired` is absent is not anonymous (the schema default is true)', () => { + expect(anonymousFlowDoorTarget({ type: 'flow', target: FLOW })).toBeUndefined(); + expect(anonymousElevatedFlowPairs([{ name: 'e', type: 'flow', target: FLOW }], [{ name: FLOW, runAs: 'system' }])) + .toEqual([]); + }); + + it('a `system` flow of another name is not paired', () => { + expect(anonymousElevatedFlowPairs( + [{ name: 'e', type: 'flow', authRequired: false, target: FLOW }], + [{ name: 'showcase_other', runAs: 'system' }], + )).toEqual([]); + }); + + it('returns the pair with both indexes and names, in endpoint order', () => { + expect(anonymousElevatedFlowPairs( + [ + { name: 'a', type: 'flow', authRequired: false, target: 'f2' }, + { type: 'flow', authRequired: false, target: 'f1' }, + ], + [{ name: 'f1', runAs: 'system' }, { name: 'f2', runAs: 'system' }, { name: 'f3', runAs: 'user' }], + )).toEqual([ + { endpointIndex: 0, endpoint: 'a', flowIndex: 1, flow: 'f2' }, + { endpointIndex: 1, endpoint: 'apis[1]', flowIndex: 0, flow: 'f1' }, + ]); + }); + + it('validateAnonymousElevatedFlows is empty without both halves', () => { + expect(validateAnonymousElevatedFlows(undefined, [{ name: FLOW, runAs: 'system' }])).toEqual([]); + expect(validateAnonymousElevatedFlows([endpoint(false)], undefined)).toEqual([]); + }); +}); + +describe('ADR-0138 D2b — each direction\'s refusal carries the prescription', () => { + it('the endpoint direction names the endpoint, the flow and every serving channel', () => { + const message = anonymousEndpointElevatedFlowMessage('e_pub', 'f_sys'); + expect(message).toContain("Endpoint 'e_pub'"); + expect(message).toContain("flow 'f_sys'"); + expectPrescription(message); + }); + + it('the flow direction names the flow and every anonymous endpoint that triggers it', () => { + const one = elevatedFlowAnonymousEndpointsMessage('f_sys', ['e_one']); + expect(one).toContain("Flow 'f_sys'"); + expect(one).toContain("'e_one'"); + expectPrescription(one); + const three = elevatedFlowAnonymousEndpointsMessage('f_sys', ['e1', 'e2', 'e3']); + for (const name of ['e1', 'e2', 'e3']) expect(three).toContain(`'${name}'`); + }); +}); diff --git a/packages/spec/src/api/endpoint-publish-gate.ts b/packages/spec/src/api/endpoint-publish-gate.ts index 97abeb2ee14..e1addd6f002 100644 --- a/packages/spec/src/api/endpoint-publish-gate.ts +++ b/packages/spec/src/api/endpoint-publish-gate.ts @@ -19,10 +19,12 @@ * declarations is exactly the set the runtime executes — the `declared = * enforced` state the whole program exists to reach. * - * ## Every gate has a runtime counterpart, and the runtime is the source + * ## Every per-endpoint gate has a runtime counterpart, and the runtime is the source * - * Nothing here is invented. Each gate mirrors a refusal the runtime already - * makes, so publish and execution can never disagree about what is servable: + * Nothing here is invented. Each per-endpoint gate mirrors a refusal the + * runtime already makes, so publish and execution can never disagree about what + * is servable (the one cross-item gate, ADR-0138 D2b in section (f), is a + * publish refusal by the record's decision and says why there): * * | gate | runtime counterpart | * |---|---| @@ -43,8 +45,13 @@ * value, and the fix — enough to act on without opening this file, the ADR, or * the issue. That is the same posture as a `retiredKey()` tombstone. * - * ## Two rulings this module implements verbatim + * ## Three rulings this module implements verbatim * + * - **ADR-0138 D2b** — publish refuses an `authRequired: false` endpoint of + * `type: 'flow'` whose target flow declares `runAs: 'system'`, in both + * directions, with a prescription: no anonymous door triggers an elevated + * flow directly. Section (f); its D3 record is the semantic entry + * `api-endpoint-anonymous-elevated-flow-refused`. * - **ADR-0121 D2 / #5040 Q1 = A** — the namespace segment comes from an * EXPLICIT `manifest.namespace`. There is no `deriveNamespaceFromPackageId` * fallback here on purpose: an outward URL contract must not shift because @@ -577,6 +584,205 @@ function policyGate( return undefined; } +// ============================================================================ +// (f) The anonymous door × elevated flow — ADR-0138 D2b +// ============================================================================ +// +// `policyGate` above reads ONE endpoint. D2b is a property of a PAIR — an +// `authRequired: false` endpoint of `type: 'flow'` and the flow its `target` +// names, refused when that flow declares `runAs: 'system'` — so it cannot be a +// member of `firstFailure`: no single declaration carries both halves. The +// judge below is the one cross-item reading, and every publish door that holds +// both items calls it (ADR-0138 D2b, "Enforced by": "The cross-item read sits +// where both items are readable at publish, beside the per-endpoint … +// `policyGate`, which reads one endpoint only"): +// +// | door | what it holds | direction | +// |---|---|---| +// | the stack parse (`ObjectStackDefinitionSchema` → `applyApiEndpointGates`) | the stack's `apis` and `flows` together | both, as one input | +// | `MetadataManager.publishPackage` (`@objectstack/metadata`) | the package's `api` and `flow` items, and the manager's index of both | both | +// | the metadata protocol's flow publish (`saveMetaItem` in publish mode, the draft promotion) | the flow being published, and the declared endpoints | a flow set to `system` | +// +// Both directions, because either item can be the last one edited: the +// endpoint's publish reads its target's `runAs`, and a flow's publish that sets +// `runAs: 'system'` reads the anonymous endpoints that target it. +// +// ⛔ Not a mirror of a runtime refusal, unlike the gates above. The endpoint +// executor's `refusesElevatedSelfTriggeredStart` refuses a non-system caller +// only for a SELF-TRIGGERED `system` flow (`autolaunched`, `record_change`, +// `schedule`); a `system` flow of type `api` or `screen` behind an anonymous +// endpoint executes, elevated. The record places D2b at publish, and this is +// that refusal. +// +// The prescription is the record's, scenario by scenario: an authenticated +// endpoint for a partner that can hold a credential, the signed inbound-hook +// channel (ADR-0041) for a partner webhook, a public form for anonymous intake. +// `runAs: 'automation'` joins it when ADR-0073 M2 lands; until then the enum +// has no such value, and prescribing it would send an author to a key value the +// schema refuses. +// +// Out of scope by the record's own words ("recorded and not decided"): a +// guest-written row firing a record-change flow. The trigger there is the write, +// not the door. + +/** + * What the D2b judge reads of an endpoint. A parsed {@link ApiEndpoint} + * satisfies it, and so does a stored body read as it sits: each field is read + * structurally and only the exact value counts, so an absent `authRequired` + * (whose schema default is `true`) is never anonymous. + */ +export interface AnonymousFlowDoorCandidate { + readonly name?: unknown; + readonly type?: unknown; + readonly authRequired?: unknown; + readonly target?: unknown; +} + +/** + * What the D2b judge reads of a flow: its `name`, and its declared `runAs` + * (absent reads as the schema default, `'user'`). + */ +export interface ElevatedFlowCandidate { + readonly name?: unknown; + readonly runAs?: unknown; +} + +/** One refused pair: an anonymous flow endpoint and the `runAs: 'system'` flow it triggers. */ +export interface AnonymousElevatedFlowPair { + /** Index into the `endpoints` the judge was handed. */ + endpointIndex: number; + /** The endpoint's `name`, or `apis[]` when it carries none. */ + endpoint: string; + /** Index into the `flows` the judge was handed. */ + flowIndex: number; + /** The flow's `name`, which is the endpoint's `target`. */ + flow: string; +} + +/** + * The flow an endpoint opens to an anonymous caller: its `target`, when the + * endpoint is `type: 'flow'` with `authRequired: false` and names a target; + * otherwise `undefined`. + */ +export function anonymousFlowDoorTarget( + endpoint: AnonymousFlowDoorCandidate | null | undefined, +): string | undefined { + if (!endpoint || typeof endpoint !== 'object') return undefined; + if (endpoint.type !== 'flow' || endpoint.authRequired !== false) return undefined; + return typeof endpoint.target === 'string' && endpoint.target !== '' ? endpoint.target : undefined; +} + +/** + * ADR-0138 D2b, the cross-item judge: every pair of an anonymous flow endpoint + * (see {@link anonymousFlowDoorTarget}) and a flow of that name declaring + * `runAs: 'system'`. + * + * Pure, total and order-preserving (endpoint order, then flow order). The + * caller decides which half it is publishing and words the refusal for that + * half: {@link anonymousEndpointElevatedFlowMessage} when the endpoint is the + * item being published, {@link elevatedFlowAnonymousEndpointsMessage} when the + * flow is. + */ +export function anonymousElevatedFlowPairs( + endpoints: readonly (AnonymousFlowDoorCandidate | null | undefined)[] | undefined, + flows: readonly (ElevatedFlowCandidate | null | undefined)[] | undefined, +): AnonymousElevatedFlowPair[] { + if (!endpoints || endpoints.length === 0 || !flows || flows.length === 0) return []; + + /** flow name → the indexes of the `system` flows of that name. */ + const elevated = new Map(); + for (let flowIndex = 0; flowIndex < flows.length; flowIndex++) { + const flow = flows[flowIndex]; + if (!flow || typeof flow !== 'object' || flow.runAs !== 'system') continue; + if (typeof flow.name !== 'string' || flow.name === '') continue; + const indexes = elevated.get(flow.name); + if (indexes) indexes.push(flowIndex); + else elevated.set(flow.name, [flowIndex]); + } + if (elevated.size === 0) return []; + + const pairs: AnonymousElevatedFlowPair[] = []; + for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex++) { + const endpoint = endpoints[endpointIndex]; + const target = anonymousFlowDoorTarget(endpoint); + const flowIndexes = target === undefined ? undefined : elevated.get(target); + if (!flowIndexes) continue; + const name = typeof endpoint?.name === 'string' && endpoint.name !== '' + ? endpoint.name + : `apis[${endpointIndex}]`; + for (const flowIndex of flowIndexes) { + pairs.push({ endpointIndex, endpoint: name, flowIndex, flow: target! }); + } + } + return pairs; +} + +/** + * The record's prescription, one serving channel per scenario. Short on + * purpose: a 4xx message crosses the REST boundary with its tail truncated, so + * the prescription comes before the reason in both sentences below. + */ +const ANONYMOUS_ELEVATED_FLOW_PRESCRIPTION = + 'an authenticated endpoint (`authRequired: true`) for a partner that can hold a credential, the flow\'s ' + + 'signed inbound hook (ADR-0041) for a partner webhook, or a public form for anonymous intake'; + +const ANONYMOUS_ELEVATED_FLOW_REASON = + "Behind an anonymous door a `runAs: 'system'` flow would run every request's data steps as the system " + + 'principal, past row-level security, with the rate limit as its only brake.'; + +/** + * D2b's refusal when the ENDPOINT is the item being published (the stack + * parse, a package publish): it names the endpoint, the flow and the + * prescription. + */ +export function anonymousEndpointElevatedFlowMessage(endpoint: string, flow: string): string { + return `Endpoint '${endpoint}' is anonymous (\`authRequired: false\`) and triggers flow '${flow}', which ` + + "declares `runAs: 'system'`: no anonymous door may start an elevated flow (ADR-0138 D2b), so publish " + + 'refuses the pair whichever of the two is published last. Serve that caller with ' + + `${ANONYMOUS_ELEVATED_FLOW_PRESCRIPTION}. ${ANONYMOUS_ELEVATED_FLOW_REASON}`; +} + +/** + * D2b's refusal when the FLOW is the item being published with + * `runAs: 'system'` while anonymous endpoints name it (the metadata protocol's + * flow publish, a package publish whose flow an endpoint outside it names). + * + * @param endpoints the anonymous endpoints' names, at least one. + */ +export function elevatedFlowAnonymousEndpointsMessage(flow: string, endpoints: readonly string[]): string { + const quoted = endpoints.map((name) => `'${name}'`); + const triggers = quoted.length === 1 + ? `the anonymous endpoint ${quoted[0]} (\`authRequired: false\`) triggers it` + : `the anonymous endpoints ${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]} ` + + '(`authRequired: false`) trigger it'; + return `Flow '${flow}' cannot be published with \`runAs: 'system'\`: ${triggers}, and no anonymous door may ` + + 'start an elevated flow (ADR-0138 D2b). Nothing was published. Serve that caller with ' + + `${ANONYMOUS_ELEVATED_FLOW_PRESCRIPTION} first. ${ANONYMOUS_ELEVATED_FLOW_REASON}`; +} + +/** + * D2b over one stack's `apis` and `flows` — the stack parse's leg, raised as + * Zod issues by `applyApiEndpointGates`. One issue per refused pair, located at + * the endpoint's `authRequired` (`['apis', , 'authRequired']`): both + * items are in the one input, so the stack is refused whichever of the two its + * author edited last, and the issue sits on the door, which is what the + * prescription changes. + * + * Independent of {@link validateApiEndpointDeclarations}'s per-endpoint + * first-failure order: a pair is a fact about two declarations, never a + * cascade of another gate's failure, so an anonymous endpoint that also lacks + * its armed budget (ADR-0121 D6) is reported for both. + */ +export function validateAnonymousElevatedFlows( + endpoints: readonly (AnonymousFlowDoorCandidate | null | undefined)[] | undefined, + flows: readonly (ElevatedFlowCandidate | null | undefined)[] | undefined, +): EndpointGateIssue[] { + return anonymousElevatedFlowPairs(endpoints, flows).map((pair) => ({ + path: ['apis', pair.endpointIndex, 'authRequired'], + message: anonymousEndpointElevatedFlowMessage(pair.endpoint, pair.flow), + })); +} + // ============================================================================ // (d) Uniqueness — one stack, one claim per METHOD + normalized path // ============================================================================ diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts index 1152e047b00..efd1fcc221f 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -24,11 +24,24 @@ export * from './endpoint.zod'; // The gate therefore becomes public so the metadata layer can reuse the SAME // criteria at publish and at index-build time — one judge, three doors, rather // than a second implementation that drifts. +// +// [ADR-0138 D2b] …and the cross-item judge beside them: the anonymous flow +// endpoint × `runAs: 'system'` flow pair. The stack parse holds both items; the +// metadata layer's package publish and the metadata protocol's flow publish each +// hold one and read the other, so all three call this one judge. export { validateApiEndpointDeclarations, identityFreeEndpointGateFailure, + anonymousFlowDoorTarget, + anonymousElevatedFlowPairs, + anonymousEndpointElevatedFlowMessage, + elevatedFlowAnonymousEndpointsMessage, + validateAnonymousElevatedFlows, type EndpointGateIssue, type EndpointGateIdentity, + type AnonymousFlowDoorCandidate, + type ElevatedFlowCandidate, + type AnonymousElevatedFlowPair, } from './endpoint-publish-gate'; export * from './discovery.zod'; export * from './events.zod'; diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 7d9189d0da3..f4c8a816f55 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -1112,6 +1112,15 @@ export const FlowSchema = lazySchema(() => strictObject( // record-change flow fired by a write that carried none (any `isSystem` // plugin/service write; `isSystem` does not suppress trigger dispatch, only // `skipTriggers` does). Declare `system` to make the elevation explicit. + // + // [ADR-0138 D2b] …except behind an anonymous door. The prescription above + // used to cover "api triggers" without a word about WHICH api door, so an + // author (an AI most of all) who followed it behind an `authRequired: false` + // endpoint of `type: 'flow'` opened an anonymous, system-level entry point. + // Publish now refuses that pair in both directions + // (`api/endpoint-publish-gate.ts`, section (f)), and the text names the + // exception and what serves that caller, so the describe and the refusal say + // one thing. runAs: z .enum(['system', 'user']) .default('user') @@ -1119,7 +1128,9 @@ export const FlowSchema = lazySchema(() => strictObject( 'Execution identity for the run: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting). ' + 'A run with no trigger user has no identity to scope to, so under user its data operations are REFUSED — ' + 'declare system to make the elevation explicit. This covers schedule/time-relative/api triggers AND any ' + - 'record-change flow fired by a write that carried no user.', + 'record-change flow fired by a write that carried no user. Except behind an anonymous door: publish refuses ' + + 'system on a flow that an authRequired: false endpoint of type flow triggers (ADR-0138 D2b); serve that caller ' + + "through an authenticated endpoint, the flow's signed inbound hook, or a public form.", ), /** diff --git a/packages/spec/src/migrations/entries/semantic/18.api-endpoint-anonymous-elevated-flow-refused.ts b/packages/spec/src/migrations/entries/semantic/18.api-endpoint-anonymous-elevated-flow-refused.ts new file mode 100644 index 00000000000..0778245cb71 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.api-endpoint-anonymous-elevated-flow-refused.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0138 D2b, ruled R: publish refuses an anonymous flow endpoint whose target +// flow runs as `system`, in both directions. The refusal narrows the accept set, +// and no conversion can choose a posture for the author, so the record's card E2 +// registers this one semantic entry with it. +export const entry: SemanticMigration = { + id: 'api-endpoint-anonymous-elevated-flow-refused', + surface: + 'A PAIR of declarations: an `apis[]` endpoint with `authRequired: false` and `type: \'flow\'`, and the ' + + 'flow its `target` names when that flow declares `runAs: \'system\'`. Refused in both directions, ' + + 'because either item can be the last one edited: the endpoint\'s publish reads its target\'s `runAs`, ' + + 'and a flow\'s publish that sets `runAs: \'system\'` reads the anonymous endpoints that target it. The ' + + 'doors: the stack parse (`defineStack`, `os validate`, `os build`, the artifact load), located at ' + + '`apis[i].authRequired`; a package publish through the metadata layer; and a flow published through ' + + 'the metadata API (`PUT /meta/flow/:name` in publish mode, a draft\'s publish, a package\'s ' + + 'publish-drafts), refused `422 INVALID_METADATA` with the issue at `runAs`. Each endpoint alone, and ' + + 'each flow alone, is unchanged: an authenticated endpoint on a `system` flow and an anonymous endpoint ' + + 'on a `user` flow both still publish.', + replacement: + 'Serve the caller through the channel that fits its scenario, and keep the flow\'s `runAs` as the work ' + + 'needs it: a partner that can hold a credential calls an authenticated endpoint (`authRequired: true`, ' + + 'the default); a partner webhook posts to the flow\'s signed inbound hook (ADR-0041: a `type: \'api\'` ' + + 'flow whose start node declares `triggerType: \'api\'`, a `hookId` and a `secret`, reached at ' + + '`POST /api/v1/automation/hooks//` with an HMAC signature header), which may keep ' + + '`runAs: \'system\'`; anonymous intake goes through a public form (a form view whose `sharing` declares ' + + '`enabled: true`, `allowAnonymous: true` and a `publicLink`). `runAs: \'automation\'` joins these when ' + + 'ADR-0073 M2 lands; the enum has no such value until then.', + reason: + 'An anonymous request admitted at such an endpoint ran the flow\'s data steps as the system principal, ' + + 'past row-level security, by the flow author\'s explicit declaration; the only brake was the endpoint\'s ' + + 'rate limit (ADR-0121 D6), and the runtime\'s own elevated-start refusal covers self-triggered flow ' + + 'types only, so a `system` flow of type `api` or `screen` behind the door executed elevated. The flow ' + + '`runAs` description prescribed `system` for user-less triggers, `api` included, so an author who ' + + 'followed it opened the door. ⛔ NOT convertible: which serving channel replaces the anonymous endpoint ' + + 'depends on who the caller is (a partner with a credential, a signed webhook sender, or an anonymous ' + + 'visitor), and whether the flow needs elevation at all is a judgment about its data steps. A transform ' + + 'that flipped `authRequired` would break every caller that holds no credential; one that dropped ' + + '`runAs: \'system\'` would refuse the flow\'s data steps for a run with no user. Either would choose a ' + + 'posture for the author, which the record forbids. Out of scope by the record: a guest-written row ' + + 'firing a record-change flow (the trigger there is the write, not the door).', + acceptanceCriteria: + 'No `apis[]` endpoint with `authRequired: false` and `type: \'flow\'` names, in `target`, a flow that ' + + 'declares `runAs: \'system\'`, whether the flow is in the same stack, another package, or authored ' + + 'through the metadata API. `os validate` passes the stack, a package publish reports no ' + + 'validation error for the pair, and publishing each formerly-paired flow through the metadata API ' + + 'answers 2xx. Each replaced caller is re-pointed and checked: an authenticated partner call answers ' + + 'with a credential and 401 without one, a webhook sender\'s signed POST reaches the inbound hook and ' + + 'an unsigned one is refused, and a public form submission lands its row. ⚠️ A stored flow row that ' + + 'already pairs this way keeps its bytes and keeps executing as published until its next publish, ' + + 'which is refused; list such flows and re-serve their callers before that publish.', +}; diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 4d9297f3978..9f2602e9be7 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -63,7 +63,11 @@ import { CapabilityDeclarationSchema } from './security/capabilities'; import { SharingRuleSchema } from './security/sharing.zod'; import { ApiEndpointSchema, type ApiEndpoint } from './api/endpoint.zod'; -import { validateApiEndpointDeclarations } from './api/endpoint-publish-gate'; +import { + validateApiEndpointDeclarations, + validateAnonymousElevatedFlows, + type ElevatedFlowCandidate, +} from './api/endpoint-publish-gate'; import { retiredKey } from './shared/retired-key'; // AI Protocol @@ -167,11 +171,27 @@ export type DatasourceMappingRule = z.input; * unavoidable: `defineStack`, `os validate`, the lint scorer, the metadata * plugin's artifact ingestion and `EnvironmentArtifactSchema.metadata` all run * through this one parse, so no publish path can forget to check. + * + * [ADR-0138 D2b] The same parse also runs the one cross-item gate: an + * `authRequired: false` endpoint of `type: 'flow'` whose `target` names a flow + * of this stack declaring `runAs: 'system'` is refused, located at the + * endpoint's `authRequired`, with the record's prescription. Both items are in + * this one input, so the refusal holds whichever of the two was edited last. A + * target flow outside the stack is not readable here; the metadata layer's + * doors read it there (`validateAnonymousElevatedFlows`' module notes). */ function applyApiEndpointGates( - config: { manifest?: { namespace?: string | undefined } | undefined; apis?: ApiEndpoint[] | undefined }, + config: { + manifest?: { namespace?: string | undefined } | undefined; + apis?: ApiEndpoint[] | undefined; + flows?: readonly ElevatedFlowCandidate[] | undefined; + }, ctx: z.RefinementCtx, ): void { + for (const issue of validateAnonymousElevatedFlows(config.apis, config.flows)) { + ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message }); + } + for (const issue of validateApiEndpointDeclarations(config.apis, { namespace: config.manifest?.namespace, })) { diff --git a/scripts/adr-anchors/packages__spec__src__api__endpoint-publish-gate.ts.json b/scripts/adr-anchors/packages__spec__src__api__endpoint-publish-gate.ts.json new file mode 100644 index 00000000000..0f7b1463a80 --- /dev/null +++ b/scripts/adr-anchors/packages__spec__src__api__endpoint-publish-gate.ts.json @@ -0,0 +1,8 @@ +{ + "file": "packages/spec/src/api/endpoint-publish-gate.ts", + "adrs": [ + "ADR-0121", + "ADR-0138" + ], + "invariant": "ADR-0121 — a declared `apis:` endpoint is refused at publish for being a shape the runtime cannot serve, one gate at a time, each mirroring a runtime refusal (D6: `authRequired: false` requires `rateLimit.enabled: true`, because the enabled flag defaults to false and a presence check would pass a budget that meters nothing). ADR-0138 D2b — the one CROSS-ITEM gate: an `authRequired: false` endpoint of `type: 'flow'` whose `target` flow declares `runAs: 'system'` is refused at publish in BOTH directions, because either item can be the last one edited. It is a pair judge (`anonymousElevatedFlowPairs`), not a member of the per-endpoint `firstFailure` chain, because no single declaration carries both halves; and it deliberately has no full runtime mirror: the endpoint executor's elevated-start refusal covers self-triggered flow types only, so an `api` or `screen` flow running as `system` behind an anonymous endpoint executes elevated unless publish refuses it. Every publish door that holds the pair calls this one judge: the stack parse, `MetadataManager.publishPackage`, and the metadata protocol's flow publish. The prescription is the record's: an authenticated endpoint, the signed inbound hook (ADR-0041), or a public form; `runAs: 'automation'` joins it only when ADR-0073 M2 adds the value." +}