Repository navigation
fix(lint,metadata-protocol)!: /meta refuses a secretless api flow, and the runtime authoring gate reads a withheld-and-stored secret as present (#20611) - #20692
Conversation
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 34fbb9c3a1b913062063c28bb4013d21e2bd0ac8 && git checkout 34fbb9c3a1b913062063c28bb4013d21e2bd0ac8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 14f80e23957165f6fb23c2b3d59bdc7668652dfb 27173c26cc49c590d8c88ecfb5cc438201577a78 && git checkout -B drift-repro 14f80e23957165f6fb23c2b3d59bdc7668652dfb && git merge --no-ff 27173c26cc49c590d8c88ecfb5cc438201577a78
node scripts/docs-audit/affected-docs.mjs --json 14f80e23957165f6fb23c2b3d59bdc7668652dfb
|
Contract reviewServed-tier: Inputs: card #20611 (body; comments 5886067949 triage, 5894817672 claim, 5895749311 os-dev-report, 5895775380 amended claim), PR #20692 (body, 9-file list, net diff against ① Derived judgmentsAccept set, runtime
Public surface.
Triage ruling kept: the gate sees positions, never values. Shipped prose, each factual sentence judged against
Check-runs on the head at the reading above (38). Success: ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #20611
Clause-②: yes (narrowing)
What changes
flow-api-trigger-secret-missingnow runs on the runtime metadata publish gate as well as onos validate/os build/os lint. The gate is handed the redaction context, so a signed flow's ordinary round trip still passes.config.secretfrom every served definition ([security] a flow's inbound-hook secret (config.secreton the start node) is served in cleartext by the flow-definition read; after #20529 every armed hook carries one #20552). So a body saved back after a read arrives without it.saveMetaItemrestores the stored secret incarryForwardRedactedCredentials, deliberately AFTER every gate, so that no gate handles a restored credential. At the gate, a withheld secret and a missing one looked the same.saveMetaItemcomputes the POSITIONS the carry-forward will fill from the stored row. It uses the same stored body and the same plan, and it grafts nothing. Those positions go to the gate. The rule reads a withheld-and-stored secret as present, and one that is absent and not stored as missing.apiflow savedactiveat/meta(or a draft of one, at publish) now gets422 INVALID_METADATA, with the issueflow-api-trigger-secret-missingat the start node'sconfig.secret. Nothing is stored. Before this change it was stored, and the engine refused it at registration.Where it lands
packages/metadata-protocol/src/metadata-redaction.ts:carryForwardRedactedValuesis refactored onto one internal plan (planCarryForward), unchanged in behaviour. The new internalredactedPathsCarriedForward(type, incoming, stored)answers where that plan lands a value: the positions only, relative to the incoming body. It is not on the package entry.packages/metadata-protocol/src/protocol.ts:saveMetaItempasses the gate a lazily resolvedrestoredCredentialPaths. It is resolved only after the gate's early returns, so a draft save, the package-author channel andmigrate-storedpay no read.restoredCredentialPathsForreads the stored body through the carry-forward's own reader. That reader is factored out asstoredBodyForCarryForward: the active row, else the code layer.packages/metadata-protocol/src/runtime-authoring-gate.ts:evaluateRuntimeAuthoringGate(internal) forwards the positions.packages/lint/src/runtime-gate.ts:runRuntimeAuthoringRulesaccepts the item-relative dotted positions (nodes.1.config.secret). It re-spells them against the written item's place in the candidate snapshot (flows[0].nodes[1].config.secret), reading[n]versus.keyoff the item itself. It hands them to the rules asAuthoringRuleContext.restoredCredentialPaths.packages/lint/src/authoring-rules.ts: thevalidateFlowApiTriggerSecretentry becomesCLI_AND_RUNTIMEwithruntimeTypes: ['flow'], and thesurfaceReasonnaming this card is gone.AuthoringRuleContextgains the optionalrestoredCredentialPaths.runAuthoringRules(the CLI) never forwards it.packages/lint/src/validate-flow-trigger-readiness.ts:validateFlowApiTriggerSecret(stack, options?)treats a start-node secret position listed inoptions.restoredCredentialPathsas present.Dispatch hypotheses, measured
assertRuntimeAuthoringRulescall site ispromoteDraftForPublish, the publish and publish-package-drafts door. It judges the STORED draft row, which the draft's own save already carried forward (the draft carry-forward falls back to the active row, then the code layer). So it needs no context.AuthoringRuleContextandrunRuntimeAuthoringRulesare on@objectstack/lint's root entry (runRuntimeAuthoringRulesalso on./runtime). Both gain an optional field. The exportedvalidateFlowApiTriggerSecretgains an optional second parameter.@objectstack/metadata-protocol's entry is unchanged:redactedPathsCarriedForwardandevaluateRuntimeAuthoringGateare not exported from it.422/INVALID_METADATA(ADR-0112), the same envelope as the other gating runtime rules (protocol.runtime-authoring-gate.test.ts). The new pins assertcode+status+ the rule id and path.Pin sweep
protocol.metadata-redaction.test.ts, "the save door: the next served read of a relocated flow carries no credential" ([security] closeout: two stored-credential positions outside #20552's projection still reach a served read unredacted (a flow http node's signingSecret; the /meta list read's raw fallback) #20590). It saved a relocatedapiflow whose NEW start node has no secret. The carry-forward drops the old secret there, so this is now refused.422/INVALID_METADATA, the rule atflows[0].nodes[2].config.secret) with the row byte-equal to the seed.validate-flow-trigger-readiness.test.ts, the describe block "one rule id on ONE side of the runtime wall — CLI-only until Seam: the /meta save path runs the runtime authoring gate on the redacted body, before #20552's stored-secret carry-forward, so flow-api-trigger-secret-missing cannot run at the runtime surface #20611". It becomes "on both sides of the runtime wall". The registry entry is pinned to['cli','runtime-publish']/['flow']with nosurfaceReason. The gate refuses the secretless flow and admits it when the host restores the position. The CLI table never forwards a caller-stated set./meta(saveMetaItem/publishMetaItem/meta/flow) and mentions anapitrigger. Hits outside these two files are connector or actiontype: 'api', not flows. No other pin asserts that a secretlessapiflow saves at/meta.apiflow (examples/app-showcaseshowcase_inbound_task_webhook) carries a secret. No runtime source in this repo authors anapiflow.skills/objectstack-automation/SKILL.mdis a finding; see Acceptance notes.Tests
All figures are at
27173c26ccunless noted.@objectstack/metadata-protocol, full suite: 189 files passed, 3 skipped; 2789 tests passed, 19 skipped.protocol.metadata-redaction.test.ts: 47/47. The [security] a flow's inbound-hook secret (config.secreton the start node) is served in cleartext by the flow-definition read; after #20529 every armed hook carries one #20552 pins "GET → edit → PUT keeps the stored secret; an explicit rotation replaces it" and "the served body saved straight back persists the overlay row WITH the stored secret" are green with the rule live./metaand nothing is stored, paired in the same harness with the signed round trip admitted;redactedPathsCarriedForwardequals the positionscarryForwardRedactedValueschanges (served, reordered, rotated, relocated).@objectstack/lint, full suite: 115 files, 5382 tests passed.validate-flow-trigger-readiness.test.ts: 90/90.@objectstack/lint(tsc --noEmitpluscheck:test-typecheck, test layer OK) and@objectstack/metadata-protocol(tsc --noEmit, 192 test files in its program) are both green.@objectstack/objectql's 39 test files that callsaveMetaItem/publishMetaItem: 506/506, against the rebuiltmetadata-protocoldist.--no-inline-config --format json) on the 8 touched.tsfiles: 8 files, 0 errors, 0 warnings.filesglob**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}covers all of them.eslint.config.mjsenables no type-aware linting (noparserOptions.project, no typed rules). So this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.Ablations (committed first;
scripts/ablation-replace.mjs, restored with blob equal to HEAD andgit diff HEADempty)protocol.ts, the gate no longer receives the context (restoredCredentialPaths:renamed torestoredCredentialPathsAblated:at thesaveMetaItemcall). The anchor went 1 to 0 and the blobe3825069c5dfbecame57762308e583.[flow-api-trigger-secret-missing]: both [security] a flow's inbound-hook secret (config.secreton the start node) is served in cleartext by the flow-definition read; after #20529 every armed hook carries one #20552 round-trip pins, and the new same-harness pin.validate-flow-trigger-readiness.ts, the rule ignores the set (&& false &&). The anchor went 1 to 0 and the blobfd5bd29b1e00became40aa85516a7b../validate-flow-trigger-readiness.jsdirectly, so nodist/was involved.runtime-authoring-gate.tspassedrestoredCredentialPaths: 42intorunRuntimeAuthoringRules, andtsc --noEmitinmetadata-protocolfailed with TS2345 against the REBUILT@objectstack/lint.d.ts. Restored.Gates
node scripts/pm/dispatch-gates.mjs --commandsat27173c26ccderived 63 families.--ranreconciled them: 61 run with exit 0, 2 NOT MEASURED, 0 unrun.check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET (nodist/for 59+ packages; the gate needs a whole-repopnpm build).check:type-check-debt, reason: PREREQUISITE NOT MET (18 ledgered packages' dependency closures are not built).check:lean-entry-closurefirst exited 3 (no objectqldist/). Afterpnpm --filter @objectstack/objectql buildit passed.check-adr-0087-registrationjudged the changeset:[BREAKING+clause-②-narrowing] not-required (no-migration-prescription).Acceptance notes
secretexcept the empty string. So a legacy row whose stored secret is whitespace-only or a non-string passes the gate on a round trip, and the engine still refuses it at registration. Judging the stored VALUE would hand the gate a restored credential, which triage ruled out.sys_metadataread on anactivesave of a type with a registered redactor (datasourcebuilt in;flowonce the automation plugin registers its projection). Nothing for other types or for drafts.skills/objectstack-automation/SKILL.mdgives two stale instructions:configtable'ssecretrow says "Strongly recommended — without it unsigned posts are accepted and a warning is logged". Since 17.5.0 the engine refuses such a flow at registration, since After #20529, authoring surfaces still teach or pass anapiflow with no secret:skills/objectstack-automationcalls it optional, andos validatepasses it #20553os validaterefuses it, and with this PR/metarefuses it.apias "Invoked explicitly via the API /engine.execute(), or bound as an inbound webhook", but everytype: 'api'flow binds the inbound trigger and needs a secret.skills/**is a Tier H surface, so this is not fixed here.Generated by Claude Code