From 29caa84eb3643456ddeffa52852daa94404f3592 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 05:00:14 +0000 Subject: [PATCH 1/5] feat(lint): validate-flow-trigger-readiness refuses an api flow with no per-flow secret An `api`-bound flow (the engine's deriveTriggerBinding: array-form record pre-check, then resolveFlowTriggerKind === 'api') whose start node carries no string config.secret non-empty after trim is now an `error`, flow-api-trigger-secret-missing, in the file's never-fire family. The automation engine refuses the same flow at registration (ADR-0041), so `os validate` no longer passes a flow no runtime will register. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .changeset/20553-validate-api-flow-secret.md | 24 +++ packages/lint/src/authoring-rules.ts | 4 +- packages/lint/src/index.ts | 1 + .../validate-flow-trigger-readiness.test.ts | 163 ++++++++++++++++++ .../src/validate-flow-trigger-readiness.ts | 136 ++++++++++++++- 5 files changed, 325 insertions(+), 3 deletions(-) create mode 100644 .changeset/20553-validate-api-flow-secret.md diff --git a/.changeset/20553-validate-api-flow-secret.md b/.changeset/20553-validate-api-flow-secret.md new file mode 100644 index 00000000000..7ef15994ea3 --- /dev/null +++ b/.changeset/20553-validate-api-flow-secret.md @@ -0,0 +1,24 @@ +--- +'@objectstack/lint': minor +--- + +`os validate` refuses an `api` flow with no per-flow secret, the flow the automation engine already refuses to register (#20553). + +Clause-②: yes (accept/reject: `os validate` newly refuses a secretless `api` flow) + +`validate-flow-trigger-readiness` gains one rule id, `flow-api-trigger-secret-missing`, at `error`. It names a flow whose binding resolves to the inbound `api` trigger when that flow's start node carries no usable `config.secret`. A usable secret is a string that is non-empty after trimming. The rule fires for a missing, blank or non-string secret, and for an `api` flow with no start node. + +**Why.** ADR-0041's `trigger-api` acceptance criteria require a per-flow secret with HMAC verification. In the same release, the automation engine refuses such a flow in `registerFlow`, whatever its `status`: the `/automation` write doors answer `400`, and a boot skips the flow with a warning. `ApiTrigger.start()` also refuses to arm it. `os validate` builds neither, so it answered `✓ Validation passed` for a flow no runtime would register. It now exits non-zero and names the flow. + +**Which flows count as `api`-bound.** The rule uses the engine's own binding, `deriveTriggerBinding`. An array-form record `triggerType` goes to the record-change trigger first. Otherwise the flow gets the kind `resolveFlowTriggerKind` answers, which is a flow declaring `type: 'api'` or a start-node `triggerType: 'api'`. The engine gives the record-change, time-relative and schedule triggers precedence over `api`. So a `type: 'api'` flow whose start node also carries a `record-*` token, a `timeRelative` descriptor or a `config.schedule` binds that other trigger. The engine never asks that flow for a secret, and the rule stays silent on it. + +**Where the refusal surfaces.** The rule is `CLI_AND_RUNTIME`, so it gates in two places: + +- `os validate`, `os build` and `os lint`. +- The runtime metadata publish gate. A `state: 'active'` write of a secretless `api` flow now carries this `error`, so the gate refuses it before the flow is stored. Before this change the gate passed that write. + +The gate judges only the item being written, so existing stored flows are not re-judged. + +**Fix.** Set a non-blank `config.secret` on the flow's start node, and sign each post with it in the `x-objectstack-signature` header. A flow that is only ever started explicitly and never receives inbound posts is `type: 'autolaunched'`, with no `triggerType: 'api'` on its start node, and it needs no secret. + +`FLOW_API_TRIGGER_SECRET_MISSING` is exported from `@objectstack/lint`. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 9aa91863fb0..789b320b74f 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -1107,7 +1107,9 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // predicate cannot route at all, a `record-*` triggerType outside the // closed token grammar `triggerTypeToHookEvents` maps, and (#6637) a // `type: 'record_change'` flow whose triggerType the engine's binding resolver - // routes nowhere, silently demoting it to a manual flow. None of those verdicts + // routes nowhere, silently demoting it to a manual flow. #20553 made it five: an + // `api`-bound flow with no usable `config.secret`, which the engine's own + // `registerFlow` refuses (ADR-0041). None of those verdicts // can be changed by installing a package, so there is no reading under which // the flow fires. `flow-trigger-unknown-object` deliberately stayed `warning` // (the object may come from another installed package — a hedge this rule diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 5dc98ebc286..01014fe6fef 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -132,6 +132,7 @@ export { FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, FLOW_TRIGGER_UNROUTABLE, + FLOW_API_TRIGGER_SECRET_MISSING, } from './validate-flow-trigger-readiness.js'; export type { FlowTriggerReadinessFinding, diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts index 1aad0afb734..2687cc6d11f 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.test.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -10,6 +10,7 @@ import { FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, FLOW_TRIGGER_UNROUTABLE, + FLOW_API_TRIGGER_SECRET_MISSING, } from './validate-flow-trigger-readiness.js'; function recordFlow(overrides: Record = {}) { @@ -1008,6 +1009,143 @@ describe('validateFlowTriggerReadiness', () => { }); }); + // ── #20553 — an `api`-bound flow with no usable secret (ADR-0041) ───────── + // + // The engine refuses such a flow in `registerFlow`; `os validate` builds no + // engine, so this rule is the only thing at authoring time that says so. The + // cases pin the three things the rule has to get right, each against the + // engine's MEASURED answer rather than a reading of `type`: which flows are + // `api`-bound, what counts as a usable secret, and that a flow the engine + // registers is never named. + describe('api trigger secret (ADR-0041)', () => { + function apiFlow( + config: Record, + overrides: Record = {}, + ): Record { + return { + name: 'inbound_order', + type: 'api', + status: 'active', + nodes: [ + { id: 'start', type: 'start', config }, + { id: 'end', type: 'end' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + ...overrides, + }; + } + const judge = (flow: Record) => + validateFlowTriggerReadiness({ objects: [candidateObject], flows: [flow] }); + + it('fails a secretless `type: api` flow, naming the flow and the key', () => { + const findings = judge(apiFlow({ hookId: 'intake' })); + // Exhaustive: the flow is active and otherwise well-formed, so this id is + // the ONLY thing wrong with it. + expect(findings.map((f) => f.rule)).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]); + const [f] = findings; + expect(f.severity).toBe('error'); + expect(f.where).toBe('flow "inbound_order" › start node'); + expect(f.path).toBe('flows[0].nodes[0].config.secret'); + expect(f.message).toContain(`type: 'api'`); + expect(f.message).toContain('config.secret'); + }); + + it('passes the same flow once it carries a secret', () => { + expect(judge(apiFlow({ hookId: 'intake', secret: 'whsec_4f9a' }))).toEqual([]); + }); + + it('passes an `autolaunched` flow — the explicit-only form needs no secret', () => { + expect(judge(apiFlow({}, { type: 'autolaunched' }))).toEqual([]); + // …and the same flow flipped to `api` with nothing else changed fails. + expect(judge(apiFlow({}, { type: 'api' })).map((f) => f.rule)).toEqual([ + FLOW_API_TRIGGER_SECRET_MISSING, + ]); + }); + + it('judges a start-node `triggerType: api` on any other flow type exactly like `type: api`', () => { + for (const type of ['autolaunched', 'screen', 'record_change']) { + const secretless = judge(apiFlow({ triggerType: 'api' }, { type })); + expect(secretless.map((f) => f.rule), type).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]); + expect(secretless[0].path, type).toBe('flows[0].nodes[0].config.secret'); + expect(secretless[0].message, type).toContain(`start-node triggerType: 'api'`); + expect(secretless[0].message, type).not.toContain(`type: 'api' and`); + expect(judge(apiFlow({ triggerType: 'api', secret: 's3cret' }, { type })), type).toEqual([]); + } + // Both declarations at once: still one finding, naming both. + const both = judge(apiFlow({ triggerType: 'api' })); + expect(both.map((f) => f.rule)).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]); + expect(both[0].message).toContain(`type: 'api' and start-node triggerType: 'api'`); + }); + + it('fails a blank secret, and every non-string one, without echoing the value', () => { + for (const secret of [' ', '', '\t\n', 12345, true, null, ['s3cret'], { value: 's3cret' }]) { + const findings = judge(apiFlow({ secret })); + expect(findings.map((f) => f.rule), JSON.stringify(secret)).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]); + // Only the TYPE is rendered — a finding travels into CI logs and + // publish-gate responses. + expect(findings[0].message, JSON.stringify(secret)).not.toContain('12345'); + expect(findings[0].message, JSON.stringify(secret)).not.toContain('s3cret'); + } + expect(judge(apiFlow({ secret: ' ' }))[0].message).toContain('blank'); + expect(judge(apiFlow({ secret: 12345 }))[0].message).toContain('a number, not a string'); + // The runtime trims before judging; a padded real secret is usable. + expect(judge(apiFlow({ secret: ' s3cret ' }))).toEqual([]); + }); + + it('judges a disabled flow too — the engine refuses it whatever its status', () => { + for (const status of ['obsolete', 'draft']) { + expect( + judge(apiFlow({}, { status })).map((f) => f.rule), + status, + ).toContain(FLOW_API_TRIGGER_SECRET_MISSING); + } + }); + + it('judges a flow with no start node, located at `nodes`', () => { + const findings = judge({ name: 'no_start_api', type: 'api', status: 'active', nodes: [{ id: 'end', type: 'end' }] }); + expect(findings.map((f) => f.rule)).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]); + expect(findings[0].where).toBe('flow "no_start_api"'); + expect(findings[0].path).toBe('flows[0].nodes'); + expect(findings[0].message).toContain('no start node'); + }); + + it('matches the engine where precedence binds another trigger — each paired with the shape that fires', () => { + // Measured against the BUILT `AutomationEngine.registerFlow` at + // f11b5f20a2: each of these registers with no secret, because + // `deriveTriggerBinding` binds the higher-precedence trigger and never + // asks for one. The `type === 'api' || triggerType === 'api'` disjunction + // would refuse all five. + const boundElsewhere: Array<[string, Record]> = [ + ['api + config.schedule', apiFlow({ schedule: { type: 'interval', intervalMs: 60000 } })], + ['api + record-* token', apiFlow({ objectName: 'app_candidate', triggerType: 'record-after-create' })], + ['api + array record token', apiFlow({ objectName: 'app_candidate', triggerType: ['record-after-create'] })], + [ + 'api + timeRelative descriptor', + apiFlow({ timeRelative: { object: 'app_candidate', dateField: 'due_at', withinDays: 3 } }), + ], + [ + 'schedule + triggerType api', + apiFlow({ triggerType: 'api', schedule: { type: 'interval', intervalMs: 60000 } }, { type: 'schedule' }), + ], + ]; + for (const [label, flow] of boundElsewhere) { + expect(judge(flow).map((f) => f.rule), label).not.toContain(FLOW_API_TRIGGER_SECRET_MISSING); + } + // A SCALAR `timeRelative` is not routed to the sweep, so the flow falls + // through to `api` — measured REFUSED by the engine — and both facts are + // reported, at two paths. + const scalar = judge(apiFlow({ timeRelative: 'daily' })).map((f) => f.rule); + expect(scalar).toContain(FLOW_API_TRIGGER_SECRET_MISSING); + expect(scalar).toContain(FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE); + // Remove the higher-precedence sibling and the same flow is refused again. + expect(judge(apiFlow({})).map((f) => f.rule)).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]); + }); + + it('the id is the published slug', () => { + expect(FLOW_API_TRIGGER_SECRET_MISSING).toBe('flow-api-trigger-secret-missing'); + }); + }); + // ── #5762 — the family's severity map ──────────────────────────────────── // // The rules in this file were reviewed as ONE family and split on a single @@ -1109,6 +1247,21 @@ describe('validateFlowTriggerReadiness', () => { ], }, ], + [ + FLOW_API_TRIGGER_SECRET_MISSING, + 'error', + { + objects: [candidateObject], + flows: [ + { + name: 'unsigned_hook', + type: 'api', + status: 'active', + nodes: [{ id: 'start', type: 'start', config: { hookId: 'intake' } }], + }, + ], + }, + ], // ── The controls. Both are hedged, and the hedge is the whole reason the // promotion above is not "everything in this file is an error now". [ @@ -1199,6 +1352,16 @@ describe('validateFlowTriggerReadiness', () => { }, ], }, + // [#20553] A signed inbound flow is what a correct `api` flow looks + // like — the secret rule must not move this floor. + { + name: 'order_intake', + type: 'api', + status: 'active', + nodes: [ + { id: 'start', type: 'start', config: { hookId: 'intake', secret: 'whsec_4f9a' } }, + ], + }, ], }), ).toEqual([]); diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index 87b1258f154..4b53700dcbc 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -51,7 +51,14 @@ // silence — every named runtime channel skips it because they all key off // the same resolution that already gave up. // -// ⚠️ A sixth rule lived here and is RETIRED (#17396): +// 6. A flow bound to the inbound `api` trigger whose start node carries no +// usable `config.secret` (ADR-0041). The automation engine refuses such a +// flow at REGISTRATION, whatever its `status`, and the trigger refuses to +// arm it — yet `os validate` never builds the engine, so until this rule +// it answered "passed" for a flow no runtime will ever register. See 1h +// for why the judgement is carried here rather than read from the runtime. +// +// ⚠️ One more rule lived here and is RETIRED (#17396): // `flow-schedule-organization-missing`, a `warning` on a time-triggered // flow declaring no `config.organization`. It was true while every such // flow owed the key. It is not true now: a deployment-level switch gates @@ -92,6 +99,16 @@ // authored-token → resolved-type map is a private chain of literal // `startsWith` / `typeof` tests with no registry lookup anywhere in it. No // package can teach the engine a new authored token. +// - `error` — `flow-api-trigger-secret-missing` joins the family on the same +// question, and its answer is the most direct one here: the verdict is the +// engine's own REGISTRATION refusal, a hardcoded check inside +// `registerFlow` that runs before any trigger is consulted and whatever the +// flow's `status`. The "installing something fixes it" hypothesis was +// measured for it too, and is false: an engine with a registered `api` +// trigger that would arm anything still refuses the flow, because it never +// reaches the point of asking that trigger. The rule speaks only for flows +// whose binding the ENGINE resolves to `api` (see 1h), so every flow it +// names is one the engine refuses. // ⚠️ `flow-schedule-organization-missing` was the family's one measured // exception — `error` on the criterion, held at `warning` by the shipped // corpus — and it is retired (#17396) rather than re-severitied. The @@ -193,6 +210,38 @@ export const FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE = 'flow-time-relative-desc * call sites that each skip it. */ export const FLOW_TRIGGER_UNROUTABLE = 'flow-trigger-unroutable'; +/** + * #20553 — ADR-0041 (`trigger-api` acceptance criteria: "a per-flow secret; + * HMAC signature verification"). A flow whose binding resolves to the inbound + * `api` trigger, and whose start node carries no usable `config.secret`, is + * refused by the automation engine at registration and by the trigger at arm + * time, so it can never receive a post. + * + * `flow--`: the descriptor is the `api` trigger's secret + * and the verdict is "missing" — absent, blank after `trim()`, or not a string, + * the three spellings both runtime copies read as one. + * + * ## The runtime copies, and why this rule cannot read them + * + * The judgement lives twice in the runtime, on purpose (each package has no + * dependency on the other, and each protects a host the other does not): + * + * - `AutomationEngine.validateApiTriggerSecret` + * (`packages/services/service-automation/src/engine.ts`), called from + * `registerFlow` — the publish-time refusal an author sees; + * - `ApiTrigger.start()` (`packages/triggers/trigger-api/src/api-trigger.ts`) + * — the arm-time refusal, for a host that binds without that engine. + * + * Neither is readable from here. The engine's is a PRIVATE method of a runtime + * package, the trigger's is inline in `start()`, and this package's stated + * dependency direction is lint → `@objectstack/spec`, never onto a runtime. + * `@objectstack/spec` exports no predicate for the secret (it exports only the + * KIND half, `resolveFlowTriggerKind`, which 1h does read). So the rule carries + * the one-line judgement — a string, non-empty after `trim()`, on the start + * node's `config` — and a spec-level predicate all three could share would be + * its own change, editing both runtime copies to read it. + */ +export const FLOW_API_TRIGGER_SECRET_MISSING = 'flow-api-trigger-secret-missing'; type AnyRec = Record; @@ -241,6 +290,29 @@ function renderTriggerToken(v: unknown): string { return json === undefined ? `a ${typeof v}` : json; } +/** + * What is wrong with an `api`-bound flow's secret, for the 1h message — or + * `undefined` when the secret is usable. + * + * "Usable" is the runtime's judgement, character for character: a string that + * is non-empty after `trim()` (`validateApiTriggerSecret` and `ApiTrigger.start()` + * both read it that way — see {@link FLOW_API_TRIGGER_SECRET_MISSING}). + * + * ⛔ The value itself is never rendered, only its TYPE. A finding travels into + * CI logs and publish-gate responses, and a non-string `secret` is still + * something an author put where a secret goes. + */ +function describeUnusableSecret(start: { node: AnyRec } | undefined, config: AnyRec): string | undefined { + if (!start) return 'it has no start node, so nothing declares a config.secret'; + const secret = config.secret; + if (typeof secret === 'string') { + return secret.trim() !== '' ? undefined : `its start node's config.secret is blank`; + } + if (secret === undefined) return 'its start node declares no config.secret'; + const kind = secret === null ? 'null' : Array.isArray(secret) ? 'an array' : `a ${typeof secret}`; + return `its start node's config.secret is ${kind}, not a string`; +} + /** The start node of a flow definition, if any. */ function startNodeOf(flow: AnyRec): { node: AnyRec; index: number } | undefined { const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : []; @@ -290,7 +362,8 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines // answers a kind exactly when one of those terms held; the array-form // record trigger (1d's subject) never counted here and resolves to no kind // there either. - const isAutoTriggered = resolveFlowTriggerKind(flow) !== undefined; + const triggerKind = resolveFlowTriggerKind(flow); + const isAutoTriggered = triggerKind !== undefined; // 1. Record-triggered flow targeting an object this stack does not define. if (isRecordTriggered && start) { @@ -676,6 +749,65 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines // diagnostic, it is the diagnostic moving to where the question is // answerable. + // 1h. #20553 — a flow bound to the inbound `api` trigger with no usable + // `config.secret` (ADR-0041). The engine refuses it in `registerFlow` + // (the `/automation` write doors answer 400; a boot skips it with a + // warning) and `ApiTrigger.start()` refuses to arm it; `os validate`, + // which builds neither, said nothing. + // + // WHICH flows are `api`-bound is the ENGINE's answer, not a reading of + // `type`: `deriveTriggerBinding` first routes an array-form record + // `triggerType` to the record-change trigger (1d's shape), and otherwise + // takes `resolveFlowTriggerKind`'s kind — the spec export this file + // already reads above. `bindsApiTrigger` is those two steps, in that + // order, and nothing else. ⛔ Not the `flow.type === 'api' || + // triggerType === 'api'` disjunction 1f uses for "routes SOMEWHERE": the + // resolver ranks record / time-relative / schedule ahead of `api`, so a + // `type: 'api'` flow whose start node also carries a `record-*` token, a + // `timeRelative` descriptor or a `config.schedule` is bound to THAT + // trigger, and the engine never asks it for a secret. Measured on the + // built engine: those five precedence shapes (with the array form, and + // `type: 'schedule'` beside `triggerType: 'api'`) all register; the + // disjunction would have refused every one. + // + // `status` is deliberately not read: the engine refuses an `obsolete` + // flow too (a refused flow is never stored at all), so a secretless + // disabled flow still fails its own registration. A flow with NO start + // node is judged as well — the engine reads its `config` as `{}` and + // refuses it for the same reason — and is located at `nodes`, since + // there is no start node to point at. + const bindsApiTrigger = !isArrayRecordTriggered && triggerKind === 'api'; + const secretProblem = bindsApiTrigger ? describeUnusableSecret(start, config) : undefined; + if (secretProblem) { + // Which declaration binds it — the engine's own message names the same + // two, so an author who meets both channels reads one story. + const binds = [ + flow.type === 'api' ? `type: 'api'` : undefined, + triggerType === 'api' ? `start-node triggerType: 'api'` : undefined, + ].filter((s): s is string => s !== undefined); + findings.push({ + // `error` — the never-fire family, and the strongest verdict in it: the + // engine does not merely leave this flow unfired, it refuses to + // register it, in a hardcoded check no installed package can reach + // (see the Severity section above). + severity: 'error', + rule: FLOW_API_TRIGGER_SECRET_MISSING, + where: start ? `flow "${flowName}" › start node` : `flow "${flowName}"`, + path: start + ? `flows[${flowIndex}].nodes[${start.index}].config.secret` + : `flows[${flowIndex}].nodes`, + message: + `binds the inbound api trigger (${binds.join(' and ')}) but ${secretProblem}. An inbound hook ` + + `is armed only with a per-flow secret that every post is HMAC-verified against (ADR-0041), so the ` + + `automation engine refuses to register this flow, whatever its status, and it never receives a post.`, + hint: + `Set a non-blank string config.secret on the start node, and sign each post with it: the ` + + `x-objectstack-signature header carries 'sha256=' and the hex HMAC-SHA256 of the raw body. A flow that is ` + + `only ever started explicitly and never receives inbound posts is type: 'autolaunched', with no ` + + `triggerType: 'api' on its start node — that flow needs no secret.`, + }); + } + // 2. Auto-triggered flow whose status is 'draft' — authored or defaulted // (defineFlow parses at definition time, so the two are the same here). if (isAutoTriggered && (flow.status == null || flow.status === 'draft')) { From d1f76819c4e21debae596d07a557b3b6cc07116c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 05:58:26 +0000 Subject: [PATCH 2/5] chore(changeset): declare the lint accept-set narrowing for the api secret rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset's Clause-② line now matches the PR body byte for byte: yes (narrowing), because os validate / os build / os lint and the runtime metadata publish gate newly refuse a secretless api-bound flow while the new exported rule id widens @objectstack/lint. It gains a BREAKING banner in the launch-window shape (minor, one-line fix) and the ADR-0087 disposition not-required (no-migration-prescription): the missing value is a shared secret no migration can supply. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .changeset/20553-validate-api-flow-secret.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.changeset/20553-validate-api-flow-secret.md b/.changeset/20553-validate-api-flow-secret.md index 7ef15994ea3..506d2f86ec7 100644 --- a/.changeset/20553-validate-api-flow-secret.md +++ b/.changeset/20553-validate-api-flow-secret.md @@ -4,7 +4,20 @@ `os validate` refuses an `api` flow with no per-flow secret, the flow the automation engine already refuses to register (#20553). -Clause-②: yes (accept/reject: `os validate` newly refuses a secretless `api` flow) +Clause-②: yes (narrowing — `os validate` / `os build` / `os lint` and the runtime metadata publish gate newly refuse a secretless `api`-bound flow; the new exported rule id `FLOW_API_TRIGGER_SECRET_MISSING` widens `@objectstack/lint`) + + + +**BREAKING** — an accept-set narrowing at two authoring doors, shipped as +`minor` under the launch-window convention (`check-changeset-no-major` refuses +`major` until GA; breaking-ness is carried by this banner and the ADR-0087 +disposition above, not by the level). A stack that declares an `api`-bound flow +whose start node carries no usable `config.secret` used to pass `os validate`, +`os build` and `os lint`; they now exit non-zero and name the flow. The runtime +metadata publish gate used to pass a `state: 'active'` write of such a flow; it +now refuses it before the flow is stored. **One-line fix:** set a non-blank +`config.secret` on the flow's start node — or, for a flow that is only ever +started explicitly, declare `type: 'autolaunched'` with no `triggerType: 'api'`. `validate-flow-trigger-readiness` gains one rule id, `flow-api-trigger-secret-missing`, at `error`. It names a flow whose binding resolves to the inbound `api` trigger when that flow's start node carries no usable `config.secret`. A usable secret is a string that is non-empty after trimming. The rule fires for a missing, blank or non-string secret, and for an `api` flow with no start node. From 5dde7e6f468c9b5056c8c20e3364e01ee4c4e865 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:33:09 +0000 Subject: [PATCH 3/5] feat(lint): flow-api-trigger-secret-missing runs on the CLI surface only, as its own rule The api-trigger secret judgement moves out of validateFlowTriggerReadiness into its own exported rule, validateFlowApiTriggerSecret, on its own registry entry: gating, all three commands, surfaces CLI_ONLY with a surfaceReason. The runtime publish gate judges a /meta save before saveMetaItem restores the inbound-hook secret the flow read path withholds, so a signed flow's GET, edit, PUT round trip would reach the rule secretless and be refused. Until the gate judges the carried-forward body, the id stays off that surface; os validate / os build / os lint keep the refusal. Pins cover both sides of the wall, with a positive control at the gate. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- packages/lint/src/authoring-rules.ts | 37 +++- packages/lint/src/index.ts | 1 + .../validate-flow-trigger-readiness.test.ts | 83 +++++++- .../src/validate-flow-trigger-readiness.ts | 184 +++++++++++------- 4 files changed, 234 insertions(+), 71 deletions(-) diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 789b320b74f..ea43905b147 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -117,7 +117,10 @@ import { validateJsxPages } from './validate-jsx-pages.js'; import { validateReactPages } from './validate-react-pages.js'; import { validatePageSourceStyling } from './validate-page-source-styling.js'; import { validateCapabilityReferences } from './validate-capability-references.js'; -import { validateFlowTriggerReadiness } from './validate-flow-trigger-readiness.js'; +import { + validateFlowApiTriggerSecret, + validateFlowTriggerReadiness, +} from './validate-flow-trigger-readiness.js'; import { validateApprovalApprovers } from './validate-approval-approvers.js'; import { validateRecordTitle } from './validate-record-title.js'; import { validateFieldConsumers } from './validate-field-consumers.js'; @@ -1109,7 +1112,8 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // `type: 'record_change'` flow whose triggerType the engine's binding resolver // routes nowhere, silently demoting it to a manual flow. #20553 made it five: an // `api`-bound flow with no usable `config.secret`, which the engine's own - // `registerFlow` refuses (ADR-0041). None of those verdicts + // `registerFlow` refuses (ADR-0041) — on its OWN entry below + // (`validateFlowApiTriggerSecret`), because it is CLI-only for now. None of those verdicts // can be changed by installing a package, so there is no reading under which // the flow fires. `flow-trigger-unknown-object` deliberately stayed `warning` // (the object may come from another installed package — a hedge this rule @@ -1139,6 +1143,35 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ runtimeTypes: ['flow'], run: (stack) => validateFlowTriggerReadiness(stack), }, + // #20553 — `flow-api-trigger-secret-missing`, split out of the entry above as + // its own exported rule (the `validateSecurityRoleWord` precedent: one rule id + // sits on ONE side of the runtime wall). Same family, same `error`, all three + // commands — but NOT the runtime publish gate yet, and #20611 is the card that + // moves it across. The flow read path withholds `config.secret` from every + // served definition (#20552) and `saveMetaItem` restores the stored secret only + // just before the put, AFTER this table has judged the body the caller sent — + // so on the gate, a signed flow's ordinary GET → edit → PUT reads as + // secretless. Measured on `825c33ff9f`: with this id on the gate, the two + // round-trip pins in `protocol.metadata-redaction.test.ts` fail; off it, 26/26. + // The `/meta` door therefore keeps its pre-rule behaviour (it stores a + // secretless flow, and the engine refuses it at registration) until the gate + // judges the carried-forward body — then this entry becomes `CLI_AND_RUNTIME` + // with `runtimeTypes: ['flow']`, like the one above. + { + name: 'validateFlowApiTriggerSecret', + tier: 'gating', + input: 'normalized', + commands: ALL, + source: 'packages/lint/src/validate-flow-trigger-readiness.ts', + surfaces: CLI_ONLY, + surfaceReason: + 'Not yet runtime-safe: the publish gate judges a /meta save BEFORE saveMetaItem restores the ' + + 'inbound-hook secret the flow read path withholds, so a signed api flow\'s ordinary GET, edit, PUT ' + + 'round trip reaches this rule secretless and would be refused. It crosses when the gate judges the ' + + 'carried-forward body (the seam follow-up named in the comment above); until then the engine\'s ' + + 'registerFlow refusal is what a secretless flow saved through /meta meets.', + run: (stack) => validateFlowApiTriggerSecret(stack), + }, // ADR-0090 D3 fallout — an approval `{ type: 'role' }` resolves against the // better-auth org-membership tier, not positions, so a position name authored // there routes the approval to nobody; and an expression approver that does diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 01014fe6fef..f84ce46e4c5 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -126,6 +126,7 @@ export type { ManagedApiMethodFinding } from './validate-managed-api-methods.js' export type { ListViewModeFinding, ListViewModeSeverity } from './validate-list-view-mode.js'; export { validateFlowTriggerReadiness, + validateFlowApiTriggerSecret, FLOW_TRIGGER_UNKNOWN_OBJECT, FLOW_DRAFT_STATUS_AMBIGUOUS, FLOW_TRIGGER_UNKNOWN_EVENT, diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts index 2687cc6d11f..b41b160012b 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.test.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -11,7 +11,22 @@ import { FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE, FLOW_TRIGGER_UNROUTABLE, FLOW_API_TRIGGER_SECRET_MISSING, + validateFlowApiTriggerSecret, } from './validate-flow-trigger-readiness.js'; +import { AUTHORING_COMMANDS, AUTHORING_RULES, runAuthoringRules } from './authoring-rules.js'; +import { runRuntimeAuthoringRules } from './runtime-gate.js'; + +/** + * [#20553] The flow-trigger family as the CLI table runs it: two registry + * entries over one file — `validateFlowTriggerReadiness` and, split out because + * it is CLI-only until #20611, `validateFlowApiTriggerSecret`. Cases that are + * about the WHOLE family's verdict on a stack (the severity map, the clean-stack + * floor, the api-secret cases' exhaustive assertions) judge through this. + */ +const cliFlowFamily = (stack: Record) => [ + ...validateFlowTriggerReadiness(stack), + ...validateFlowApiTriggerSecret(stack), +]; function recordFlow(overrides: Record = {}) { return { @@ -1035,7 +1050,7 @@ describe('validateFlowTriggerReadiness', () => { }; } const judge = (flow: Record) => - validateFlowTriggerReadiness({ objects: [candidateObject], flows: [flow] }); + cliFlowFamily({ objects: [candidateObject], flows: [flow] }); it('fails a secretless `type: api` flow, naming the flow and the key', () => { const findings = judge(apiFlow({ hookId: 'intake' })); @@ -1144,6 +1159,66 @@ describe('validateFlowTriggerReadiness', () => { it('the id is the published slug', () => { expect(FLOW_API_TRIGGER_SECRET_MISSING).toBe('flow-api-trigger-secret-missing'); }); + + // [#20611] The id is CLI-only until the runtime publish gate judges the + // carried-forward body: a `/meta` save is gated BEFORE `saveMetaItem` + // restores the `config.secret` the flow read path withholds (#20552), so a + // signed flow's GET → edit → PUT would reach the gate secretless. Each side + // of the wall is pinned, and the gate pin carries its own positive control. + describe('one rule id on ONE side of the runtime wall — CLI-only until #20611', () => { + const secretless = apiFlow({ hookId: 'intake' }); + const stack = { objects: [candidateObject], flows: [secretless] }; + + it('the split is whole: validateFlowTriggerReadiness alone no longer emits the id', () => { + expect(validateFlowTriggerReadiness(stack).map((f) => f.rule)).not.toContain( + FLOW_API_TRIGGER_SECRET_MISSING, + ); + expect(validateFlowApiTriggerSecret(stack).map((f) => f.rule)).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]); + }); + + it('its registry entry gates all three commands on the cli surface only, with a reason', () => { + const entry = AUTHORING_RULES.find((r) => r.name === 'validateFlowApiTriggerSecret'); + expect(entry).toBeDefined(); + expect(entry!.tier).toBe('gating'); + expect([...entry!.commands].sort()).toEqual([...AUTHORING_COMMANDS].sort()); + expect(entry!.surfaces).toEqual(['cli']); + expect(entry!.runtimeTypes).toBeUndefined(); + expect((entry!.surfaceReason ?? '').trim().length).toBeGreaterThan(40); + // …while the family's shared entry still crosses the wall. + expect(AUTHORING_RULES.find((r) => r.name === 'validateFlowTriggerReadiness')!.surfaces).toContain( + 'runtime-publish', + ); + }); + + it.each([...AUTHORING_COMMANDS])('os %s still refuses a secretless api flow through the table', (command) => { + const hits = runAuthoringRules(command, { normalized: stack, parsed: stack }).filter( + (f) => f.rule === FLOW_API_TRIGGER_SECRET_MISSING, + ); + expect(hits.map((f) => [f.severity, f.path])).toEqual([['error', 'flows[0].nodes[0].config.secret']]); + }); + + it('the runtime publish gate does not emit it — and still runs the rest of the family', () => { + const gated = runRuntimeAuthoringRules({ type: 'flow', item: secretless }); + expect(gated.rulesRun).toContain('validateFlowTriggerReadiness'); + expect(gated.rulesRun).not.toContain('validateFlowApiTriggerSecret'); + expect([...gated.errors, ...gated.advisories].map((f) => f.rule)).not.toContain( + FLOW_API_TRIGGER_SECRET_MISSING, + ); + // Positive control: the same door still refuses a flow the family + // proves dead, so the absence above is the wall, not a gate that ran + // nothing. + const dead = runRuntimeAuthoringRules({ + type: 'flow', + item: { + name: 'declared_dead', + type: 'record_change', + status: 'active', + nodes: [{ id: 'start', type: 'start', config: { objectName: 'app_candidate', triggerType: 'onCreate' } }], + }, + }); + expect(dead.errors.map((f) => f.rule)).toContain(FLOW_TRIGGER_UNROUTABLE); + }); + }); }); // ── #5762 — the family's severity map ──────────────────────────────────── @@ -1297,7 +1372,7 @@ describe('validateFlowTriggerReadiness', () => { for (const [rule, severity, stack] of provoke) { it(`${rule} is ${severity}`, () => { - const matching = validateFlowTriggerReadiness(stack).filter((f) => f.rule === rule); + const matching = cliFlowFamily(stack).filter((f) => f.rule === rule); // Non-vacuous first: the fixture really does provoke this id. expect(matching.length, `${rule} was not provoked by its own fixture`).toBeGreaterThan(0); for (const f of matching) expect(f.severity).toBe(severity); @@ -1310,7 +1385,7 @@ describe('validateFlowTriggerReadiness', () => { // makes a gate read as a bug — and the cross-package sentence is exactly // what earns `flow-trigger-unknown-object` its warning. for (const [rule, severity, stack] of provoke) { - for (const f of validateFlowTriggerReadiness(stack).filter((x) => x.rule === rule)) { + for (const f of cliFlowFamily(stack).filter((x) => x.rule === rule)) { if (severity === 'warning' && rule === FLOW_TRIGGER_UNKNOWN_OBJECT) { expect(f.hint, rule).toMatch(/another installed package/); } @@ -1327,7 +1402,7 @@ describe('validateFlowTriggerReadiness', () => { // correct flow fail. Without this, "everything is an error" would pass // every assertion above. expect( - validateFlowTriggerReadiness({ + cliFlowFamily({ objects: [candidateObject, { name: 'task', label: 'Task', fields: {} }], flows: [ recordFlow({ status: 'active' }), diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index 4b53700dcbc..13d9569c0a5 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -55,8 +55,11 @@ // usable `config.secret` (ADR-0041). The automation engine refuses such a // flow at REGISTRATION, whatever its `status`, and the trigger refuses to // arm it — yet `os validate` never builds the engine, so until this rule -// it answered "passed" for a flow no runtime will ever register. See 1h -// for why the judgement is carried here rather than read from the runtime. +// it answered "passed" for a flow no runtime will ever register. It is its +// OWN exported rule, `validateFlowApiTriggerSecret` (1h, at the foot of +// this file), because it runs on the CLI surface only until #20611 — see +// its docblock for why, and see `FLOW_API_TRIGGER_SECRET_MISSING` for why +// the judgement is carried here rather than read from the runtime. // // ⚠️ One more rule lived here and is RETIRED (#17396): // `flow-schedule-organization-missing`, a `warning` on a time-triggered @@ -136,6 +139,14 @@ // never refused because stored flow B is dead, and a tenant's existing dead // flows keep being served. What IS refused is the dead flow's own publish — and, // on the CLI surface, a package build whose stack contains one. +// +// ⚠️ All of the above is about `validateFlowTriggerReadiness`. The sixth id, +// `flow-api-trigger-secret-missing`, lives in `validateFlowApiTriggerSecret` on +// its own registry entry, which is CLI-only until #20611: the publish gate +// judges a `/meta` save before the stored `config.secret` the read path withheld +// is restored, so at that door a signed flow's round trip would read as +// secretless. That door still stores a secretless flow today, and the engine +// refuses it at registration. import { TimeRelativeTriggerSchema, @@ -313,6 +324,20 @@ function describeUnusableSecret(start: { node: AnyRec } | undefined, config: Any return `its start node's config.secret is ${kind}, not a string`; } +/** + * The array-form record `triggerType` — `['record-after-create', …]`, any + * `record-` element. The ONE spelling of this predicate in the file: 1d reports + * the shape, and {@link validateFlowApiTriggerSecret} excludes it from the `api` + * binding exactly as the engine's `deriveTriggerBinding` pre-check does (it + * routes the shape to the record-change trigger before asking for a kind). + */ +function isArrayRecordTriggerType(config: AnyRec): boolean { + return ( + Array.isArray(config.triggerType) && + (config.triggerType as unknown[]).some((t) => typeof t === 'string' && t.startsWith('record-')) + ); +} + /** The start node of a flow definition, if any. */ function startNodeOf(flow: AnyRec): { node: AnyRec; index: number } | undefined { const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : []; @@ -350,9 +375,7 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines // detection because a non-string triggerType folds to `undefined` above, so the // runtime misclassifies the flow as manual and it never fires with zero output // (#3481). Any record-* element is enough to recognize the (unsupported) intent. - const isArrayRecordTriggered = - Array.isArray(config.triggerType) && - (config.triggerType as unknown[]).some((t) => typeof t === 'string' && t.startsWith('record-')); + const isArrayRecordTriggered = isArrayRecordTriggerType(config); const isTimeRelative = config.timeRelative != null && typeof config.timeRelative === 'object'; // The auto-triggered predicate is the spec's `resolveFlowTriggerKind`: the // same start-node reads this rule makes above, in the engine's precedence, @@ -362,8 +385,7 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines // answers a kind exactly when one of those terms held; the array-form // record trigger (1d's subject) never counted here and resolves to no kind // there either. - const triggerKind = resolveFlowTriggerKind(flow); - const isAutoTriggered = triggerKind !== undefined; + const isAutoTriggered = resolveFlowTriggerKind(flow) !== undefined; // 1. Record-triggered flow targeting an object this stack does not define. if (isRecordTriggered && start) { @@ -749,64 +771,10 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines // diagnostic, it is the diagnostic moving to where the question is // answerable. - // 1h. #20553 — a flow bound to the inbound `api` trigger with no usable - // `config.secret` (ADR-0041). The engine refuses it in `registerFlow` - // (the `/automation` write doors answer 400; a boot skips it with a - // warning) and `ApiTrigger.start()` refuses to arm it; `os validate`, - // which builds neither, said nothing. - // - // WHICH flows are `api`-bound is the ENGINE's answer, not a reading of - // `type`: `deriveTriggerBinding` first routes an array-form record - // `triggerType` to the record-change trigger (1d's shape), and otherwise - // takes `resolveFlowTriggerKind`'s kind — the spec export this file - // already reads above. `bindsApiTrigger` is those two steps, in that - // order, and nothing else. ⛔ Not the `flow.type === 'api' || - // triggerType === 'api'` disjunction 1f uses for "routes SOMEWHERE": the - // resolver ranks record / time-relative / schedule ahead of `api`, so a - // `type: 'api'` flow whose start node also carries a `record-*` token, a - // `timeRelative` descriptor or a `config.schedule` is bound to THAT - // trigger, and the engine never asks it for a secret. Measured on the - // built engine: those five precedence shapes (with the array form, and - // `type: 'schedule'` beside `triggerType: 'api'`) all register; the - // disjunction would have refused every one. - // - // `status` is deliberately not read: the engine refuses an `obsolete` - // flow too (a refused flow is never stored at all), so a secretless - // disabled flow still fails its own registration. A flow with NO start - // node is judged as well — the engine reads its `config` as `{}` and - // refuses it for the same reason — and is located at `nodes`, since - // there is no start node to point at. - const bindsApiTrigger = !isArrayRecordTriggered && triggerKind === 'api'; - const secretProblem = bindsApiTrigger ? describeUnusableSecret(start, config) : undefined; - if (secretProblem) { - // Which declaration binds it — the engine's own message names the same - // two, so an author who meets both channels reads one story. - const binds = [ - flow.type === 'api' ? `type: 'api'` : undefined, - triggerType === 'api' ? `start-node triggerType: 'api'` : undefined, - ].filter((s): s is string => s !== undefined); - findings.push({ - // `error` — the never-fire family, and the strongest verdict in it: the - // engine does not merely leave this flow unfired, it refuses to - // register it, in a hardcoded check no installed package can reach - // (see the Severity section above). - severity: 'error', - rule: FLOW_API_TRIGGER_SECRET_MISSING, - where: start ? `flow "${flowName}" › start node` : `flow "${flowName}"`, - path: start - ? `flows[${flowIndex}].nodes[${start.index}].config.secret` - : `flows[${flowIndex}].nodes`, - message: - `binds the inbound api trigger (${binds.join(' and ')}) but ${secretProblem}. An inbound hook ` + - `is armed only with a per-flow secret that every post is HMAC-verified against (ADR-0041), so the ` + - `automation engine refuses to register this flow, whatever its status, and it never receives a post.`, - hint: - `Set a non-blank string config.secret on the start node, and sign each post with it: the ` + - `x-objectstack-signature header carries 'sha256=' and the hex HMAC-SHA256 of the raw body. A flow that is ` + - `only ever started explicitly and never receives inbound posts is type: 'autolaunched', with no ` + - `triggerType: 'api' on its start node — that flow needs no secret.`, - }); - } + // 1h. ⚠️ NOT here — the `api` trigger's secret (#20553) is its own exported + // rule, {@link validateFlowApiTriggerSecret} below, on its own registry + // entry: it sits on the OTHER side of the runtime wall (CLI-only until + // #20611), and one rule id sits on ONE side of it. // 2. Auto-triggered flow whose status is 'draft' — authored or defaulted // (defineFlow parses at definition time, so the two are the same here). @@ -826,3 +794,89 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines return findings; } + +/** + * 1h. #20553 — a flow bound to the inbound `api` trigger with no usable + * `config.secret` (ADR-0041). The engine refuses it in `registerFlow` (the + * `/automation` write doors answer 400; a boot skips it with a warning) and + * `ApiTrigger.start()` refuses to arm it; `os validate`, which builds neither, + * said nothing. + * + * WHICH flows are `api`-bound is the ENGINE's answer, not a reading of `type`: + * `deriveTriggerBinding` first routes an array-form record `triggerType` to the + * record-change trigger (1d's shape), and otherwise takes + * `resolveFlowTriggerKind`'s kind — the spec export this file already reads. + * `bindsApiTrigger` is those two steps, in that order, and nothing else. + * ⛔ Not the `flow.type === 'api' || triggerType === 'api'` disjunction 1f uses + * for "routes SOMEWHERE": the resolver ranks record / time-relative / schedule + * ahead of `api`, so a `type: 'api'` flow whose start node also carries a + * `record-*` token, a `timeRelative` descriptor or a `config.schedule` is bound + * to THAT trigger, and the engine never asks it for a secret. Measured on the + * built engine: those five precedence shapes (with the array form, and + * `type: 'schedule'` beside `triggerType: 'api'`) all register; the disjunction + * would have refused every one. + * + * `status` is deliberately not read: the engine refuses an `obsolete` flow too + * (a refused flow is never stored at all), so a secretless disabled flow still + * fails its own registration. A flow with NO start node is judged as well — the + * engine reads its `config` as `{}` and refuses it for the same reason — and is + * located at `nodes`, since there is no start node to point at. + * + * ## Why this is a separate function from {@link validateFlowTriggerReadiness} + * + * A surface boundary, not taste — the registry's `validateSecurityRoleWord` + * split is the precedent. `validateFlowTriggerReadiness` runs on the runtime + * publish gate too; this rule cannot, yet (#20611). The flow read path withholds + * `config.secret` from every served definition (#20552), and `saveMetaItem` + * restores the stored secret only just before the put — AFTER the runtime + * authoring gate has judged the body the caller sent. So an ordinary `/meta` + * GET → edit → PUT of a SIGNED flow reaches the gate secretless, and this rule + * would refuse a save that keeps the secret. Measured on `825c33ff9f`: with this + * id at the gate, `protocol.metadata-redaction.test.ts` fails exactly its two + * round-trip pins; with it dropped there, 26/26 pass. So this id stays CLI-only + * (`os validate` / `os build` / `os lint`, whose stacks carry the author's own + * secret) until the gate judges the carried-forward body, and it is split out + * WHOLE rather than filtered at one entry: one rule id sits on ONE side of the + * wall. Meanwhile the `/meta` door behaves as it did before this rule existed: + * it stores a secretless flow, and the engine refuses it at registration. + */ +export function validateFlowApiTriggerSecret(stack: AnyRec): FlowTriggerReadinessFinding[] { + const findings: FlowTriggerReadinessFinding[] = []; + recordsOf(stack.flows).forEach((flow, flowIndex) => { + const flowName = typeof flow.name === 'string' ? flow.name : `#${flowIndex}`; + const start = startNodeOf(flow); + const config = (start?.node.config ?? {}) as AnyRec; + const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined; + const bindsApiTrigger = !isArrayRecordTriggerType(config) && resolveFlowTriggerKind(flow) === 'api'; + const secretProblem = bindsApiTrigger ? describeUnusableSecret(start, config) : undefined; + if (!secretProblem) return; + // Which declaration binds it — the engine's own message names the same + // two, so an author who meets both channels reads one story. + const binds = [ + flow.type === 'api' ? `type: 'api'` : undefined, + triggerType === 'api' ? `start-node triggerType: 'api'` : undefined, + ].filter((s): s is string => s !== undefined); + findings.push({ + // `error` — the never-fire family, and the strongest verdict in it: the + // engine does not merely leave this flow unfired, it refuses to register + // it, in a hardcoded check no installed package can reach (see the + // Severity section at the top of this file). + severity: 'error', + rule: FLOW_API_TRIGGER_SECRET_MISSING, + where: start ? `flow "${flowName}" › start node` : `flow "${flowName}"`, + path: start + ? `flows[${flowIndex}].nodes[${start.index}].config.secret` + : `flows[${flowIndex}].nodes`, + message: + `binds the inbound api trigger (${binds.join(' and ')}) but ${secretProblem}. An inbound hook ` + + `is armed only with a per-flow secret that every post is HMAC-verified against (ADR-0041), so the ` + + `automation engine refuses to register this flow, whatever its status, and it never receives a post.`, + hint: + `Set a non-blank string config.secret on the start node, and sign each post with it: the ` + + `x-objectstack-signature header carries 'sha256=' and the hex HMAC-SHA256 of the raw body. A flow that is ` + + `only ever started explicitly and never receives inbound posts is type: 'autolaunched', with no ` + + `triggerType: 'api' on its start node — that flow needs no secret.`, + }); + }); + return findings; +} From 18d5ba2153d6e2dfbd75543e941acefe0958ce45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:34:02 +0000 Subject: [PATCH 4/5] chore(changeset): narrow the api-secret changeset to the three CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Clause-② line matches the PR body again (os validate / os build / os lint only). The publish-gate sentences go, replaced by one saying the gate is deliberately not covered yet. The Why paragraph and the ADR-0087 reason name 17.5.0, the release whose published changelog carries the engine's registration refusal, instead of "the same release". Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- .changeset/20553-validate-api-flow-secret.md | 30 ++++++++------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/.changeset/20553-validate-api-flow-secret.md b/.changeset/20553-validate-api-flow-secret.md index 506d2f86ec7..b432ee2fbfa 100644 --- a/.changeset/20553-validate-api-flow-secret.md +++ b/.changeset/20553-validate-api-flow-secret.md @@ -2,36 +2,30 @@ '@objectstack/lint': minor --- -`os validate` refuses an `api` flow with no per-flow secret, the flow the automation engine already refuses to register (#20553). +`os validate`, `os build` and `os lint` refuse an `api` flow with no per-flow secret, the flow the automation engine already refuses to register (#20553). -Clause-②: yes (narrowing — `os validate` / `os build` / `os lint` and the runtime metadata publish gate newly refuse a secretless `api`-bound flow; the new exported rule id `FLOW_API_TRIGGER_SECRET_MISSING` widens `@objectstack/lint`) +Clause-②: yes (narrowing — `os validate` / `os build` / `os lint` newly refuse a secretless `api`-bound flow; the new exported rule id `FLOW_API_TRIGGER_SECRET_MISSING` widens `@objectstack/lint`) - + -**BREAKING** — an accept-set narrowing at two authoring doors, shipped as +**BREAKING** — an accept-set narrowing on three authoring commands, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition above, not by the level). A stack that declares an `api`-bound flow whose start node carries no usable `config.secret` used to pass `os validate`, -`os build` and `os lint`; they now exit non-zero and name the flow. The runtime -metadata publish gate used to pass a `state: 'active'` write of such a flow; it -now refuses it before the flow is stored. **One-line fix:** set a non-blank -`config.secret` on the flow's start node — or, for a flow that is only ever -started explicitly, declare `type: 'autolaunched'` with no `triggerType: 'api'`. +`os build` and `os lint`; they now exit non-zero and name the flow. +**One-line fix:** set a non-blank `config.secret` on the flow's start node — or, +for a flow that is only ever started explicitly, declare `type: 'autolaunched'` +with no `triggerType: 'api'`. -`validate-flow-trigger-readiness` gains one rule id, `flow-api-trigger-secret-missing`, at `error`. It names a flow whose binding resolves to the inbound `api` trigger when that flow's start node carries no usable `config.secret`. A usable secret is a string that is non-empty after trimming. The rule fires for a missing, blank or non-string secret, and for an `api` flow with no start node. +`@objectstack/lint` gains one rule id, `flow-api-trigger-secret-missing`, at `error`, emitted by a new exported rule, `validateFlowApiTriggerSecret`, in the `validate-flow-trigger-readiness` family. It names a flow whose binding resolves to the inbound `api` trigger when that flow's start node carries no usable `config.secret`. A usable secret is a string that is non-empty after trimming. The rule fires for a missing, blank or non-string secret, and for an `api` flow with no start node. -**Why.** ADR-0041's `trigger-api` acceptance criteria require a per-flow secret with HMAC verification. In the same release, the automation engine refuses such a flow in `registerFlow`, whatever its `status`: the `/automation` write doors answer `400`, and a boot skips the flow with a warning. `ApiTrigger.start()` also refuses to arm it. `os validate` builds neither, so it answered `✓ Validation passed` for a flow no runtime would register. It now exits non-zero and names the flow. +**Why.** ADR-0041's `trigger-api` acceptance criteria require a per-flow secret with HMAC verification. Since 17.5.0 the automation engine refuses such a flow in `registerFlow`, whatever its `status`: the `/automation` write doors answer `400`, and a boot skips the flow with a warning. `ApiTrigger.start()` also refuses to arm it. `os validate` builds neither, so it answered `✓ Validation passed` for a flow no runtime would register. It now exits non-zero and names the flow. **Which flows count as `api`-bound.** The rule uses the engine's own binding, `deriveTriggerBinding`. An array-form record `triggerType` goes to the record-change trigger first. Otherwise the flow gets the kind `resolveFlowTriggerKind` answers, which is a flow declaring `type: 'api'` or a start-node `triggerType: 'api'`. The engine gives the record-change, time-relative and schedule triggers precedence over `api`. So a `type: 'api'` flow whose start node also carries a `record-*` token, a `timeRelative` descriptor or a `config.schedule` binds that other trigger. The engine never asks that flow for a secret, and the rule stays silent on it. -**Where the refusal surfaces.** The rule is `CLI_AND_RUNTIME`, so it gates in two places: - -- `os validate`, `os build` and `os lint`. -- The runtime metadata publish gate. A `state: 'active'` write of a secretless `api` flow now carries this `error`, so the gate refuses it before the flow is stored. Before this change the gate passed that write. - -The gate judges only the item being written, so existing stored flows are not re-judged. +**Where the refusal surfaces.** `os validate`, `os build` and `os lint`. The runtime metadata publish gate is deliberately not covered yet (#20611): it judges a `/meta` save before the stored secret the flow read path withholds is restored, so for now a secretless flow saved there is still stored, and the engine then refuses it at registration. **Fix.** Set a non-blank `config.secret` on the flow's start node, and sign each post with it in the `x-objectstack-signature` header. A flow that is only ever started explicitly and never receives inbound posts is `type: 'autolaunched'`, with no `triggerType: 'api'` on its start node, and it needs no secret. -`FLOW_API_TRIGGER_SECRET_MISSING` is exported from `@objectstack/lint`. +`validateFlowApiTriggerSecret` and `FLOW_API_TRIGGER_SECRET_MISSING` are exported from `@objectstack/lint`. From afa9e266fd9729c6a12cd3bea1a348da10379ef2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 07:41:33 +0000 Subject: [PATCH 5/5] docs: the os validate / os build transcripts quote 47 author-time rules The CLI-only validateFlowApiTriggerSecret entry takes the registry from 46 to 47 rules for every command, and check:docs-transcript-drift holds each pasted `Running author-time rules (N)...` line to authoringRulesFor(cmd). Exactly the four quoted lines change. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude --- content/docs/deployment/cli.mdx | 2 +- content/docs/deployment/validating-metadata.mdx | 2 +- content/docs/getting-started/build-with-claude-code.mdx | 2 +- content/docs/ui/react-pages.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index f90159de36c..97a3c8f5feb 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -604,7 +604,7 @@ os compile --json # JSON output for CI pipelines → Normalizing stack definition... → Lowering inline handlers... → Validating protocol compliance... - → Running author-time rules (46)... + → Running author-time rules (47)... → Checking capability providers (#3366)... → Collecting package docs (ADR-0046)... 0 collected → Writing artifact... diff --git a/content/docs/deployment/validating-metadata.mdx b/content/docs/deployment/validating-metadata.mdx index f0bc54f865e..7e9bae20bfc 100644 --- a/content/docs/deployment/validating-metadata.mdx +++ b/content/docs/deployment/validating-metadata.mdx @@ -679,7 +679,7 @@ A clean run walks the registry and reports timing: Config: /path/to/support-desk/objectstack.config.ts Load time: 21ms → Validating against ObjectStack Protocol... - → Running author-time rules (46)... + → Running author-time rules (47)... → Checking capability providers (#3366)... → Checking package docs (ADR-0046)... diff --git a/content/docs/getting-started/build-with-claude-code.mdx b/content/docs/getting-started/build-with-claude-code.mdx index 1640287c80b..eda60f85e67 100644 --- a/content/docs/getting-started/build-with-claude-code.mdx +++ b/content/docs/getting-started/build-with-claude-code.mdx @@ -270,7 +270,7 @@ visible: 'status != "resolved"' ◆ Validate ──────────────────────────────────────── → Validating against ObjectStack Protocol... - → Running author-time rules (46)... + → Running author-time rules (47)... ✗ Author-time rules failed (1 issue) • stack · action 'resolve_ticket' visible: bare reference `status` — a diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index 4b77ffa6bc4..8e424d6ebc7 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -382,7 +382,7 @@ objectstack validate ──────────────────────────────────────── → Loading configuration... → Validating against ObjectStack Protocol... - → Running author-time rules (46)... + → Running author-time rules (47)... → Checking capability providers (#3366)... → Checking package docs (ADR-0046)...