Skip to content

Commit 5dde7e6

Browse files
committed
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 <noreply@anthropic.com>
1 parent 825c33f commit 5dde7e6

4 files changed

Lines changed: 234 additions & 71 deletions

File tree

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

Lines changed: 35 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';
@@ -1109,7 +1112,8 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
11091112
// `type: 'record_change'` flow whose triggerType the engine's binding resolver
11101113
// routes nowhere, silently demoting it to a manual flow. #20553 made it five: an
11111114
// `api`-bound flow with no usable `config.secret`, which the engine's own
1112-
// `registerFlow` refuses (ADR-0041). None of those verdicts
1115+
// `registerFlow` refuses (ADR-0041) — on its OWN entry below
1116+
// (`validateFlowApiTriggerSecret`), because it is CLI-only for now. None of those verdicts
11131117
// can be changed by installing a package, so there is no reading under which
11141118
// the flow fires. `flow-trigger-unknown-object` deliberately stayed `warning`
11151119
// (the object may come from another installed package — a hedge this rule
@@ -1139,6 +1143,35 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
11391143
runtimeTypes: ['flow'],
11401144
run: (stack) => validateFlowTriggerReadiness(stack),
11411145
},
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+
},
11421175
// ADR-0090 D3 fallout — an approval `{ type: 'role' }` resolves against the
11431176
// better-auth org-membership tier, not positions, so a position name authored
11441177
// there routes the approval to nobody; and an expression approver that does

‎packages/lint/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ 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,

‎packages/lint/src/validate-flow-trigger-readiness.test.ts‎

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,22 @@ import {
1111
FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
1212
FLOW_TRIGGER_UNROUTABLE,
1313
FLOW_API_TRIGGER_SECRET_MISSING,
14+
validateFlowApiTriggerSecret,
1415
} from './validate-flow-trigger-readiness.js';
16+
import { AUTHORING_COMMANDS, AUTHORING_RULES, runAuthoringRules } from './authoring-rules.js';
17+
import { runRuntimeAuthoringRules } from './runtime-gate.js';
18+
19+
/**
20+
* [#20553] The flow-trigger family as the CLI table runs it: two registry
21+
* entries over one file — `validateFlowTriggerReadiness` and, split out because
22+
* it is CLI-only until #20611, `validateFlowApiTriggerSecret`. Cases that are
23+
* about the WHOLE family's verdict on a stack (the severity map, the clean-stack
24+
* floor, the api-secret cases' exhaustive assertions) judge through this.
25+
*/
26+
const cliFlowFamily = (stack: Record<string, unknown>) => [
27+
...validateFlowTriggerReadiness(stack),
28+
...validateFlowApiTriggerSecret(stack),
29+
];
1530

1631
function recordFlow(overrides: Record<string, unknown> = {}) {
1732
return {
@@ -1035,7 +1050,7 @@ describe('validateFlowTriggerReadiness', () => {
10351050
};
10361051
}
10371052
const judge = (flow: Record<string, unknown>) =>
1038-
validateFlowTriggerReadiness({ objects: [candidateObject], flows: [flow] });
1053+
cliFlowFamily({ objects: [candidateObject], flows: [flow] });
10391054

10401055
it('fails a secretless `type: api` flow, naming the flow and the key', () => {
10411056
const findings = judge(apiFlow({ hookId: 'intake' }));
@@ -1144,6 +1159,66 @@ describe('validateFlowTriggerReadiness', () => {
11441159
it('the id is the published slug', () => {
11451160
expect(FLOW_API_TRIGGER_SECRET_MISSING).toBe('flow-api-trigger-secret-missing');
11461161
});
1162+
1163+
// [#20611] The id is CLI-only until the runtime publish gate judges the
1164+
// carried-forward body: a `/meta` save is gated BEFORE `saveMetaItem`
1165+
// restores the `config.secret` the flow read path withholds (#20552), so a
1166+
// signed flow's GET → edit → PUT would reach the gate secretless. Each side
1167+
// of the wall is pinned, and the gate pin carries its own positive control.
1168+
describe('one rule id on ONE side of the runtime wall — CLI-only until #20611', () => {
1169+
const secretless = apiFlow({ hookId: 'intake' });
1170+
const stack = { objects: [candidateObject], flows: [secretless] };
1171+
1172+
it('the split is whole: validateFlowTriggerReadiness alone no longer emits the id', () => {
1173+
expect(validateFlowTriggerReadiness(stack).map((f) => f.rule)).not.toContain(
1174+
FLOW_API_TRIGGER_SECRET_MISSING,
1175+
);
1176+
expect(validateFlowApiTriggerSecret(stack).map((f) => f.rule)).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]);
1177+
});
1178+
1179+
it('its registry entry gates all three commands on the cli surface only, with a reason', () => {
1180+
const entry = AUTHORING_RULES.find((r) => r.name === 'validateFlowApiTriggerSecret');
1181+
expect(entry).toBeDefined();
1182+
expect(entry!.tier).toBe('gating');
1183+
expect([...entry!.commands].sort()).toEqual([...AUTHORING_COMMANDS].sort());
1184+
expect(entry!.surfaces).toEqual(['cli']);
1185+
expect(entry!.runtimeTypes).toBeUndefined();
1186+
expect((entry!.surfaceReason ?? '').trim().length).toBeGreaterThan(40);
1187+
// …while the family's shared entry still crosses the wall.
1188+
expect(AUTHORING_RULES.find((r) => r.name === 'validateFlowTriggerReadiness')!.surfaces).toContain(
1189+
'runtime-publish',
1190+
);
1191+
});
1192+
1193+
it.each([...AUTHORING_COMMANDS])('os %s still refuses a secretless api flow through the table', (command) => {
1194+
const hits = runAuthoringRules(command, { normalized: stack, parsed: stack }).filter(
1195+
(f) => f.rule === FLOW_API_TRIGGER_SECRET_MISSING,
1196+
);
1197+
expect(hits.map((f) => [f.severity, f.path])).toEqual([['error', 'flows[0].nodes[0].config.secret']]);
1198+
});
1199+
1200+
it('the runtime publish gate does not emit it — and still runs the rest of the family', () => {
1201+
const gated = runRuntimeAuthoringRules({ type: 'flow', item: secretless });
1202+
expect(gated.rulesRun).toContain('validateFlowTriggerReadiness');
1203+
expect(gated.rulesRun).not.toContain('validateFlowApiTriggerSecret');
1204+
expect([...gated.errors, ...gated.advisories].map((f) => f.rule)).not.toContain(
1205+
FLOW_API_TRIGGER_SECRET_MISSING,
1206+
);
1207+
// Positive control: the same door still refuses a flow the family
1208+
// proves dead, so the absence above is the wall, not a gate that ran
1209+
// nothing.
1210+
const dead = runRuntimeAuthoringRules({
1211+
type: 'flow',
1212+
item: {
1213+
name: 'declared_dead',
1214+
type: 'record_change',
1215+
status: 'active',
1216+
nodes: [{ id: 'start', type: 'start', config: { objectName: 'app_candidate', triggerType: 'onCreate' } }],
1217+
},
1218+
});
1219+
expect(dead.errors.map((f) => f.rule)).toContain(FLOW_TRIGGER_UNROUTABLE);
1220+
});
1221+
});
11471222
});
11481223

11491224
// ── #5762 — the family's severity map ────────────────────────────────────
@@ -1297,7 +1372,7 @@ describe('validateFlowTriggerReadiness', () => {
12971372

12981373
for (const [rule, severity, stack] of provoke) {
12991374
it(`${rule} is ${severity}`, () => {
1300-
const matching = validateFlowTriggerReadiness(stack).filter((f) => f.rule === rule);
1375+
const matching = cliFlowFamily(stack).filter((f) => f.rule === rule);
13011376
// Non-vacuous first: the fixture really does provoke this id.
13021377
expect(matching.length, `${rule} was not provoked by its own fixture`).toBeGreaterThan(0);
13031378
for (const f of matching) expect(f.severity).toBe(severity);
@@ -1310,7 +1385,7 @@ describe('validateFlowTriggerReadiness', () => {
13101385
// makes a gate read as a bug — and the cross-package sentence is exactly
13111386
// what earns `flow-trigger-unknown-object` its warning.
13121387
for (const [rule, severity, stack] of provoke) {
1313-
for (const f of validateFlowTriggerReadiness(stack).filter((x) => x.rule === rule)) {
1388+
for (const f of cliFlowFamily(stack).filter((x) => x.rule === rule)) {
13141389
if (severity === 'warning' && rule === FLOW_TRIGGER_UNKNOWN_OBJECT) {
13151390
expect(f.hint, rule).toMatch(/another installed package/);
13161391
}
@@ -1327,7 +1402,7 @@ describe('validateFlowTriggerReadiness', () => {
13271402
// correct flow fail. Without this, "everything is an error" would pass
13281403
// every assertion above.
13291404
expect(
1330-
validateFlowTriggerReadiness({
1405+
cliFlowFamily({
13311406
objects: [candidateObject, { name: 'task', label: 'Task', fields: {} }],
13321407
flows: [
13331408
recordFlow({ status: 'active' }),

0 commit comments

Comments
 (0)