Repository navigation
Commit e651556
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
- .changeset
- content/docs
- deployment
- getting-started
- ui
- packages/lint/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
604 | 604 | | |
605 | 605 | | |
606 | 606 | | |
607 | | - | |
| 607 | + | |
608 | 608 | | |
609 | 609 | | |
610 | 610 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
679 | 679 | | |
680 | 680 | | |
681 | 681 | | |
682 | | - | |
| 682 | + | |
683 | 683 | | |
684 | 684 | | |
685 | 685 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
270 | 270 | | |
271 | 271 | | |
272 | 272 | | |
273 | | - | |
| 273 | + | |
274 | 274 | | |
275 | 275 | | |
276 | 276 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
382 | 382 | | |
383 | 383 | | |
384 | 384 | | |
385 | | - | |
| 385 | + | |
386 | 386 | | |
387 | 387 | | |
388 | 388 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
117 | 117 | | |
118 | 118 | | |
119 | 119 | | |
120 | | - | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
121 | 124 | | |
122 | 125 | | |
123 | 126 | | |
| |||
1107 | 1110 | | |
1108 | 1111 | | |
1109 | 1112 | | |
1110 | | - | |
| 1113 | + | |
| 1114 | + | |
| 1115 | + | |
| 1116 | + | |
1111 | 1117 | | |
1112 | 1118 | | |
1113 | 1119 | | |
| |||
1137 | 1143 | | |
1138 | 1144 | | |
1139 | 1145 | | |
| 1146 | + | |
| 1147 | + | |
| 1148 | + | |
| 1149 | + | |
| 1150 | + | |
| 1151 | + | |
| 1152 | + | |
| 1153 | + | |
| 1154 | + | |
| 1155 | + | |
| 1156 | + | |
| 1157 | + | |
| 1158 | + | |
| 1159 | + | |
| 1160 | + | |
| 1161 | + | |
| 1162 | + | |
| 1163 | + | |
| 1164 | + | |
| 1165 | + | |
| 1166 | + | |
| 1167 | + | |
| 1168 | + | |
| 1169 | + | |
| 1170 | + | |
| 1171 | + | |
| 1172 | + | |
| 1173 | + | |
| 1174 | + | |
1140 | 1175 | | |
1141 | 1176 | | |
1142 | 1177 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
126 | 126 | | |
127 | 127 | | |
128 | 128 | | |
| 129 | + | |
129 | 130 | | |
130 | 131 | | |
131 | 132 | | |
132 | 133 | | |
133 | 134 | | |
134 | 135 | | |
| 136 | + | |
135 | 137 | | |
136 | 138 | | |
137 | 139 | | |
| |||
0 commit comments