Skip to content

Commit e651556

Browse files
feat(lint): os validate refuses an api flow with no per-flow secret (#20593)
Fixes #20553 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`) This PR is the `os validate` half of the card. Triage split the skill half out to #20569, which stays open and is not addressed here. ## What this changes `packages/lint/src/validate-flow-trigger-readiness.ts` gains one rule id, `flow-api-trigger-secret-missing`, at severity `error`. It names a flow bound to the inbound `api` trigger when the flow's start node carries no usable `config.secret`. - **Usable secret.** This is the runtime's judgement, read the same way: a string that is non-empty after `trim()`. The rule fires for a missing, blank or non-string secret. It also fires for an `api` flow with no start node, because the engine reads that flow's `config` as `{}` and refuses it too. That finding is located at `flows[i].nodes`. - **`status` is not read.** The engine refuses an `obsolete` flow as well. - **What the finding says.** It names the flow, the declaration that binds it (`type: 'api'` and/or a start-node `triggerType: 'api'`) and what is wrong with the secret. It gives the type of a bad value only, never the value, because findings travel into CI logs (and, once #20611 moves the rule onto the runtime publish gate, into that gate's responses). - **What the hint says.** It prescribes a non-blank `config.secret` plus signing with `x-objectstack-signature`. For a flow that is only ever started explicitly, it prescribes `type: 'autolaunched'` with no `triggerType: 'api'`. - **Severity: `error`, in the file's never-fire family.** The question the family's Severity section asks is whether this stack alone is enough to know the flow is dead. Here the verdict is `registerFlow`'s own hardcoded refusal, which runs before any trigger is consulted. So I measured the "installing something fixes it" hypothesis, and it is false. An engine with a registered `api` trigger that would arm anything still refused every secretless shape below. - **Rule id.** The name follows the file's `flow-DESCRIPTOR-VERDICT` convention: the descriptor is the `api` trigger's secret, and the verdict is "missing". - **Gate-required edits.** - `index.ts` re-exports `validateFlowApiTriggerSecret` and `FLOW_API_TRIGGER_SECRET_MISSING`. `rule-id-barrel-exports.test.ts` requires every rule id to be reachable from a published barrel, and the wiring guard requires every exported rule to be registered. - `authoring-rules.ts` gains the registry entry `validateFlowApiTriggerSecret` (`tier: 'gating'`, all three commands, `surfaces: CLI_ONLY` with a `surfaceReason`) and updates its family comment, which said "Four rules answer yes and emit `error`". - Four `content/docs` CLI transcripts quote `Running author-time rules (N)...`. The new entry takes the registry from 46 to 47, and `check:docs-transcript-drift` holds each quote to `authoringRulesFor(cmd)`, so exactly those four lines move to 47. - **Changeset.** `.changeset/20553-validate-api-flow-secret.md` bumps `@objectstack/lint` `minor`. ### Which flows are `api`-bound: the engine's binding, not a reading of `type` `bindsApiTrigger` is the engine's `deriveTriggerBinding`, in its own order: 1. The array-form record `triggerType` pre-check. This is the same predicate as this file's `isArrayRecordTriggered`. 2. Otherwise `resolveFlowTriggerKind(flow) === 'api'`. This is the spec export the file already reads. I measured it on the built `AutomationEngine.registerFlow` at `f11b5f20a2`, with a scratch script (deleted afterwards) and a recording trigger registered for each kind. The script compared the engine against two candidate derivations over 15 shapes: | shape | engine | this rule's derivation | `type === 'api' OR triggerType === 'api'` | |---|---|---|---| | `type: 'api'`, no / blank / non-string secret | refused | bound | bound | | `type: 'api'`, secret | registered, `api` started | bound (passes) | bound | | `autolaunched` / `screen` / `record_change` + `triggerType: 'api'`, no secret | refused | bound | bound | | `type: 'api'` + scalar `timeRelative: 'daily'` | refused | bound | bound | | `type: 'api'`, `obsolete`, no secret | refused | bound | bound | | `autolaunched`, neither | registered | not bound | not bound | | `type: 'api'` + `config.schedule` | registered (no secret asked) | not bound | **bound** | | `type: 'schedule'` + `triggerType: 'api'` | registered (no secret asked) | not bound | **bound** | | `type: 'api'` + `record-after-create` | registered, `record_change` started | not bound | **bound** | | `type: 'api'` + array record token | registered, `record_change` started | not bound | **bound** | | `type: 'api'` + `timeRelative` object | registered (no secret asked) | not bound | **bound** | The composed derivation agrees with the engine on all 15 shapes. The disjunction disagrees on the 5 bold precedence shapes, and would refuse flows the engine registers. A start-less `type: 'api'` flow was measured separately: the engine refused it with the secret error. ### Why the rule carries the check instead of reading the runtime's - **Two runtime copies.** The judgement lives in `AutomationEngine.validateApiTriggerSecret`, a private method in `packages/services/service-automation/src/engine.ts` called from `registerFlow`. It also lives inline in `ApiTrigger.start()` in `packages/triggers/trigger-api/src/api-trigger.ts`. - **No spec predicate.** I searched for one, and `@objectstack/spec` exports none for the secret. The only spec hits are outbound-webhook signing keys. The spec does export the kind half, `resolveFlowTriggerKind`, and the rule reads it. - **Dependency direction.** This package depends on `@objectstack/spec` only, never on a runtime. - **So the rule carries the one-line judgement.** The new rule id's docblock names both runtime copies and says why neither can be read from here. ## Verification record (HEAD `afa9e266fd`; the premise, corpus and first two ablations were measured at `29caa84eb3`) **Round 3, at `afa9e266fd` — the rule is CLI-only until #20611.** `flow-api-trigger-secret-missing` moved into its own exported rule, `validateFlowApiTriggerSecret`, on its own `CLI_ONLY` registry entry. `@objectstack/lint`: 115 files, 5379 passed; typecheck exit 0. `@objectstack/metadata-protocol`: 189 files passed, 3 skipped (2768 tests passed, 19 skipped), including #20552's two round-trip pins in `protocol.metadata-redaction.test.ts`, which failed while the id sat on the runtime gate. `service-automation` (7 files, 45), `metadata-service` (72) and runtime `automation-flow-credential-projection` (7) pass. CLI consumers (36 files): unit 12/257, integration 6/82 + 6/42, nightly `.e2e` 6/70 + 6/41. The built CLI prints "Running author-time rules (47)": a secretless probe exits 1 with the finding, a signed one exits 0. `dispatch-gates` derived 89 commands, all exit 0 (two answered PREREQUISITE NOT MET first and passed after building what they named); `--ran`: 89 derived, 89 run, 0 NOT-MEASURED. **Premise, measured first, at `origin/main` `f11b5f20a2` (unmodified tree).** - **Card's re-check.** `git grep -c secret -- packages/lint/src/validate-flow-trigger-readiness.ts` gave no output with exit 1, i.e. 0 hits. The lit control `git grep -c triggerType` on the same file answered 39. - **Instrument.** The built CLI, `node packages/cli/bin/run.js validate objectstack.config.ts`. I ran it on a throwaway stack, deleted afterwards, under `examples/app-showcase/.probe-20553/`. The stack had `requires: ['automation', 'triggers', 'queue']` and one flow: `type: 'api'`, `status: 'active'`, `runAs: 'system'`, start `config: { hookId: 'intake' }`. - **Before.** The CLI printed "Running author-time rules (46)" and `✓ Validation passed`, exit 0. A start-less variant also passed, exit 0. - **After, at `29caa84eb3`.** - The same stack gave `✗ Author-time rules failed (1 issue)`, `rule: flow-api-trigger-secret-missing at flows[0].nodes[0].config.secret`, exit 1. - The start-less variant exited 1, at `flows[0].nodes`. - The same stack with `secret: 'whsec_probe'` gave `✓ Validation passed`, exit 0. - **Runtime publish gate, at `afa9e266fd`.** The rule's own registry entry is `surfaces: CLI_ONLY`, so the gate does not reach it. `runRuntimeAuthoringRules({ type: 'flow', item })` from the built `@objectstack/lint/runtime` gave `errors: []` for a secretless flow; `rulesRun` held `validateFlowTriggerReadiness` but not `validateFlowApiTriggerSecret`. At `29caa84eb3`, before the split, the same call gave `errors: [["flow-api-trigger-secret-missing","flows[0].nodes[0].config.secret"]]` — the behaviour that broke #20552's round-trip pins once #20552 landed. **Build.** - CLI closure: `turbo run build --filter='@objectstack/cli...' --concurrency=2`, 59/59 tasks. - `pnpm --filter @objectstack/lint build`: exit 0, and `check-dts-emitted` reported 4/4. - Showcase closure: `--filter='@objectstack/example-showcase^...'`, 60/60 tasks. - Every build went through `os-verify-lock.sh` and printed `VERDICT command-exit 0`. **Tests.** Counts at `afa9e266fd` unless marked. - `@objectstack/lint`, whole package: 115 files, **5379 passed** (5373 at `29caa84eb3`; the 6 added are the wall pins below). - The rule's file plus `rule-id-barrel-exports.test.ts` and `authoring-rule-wiring.test.ts`: 117 passed. - New cases: - a secretless `type: 'api'` flow fails, checked exhaustively on rule id, severity, `where` and `path`; - the same flow with a secret passes; - an `autolaunched` flow passes; - a start-node `triggerType: 'api'` on `autolaunched`, `screen` and `record_change` flows is judged like `type: 'api'`, and passes with a secret; - blank `' '`, `''` and a tab-newline secret fail, as do a number, boolean, null, array and object, and the value is never echoed; - a padded real secret passes; - `obsolete` and `draft` flows are judged; - a no-start-node flow is judged; - the five precedence shapes stay silent, and each is paired with the shape that fires; - the id slug is pinned. - one rule id on ONE side of the runtime wall: `validateFlowTriggerReadiness` alone no longer emits it; its registry entry is gating, on all three commands, `surfaces: ['cli']`, with a reason; `os validate` / `os build` / `os lint` each still refuse a secretless flow through the table; and the runtime gate emits no `flow-api-trigger-secret-missing` for it, with a positive control (the same gate still refuses a dead `record_change` flow with `flow-trigger-unroutable`). - The severity map's `provoke` table gains this id as `error`. The clean-stack floor gains a signed `api` flow. - **CLI consumer tests.** Every `@objectstack/cli` test that reaches the validate, build or lint rule table: 36 files at `afa9e266fd` (the merge of `main` added one). None was edited. - `unit` project: 12 files, 257 passed. - Nightly-tier `.e2e` files, run with `OS_TEST_TIERS=nightly`: 6 files / 70 passed, then 6 files / 41 passed. - `integration` project: 6 files / 82 passed, then 6 files / 42 passed. **Typecheck.** `pnpm --filter @objectstack/lint typecheck`: exit 0. - `tsc --noEmit` covers the rule file. - `check:test-typecheck` reported "OK, test layer compiles under tsconfig.test.json", and its debt is unchanged. `tsc --listFiles -p tsconfig.test.json` lists the test file. **Ablations.** The first two ran at `29caa84eb3` on the pre-split code, with `scripts/ablation-replace.mjs` in WRAP mode and an outer `trap` restoring the absolute path; the subject is imported from relative source, so no `dist/` is involved. The third ran at `825c33ff9f` through lint's built `dist/` (`metadata-protocol` → `@objectstack/lint` is a known unaliased pair): with the id dropped at the runtime surface only (marker proven in 4 `dist/` files), `protocol.metadata-redaction.test.ts` passed 26/26; restored (blob == HEAD `789b320b`, `git diff HEAD` empty, marker absent from all 14 `dist/` files), exactly its two round-trip pins failed again (2 failed / 24 passed). - **Ablation 1: the finding disabled.** The `if (secretProblem) {` anchor went from 1 hit to 0, and the blob moved from `4b53700d` to `46f09b8d`. - Result: **8 failed, 73 passed.** All 7 positive cases in the new block failed, plus the `provoke` row. The pass-controls stayed green. - Restored: the blob equals HEAD `4b53700d`, and `git diff HEAD` is empty. - **Ablation 2: the binding swapped for the `type OR triggerType` disjunction.** The anchor went from 1 hit to 0, and the blob moved from `4b53700d` to `341d0408`. - Result: **1 failed, 80 passed.** Exactly the precedence case failed, first at `api + config.schedule`. - Restored: the blob equals HEAD, and `git diff HEAD` is empty. **Gates.** - **Derivation, at `afa9e266fd`.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 89 commands (the four `content/docs` transcripts add 29 docs families). Each was run with its exit code captured before any pipe. - **Result.** All 89 exited 0. - `check:dual-build-cjs-loads` and `@objectstack/spec`'s `check:skill-examples` first answered `PREREQUISITE NOT MET`, exit 3. That is not a measurement. After building the packages they named, both exited 0. - **Reconciliation.** `--ran` gave "89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero, all 89 recorded an exit code)". - **Changeset level axis.** Locally this reads NOT APPLICABLE, because there is no PR payload. I drove it offline with an event file carrying this body's first two lines, and it answered: "this PR declares clause-② `yes (narrowing)`, and no package whose `packages/**/src/**` it moves is graded `patch`". - **`check-adr-0087-registration`.** "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition": `[BREAKING+clause-②-narrowing] not-required (no-migration-prescription)`. Its `--self-test`: 441 assertions. **Lint, narrowed and proven.** eslint `--no-inline-config --format json` over the 4 changed `.ts` files reported 4 files, 0 errors and 0 warnings. Three facts make that narrowing a measurement: - None of the files reported "File ignored". - The count comes from the JSON output. - `eslint.config.mjs` lines 327-328 state that the config never enables type-aware linting, so this diff cannot move an untouched file's verdict. The repo-wide `pnpm lint` is declared to CI. **Corpus sweep, at `29caa84eb3`.** - `os validate` over all 4 example stacks: `app-crm`, `app-multi-package`, `app-showcase` (after building its closure) and `app-todo`. All answered `✓ Validation passed`, exit 0, with 0 hits of the new id. - The repo's one `api`-bound example flow, `showcase_inbound_task_webhook`, carries `secret: 'showcase-webhook-secret'`. That secret predates PR #20551, which gave no example or fixture a secret. - CLI tests and fixtures declare no `api`-bound flow. A grep for `type`/`triggerType` `'api'` over `packages/cli/test` hit only the `os explain` type-enum doc, which is a `record_change` example. **Pin sweep.** - No test or doc asserts that `os validate` passes a secretless `api` flow. - No catalogue outside `packages/lint` lists this file's rule ids exhaustively. The one non-lint hit, `flow-trigger-kind.ts`, is a docblock mention. - `content/docs` has no "secret optional" line for the inbound trigger. The only hit is `webhooks.mdx` P3, which is about outbound webhooks. **Other checks.** - `grep -naP` for raw control bytes over the 9 changed files found nothing. - The branch merges `origin/main` at `c96beb2707` (#20552's landing, which surfaced the round-trip conflict) in merge commit `825c33ff9f`. `origin/main` has since moved to `7510663c87`; `dispatch-gates` reports none of those commits touched what its derivation reads. ## Acceptance notes - **Edits outside the original claim surface, admitted by the seat.** `authoring-rules.ts` gains the CLI-only `validateFlowApiTriggerSecret` entry (amended into the claim by the seat's fork ruling), and four `content/docs` transcripts move their quoted rule count from 46 to 47 (admitted as the registry's own quotation; nothing under `content/docs/releases/`). `index.ts` carries the barrel lines the rule-id barrel test and the wiring guard require. - **Claim/dispatch mechanism assumption corrected by measurement.** The claim calls lines `:507` and `:618` "the binding this rule already derives". - `:618` (`routesToSomeTrigger`) is a routes-anywhere disjunction. As an `api` derivation it disagrees with the engine on 5 shapes, per the table above. - `:507` is precedence-ordered, but it is reached only inside 1e. - The rule uses the engine's own two-step derivation instead. Ablation 2 shows the test holds that line. - **Clause-② arm.** The seat ruled `yes (narrowing)`: the new exported rule id widens `@objectstack/lint`, and `os validate` / `os build` / `os lint` newly refuse a stack they used to pass. The changeset carries the line byte-for-byte, a `**BREAKING**` banner (shipped `minor` under the launch-window convention) and the ADR-0087 disposition `not-required (no-migration-prescription)`. The engine's own refusal already shipped in 17.5.0 (PR #20551's published changelog entry). - **Publish gate: deliberately not covered yet (#20611).** `saveMetaItem` runs the runtime authoring gate (`protocol.ts:16352`) before it restores the stored secret the flow read path withholds (`:16588`, #20552), so on that gate a signed flow's GET → edit → PUT arrives secretless. With this id on the gate, `protocol.metadata-redaction.test.ts`'s two round-trip pins failed (measured at `825c33ff9f`; 26/26 with the id dropped there). The id therefore sits on its own `CLI_ONLY` registry entry until #20611 makes the gate judge the carried-forward body. Meanwhile the `/meta` door behaves as it did before this PR: it stores a secretless flow, and the engine refuses it at registration. The `/automation` write doors call `registerFlow` directly and never reach this gate, so their `400 VALIDATION_FAILED` answer is unchanged. - **Observation, not filed: the engine's wording.** `engine.ts` `validateApiTriggerSecret` answers "declares no `config.secret`" even when a non-string secret is present. The measured case was `secret: 12345`. Carrier: none now that #20552, which held `service-automation/src/**`, has landed. - **Observation, not filed: a test title.** The existing test "flags schedule and api flows for missing status too" builds only a `schedule` flow. Carrier: none. - **Not measured.** Whether the Studio flow designer (objectui) lets an author set `config.secret` on an `api` flow's start node. The sibling repo is not in this change. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 682873f commit e651556

9 files changed

Lines changed: 505 additions & 13 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
`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).
6+
7+
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`)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable changes spelling or type: `packages/spec` is untouched, and the start node `config` stays the open record it was. What changes is that three authoring commands, `os validate` / `os build` / `os lint`, now refuse one authored shape: an `api`-bound flow whose start node carries no usable `config.secret`. `objectstack migrate meta` could not rewrite that shape even in principle, because the missing value is a shared secret only the author and the sending system can supply. A stored flow of that shape has been refused at registration by `@objectstack/service-automation` since 17.5.0, whose published changelog entry records that load path's disposition; nothing here judges a stored row. -->
10+
11+
**BREAKING** — an accept-set narrowing on three authoring commands, shipped as
12+
`minor` under the launch-window convention (`check-changeset-no-major` refuses
13+
`major` until GA; breaking-ness is carried by this banner and the ADR-0087
14+
disposition above, not by the level). A stack that declares an `api`-bound flow
15+
whose start node carries no usable `config.secret` used to pass `os validate`,
16+
`os build` and `os lint`; they now exit non-zero and name the flow.
17+
**One-line fix:** set a non-blank `config.secret` on the flow's start node — or,
18+
for a flow that is only ever started explicitly, declare `type: 'autolaunched'`
19+
with no `triggerType: 'api'`.
20+
21+
`@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.
22+
23+
**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.
24+
25+
**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.
26+
27+
**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.
28+
29+
**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.
30+
31+
`validateFlowApiTriggerSecret` and `FLOW_API_TRIGGER_SECRET_MISSING` are exported from `@objectstack/lint`.

‎content/docs/deployment/cli.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ os compile --json # JSON output for CI pipelines
604604
→ Normalizing stack definition...
605605
→ Lowering inline handlers...
606606
→ Validating protocol compliance...
607-
→ Running author-time rules (46)...
607+
→ Running author-time rules (47)...
608608
→ Checking capability providers (#3366)...
609609
→ Collecting package docs (ADR-0046)... 0 collected
610610
→ Writing artifact...

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,7 @@ A clean run walks the registry and reports timing:
679679
Config: /path/to/support-desk/objectstack.config.ts
680680
Load time: 21ms
681681
→ Validating against ObjectStack Protocol...
682-
→ Running author-time rules (46)...
682+
→ Running author-time rules (47)...
683683
→ Checking capability providers (#3366)...
684684
→ Checking package docs (ADR-0046)...
685685

‎content/docs/getting-started/build-with-claude-code.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ visible: 'status != "resolved"'
270270
◆ Validate
271271
────────────────────────────────────────
272272
→ Validating against ObjectStack Protocol...
273-
→ Running author-time rules (46)...
273+
→ Running author-time rules (47)...
274274
275275
✗ Author-time rules failed (1 issue)
276276
• stack · action 'resolve_ticket' visible: bare reference `status` — a

‎content/docs/ui/react-pages.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ objectstack validate
382382
────────────────────────────────────────
383383
→ Loading configuration...
384384
→ Validating against ObjectStack Protocol...
385-
→ Running author-time rules (46)...
385+
→ Running author-time rules (47)...
386386
→ Checking capability providers (#3366)...
387387
→ Checking package docs (ADR-0046)...
388388

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,10 @@ import { validateJsxPages } from './validate-jsx-pages.js';
117117
import { validateReactPages } from './validate-react-pages.js';
118118
import { validatePageSourceStyling } from './validate-page-source-styling.js';
119119
import { validateCapabilityReferences } from './validate-capability-references.js';
120-
import { validateFlowTriggerReadiness } from './validate-flow-trigger-readiness.js';
120+
import {
121+
validateFlowApiTriggerSecret,
122+
validateFlowTriggerReadiness,
123+
} from './validate-flow-trigger-readiness.js';
121124
import { validateApprovalApprovers } from './validate-approval-approvers.js';
122125
import { validateRecordTitle } from './validate-record-title.js';
123126
import { validateFieldConsumers } from './validate-field-consumers.js';
@@ -1107,7 +1110,10 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
11071110
// predicate cannot route at all, a `record-*` triggerType outside the
11081111
// closed token grammar `triggerTypeToHookEvents` maps, and (#6637) a
11091112
// `type: 'record_change'` flow whose triggerType the engine's binding resolver
1110-
// routes nowhere, silently demoting it to a manual flow. None of those verdicts
1113+
// routes nowhere, silently demoting it to a manual flow. #20553 made it five: an
1114+
// `api`-bound flow with no usable `config.secret`, which the engine's own
1115+
// `registerFlow` refuses (ADR-0041) — on its OWN entry below
1116+
// (`validateFlowApiTriggerSecret`), because it is CLI-only for now. None of those verdicts
11111117
// can be changed by installing a package, so there is no reading under which
11121118
// the flow fires. `flow-trigger-unknown-object` deliberately stayed `warning`
11131119
// (the object may come from another installed package — a hedge this rule
@@ -1137,6 +1143,35 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
11371143
runtimeTypes: ['flow'],
11381144
run: (stack) => validateFlowTriggerReadiness(stack),
11391145
},
1146+
// #20553 — `flow-api-trigger-secret-missing`, split out of the entry above as
1147+
// its own exported rule (the `validateSecurityRoleWord` precedent: one rule id
1148+
// sits on ONE side of the runtime wall). Same family, same `error`, all three
1149+
// commands — but NOT the runtime publish gate yet, and #20611 is the card that
1150+
// moves it across. The flow read path withholds `config.secret` from every
1151+
// served definition (#20552) and `saveMetaItem` restores the stored secret only
1152+
// just before the put, AFTER this table has judged the body the caller sent —
1153+
// so on the gate, a signed flow's ordinary GET → edit → PUT reads as
1154+
// secretless. Measured on `825c33ff9f`: with this id on the gate, the two
1155+
// round-trip pins in `protocol.metadata-redaction.test.ts` fail; off it, 26/26.
1156+
// The `/meta` door therefore keeps its pre-rule behaviour (it stores a
1157+
// secretless flow, and the engine refuses it at registration) until the gate
1158+
// judges the carried-forward body — then this entry becomes `CLI_AND_RUNTIME`
1159+
// with `runtimeTypes: ['flow']`, like the one above.
1160+
{
1161+
name: 'validateFlowApiTriggerSecret',
1162+
tier: 'gating',
1163+
input: 'normalized',
1164+
commands: ALL,
1165+
source: 'packages/lint/src/validate-flow-trigger-readiness.ts',
1166+
surfaces: CLI_ONLY,
1167+
surfaceReason:
1168+
'Not yet runtime-safe: the publish gate judges a /meta save BEFORE saveMetaItem restores the ' +
1169+
'inbound-hook secret the flow read path withholds, so a signed api flow\'s ordinary GET, edit, PUT ' +
1170+
'round trip reaches this rule secretless and would be refused. It crosses when the gate judges the ' +
1171+
'carried-forward body (the seam follow-up named in the comment above); until then the engine\'s ' +
1172+
'registerFlow refusal is what a secretless flow saved through /meta meets.',
1173+
run: (stack) => validateFlowApiTriggerSecret(stack),
1174+
},
11401175
// ADR-0090 D3 fallout — an approval `{ type: 'role' }` resolves against the
11411176
// better-auth org-membership tier, not positions, so a position name authored
11421177
// there routes the approval to nobody; and an expression approver that does

‎packages/lint/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,14 @@ export type { ManagedApiMethodFinding } from './validate-managed-api-methods.js'
126126
export type { ListViewModeFinding, ListViewModeSeverity } from './validate-list-view-mode.js';
127127
export {
128128
validateFlowTriggerReadiness,
129+
validateFlowApiTriggerSecret,
129130
FLOW_TRIGGER_UNKNOWN_OBJECT,
130131
FLOW_DRAFT_STATUS_AMBIGUOUS,
131132
FLOW_TRIGGER_UNKNOWN_EVENT,
132133
FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
133134
FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
134135
FLOW_TRIGGER_UNROUTABLE,
136+
FLOW_API_TRIGGER_SECRET_MISSING,
135137
} from './validate-flow-trigger-readiness.js';
136138
export type {
137139
FlowTriggerReadinessFinding,

0 commit comments

Comments
 (0)