Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/20553-validate-api-flow-secret.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/lint': minor
---

`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` newly refuse a secretless `api`-bound flow; the new exported rule id `FLOW_API_TRIGGER_SECRET_MISSING` widens `@objectstack/lint`)

<!-- 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. -->

**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.
**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'`.

`@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. 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.** `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.

`validateFlowApiTriggerSecret` and `FLOW_API_TRIGGER_SECRET_MISSING` are exported from `@objectstack/lint`.
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)...

Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)...

Expand Down
39 changes: 37 additions & 2 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1107,7 +1110,10 @@ 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) — 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
Expand Down Expand Up @@ -1137,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
Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,14 @@ 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,
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,
Expand Down
Loading
Loading