Skip to content

Commit 29caa84

Browse files
committed
feat(lint): validate-flow-trigger-readiness refuses an api flow with no per-flow secret
An `api`-bound flow (the engine's deriveTriggerBinding: array-form record pre-check, then resolveFlowTriggerKind === 'api') whose start node carries no string config.secret non-empty after trim is now an `error`, flow-api-trigger-secret-missing, in the file's never-fire family. The automation engine refuses the same flow at registration (ADR-0041), so `os validate` no longer passes a flow no runtime will register. Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx Co-authored-by: Claude <noreply@anthropic.com>
1 parent f11b5f2 commit 29caa84

5 files changed

Lines changed: 325 additions & 3 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
`os validate` refuses an `api` flow with no per-flow secret, the flow the automation engine already refuses to register (#20553).
6+
7+
Clause-②: yes (accept/reject: `os validate` newly refuses a secretless `api` flow)
8+
9+
`validate-flow-trigger-readiness` gains one rule id, `flow-api-trigger-secret-missing`, at `error`. 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.
10+
11+
**Why.** ADR-0041's `trigger-api` acceptance criteria require a per-flow secret with HMAC verification. In the same release, 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.
12+
13+
**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.
14+
15+
**Where the refusal surfaces.** The rule is `CLI_AND_RUNTIME`, so it gates in two places:
16+
17+
- `os validate`, `os build` and `os lint`.
18+
- The runtime metadata publish gate. A `state: 'active'` write of a secretless `api` flow now carries this `error`, so the gate refuses it before the flow is stored. Before this change the gate passed that write.
19+
20+
The gate judges only the item being written, so existing stored flows are not re-judged.
21+
22+
**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.
23+
24+
`FLOW_API_TRIGGER_SECRET_MISSING` is exported from `@objectstack/lint`.

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1107,7 +1107,9 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
11071107
// predicate cannot route at all, a `record-*` triggerType outside the
11081108
// closed token grammar `triggerTypeToHookEvents` maps, and (#6637) a
11091109
// `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
1110+
// routes nowhere, silently demoting it to a manual flow. #20553 made it five: an
1111+
// `api`-bound flow with no usable `config.secret`, which the engine's own
1112+
// `registerFlow` refuses (ADR-0041). None of those verdicts
11111113
// can be changed by installing a package, so there is no reading under which
11121114
// the flow fires. `flow-trigger-unknown-object` deliberately stayed `warning`
11131115
// (the object may come from another installed package — a hedge this rule

‎packages/lint/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export {
132132
FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
133133
FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
134134
FLOW_TRIGGER_UNROUTABLE,
135+
FLOW_API_TRIGGER_SECRET_MISSING,
135136
} from './validate-flow-trigger-readiness.js';
136137
export type {
137138
FlowTriggerReadinessFinding,

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

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID,
1111
FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
1212
FLOW_TRIGGER_UNROUTABLE,
13+
FLOW_API_TRIGGER_SECRET_MISSING,
1314
} from './validate-flow-trigger-readiness.js';
1415

1516
function recordFlow(overrides: Record<string, unknown> = {}) {
@@ -1008,6 +1009,143 @@ describe('validateFlowTriggerReadiness', () => {
10081009
});
10091010
});
10101011

1012+
// ── #20553 — an `api`-bound flow with no usable secret (ADR-0041) ─────────
1013+
//
1014+
// The engine refuses such a flow in `registerFlow`; `os validate` builds no
1015+
// engine, so this rule is the only thing at authoring time that says so. The
1016+
// cases pin the three things the rule has to get right, each against the
1017+
// engine's MEASURED answer rather than a reading of `type`: which flows are
1018+
// `api`-bound, what counts as a usable secret, and that a flow the engine
1019+
// registers is never named.
1020+
describe('api trigger secret (ADR-0041)', () => {
1021+
function apiFlow(
1022+
config: Record<string, unknown>,
1023+
overrides: Record<string, unknown> = {},
1024+
): Record<string, unknown> {
1025+
return {
1026+
name: 'inbound_order',
1027+
type: 'api',
1028+
status: 'active',
1029+
nodes: [
1030+
{ id: 'start', type: 'start', config },
1031+
{ id: 'end', type: 'end' },
1032+
],
1033+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
1034+
...overrides,
1035+
};
1036+
}
1037+
const judge = (flow: Record<string, unknown>) =>
1038+
validateFlowTriggerReadiness({ objects: [candidateObject], flows: [flow] });
1039+
1040+
it('fails a secretless `type: api` flow, naming the flow and the key', () => {
1041+
const findings = judge(apiFlow({ hookId: 'intake' }));
1042+
// Exhaustive: the flow is active and otherwise well-formed, so this id is
1043+
// the ONLY thing wrong with it.
1044+
expect(findings.map((f) => f.rule)).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]);
1045+
const [f] = findings;
1046+
expect(f.severity).toBe('error');
1047+
expect(f.where).toBe('flow "inbound_order" › start node');
1048+
expect(f.path).toBe('flows[0].nodes[0].config.secret');
1049+
expect(f.message).toContain(`type: 'api'`);
1050+
expect(f.message).toContain('config.secret');
1051+
});
1052+
1053+
it('passes the same flow once it carries a secret', () => {
1054+
expect(judge(apiFlow({ hookId: 'intake', secret: 'whsec_4f9a' }))).toEqual([]);
1055+
});
1056+
1057+
it('passes an `autolaunched` flow — the explicit-only form needs no secret', () => {
1058+
expect(judge(apiFlow({}, { type: 'autolaunched' }))).toEqual([]);
1059+
// …and the same flow flipped to `api` with nothing else changed fails.
1060+
expect(judge(apiFlow({}, { type: 'api' })).map((f) => f.rule)).toEqual([
1061+
FLOW_API_TRIGGER_SECRET_MISSING,
1062+
]);
1063+
});
1064+
1065+
it('judges a start-node `triggerType: api` on any other flow type exactly like `type: api`', () => {
1066+
for (const type of ['autolaunched', 'screen', 'record_change']) {
1067+
const secretless = judge(apiFlow({ triggerType: 'api' }, { type }));
1068+
expect(secretless.map((f) => f.rule), type).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]);
1069+
expect(secretless[0].path, type).toBe('flows[0].nodes[0].config.secret');
1070+
expect(secretless[0].message, type).toContain(`start-node triggerType: 'api'`);
1071+
expect(secretless[0].message, type).not.toContain(`type: 'api' and`);
1072+
expect(judge(apiFlow({ triggerType: 'api', secret: 's3cret' }, { type })), type).toEqual([]);
1073+
}
1074+
// Both declarations at once: still one finding, naming both.
1075+
const both = judge(apiFlow({ triggerType: 'api' }));
1076+
expect(both.map((f) => f.rule)).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]);
1077+
expect(both[0].message).toContain(`type: 'api' and start-node triggerType: 'api'`);
1078+
});
1079+
1080+
it('fails a blank secret, and every non-string one, without echoing the value', () => {
1081+
for (const secret of [' ', '', '\t\n', 12345, true, null, ['s3cret'], { value: 's3cret' }]) {
1082+
const findings = judge(apiFlow({ secret }));
1083+
expect(findings.map((f) => f.rule), JSON.stringify(secret)).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]);
1084+
// Only the TYPE is rendered — a finding travels into CI logs and
1085+
// publish-gate responses.
1086+
expect(findings[0].message, JSON.stringify(secret)).not.toContain('12345');
1087+
expect(findings[0].message, JSON.stringify(secret)).not.toContain('s3cret');
1088+
}
1089+
expect(judge(apiFlow({ secret: ' ' }))[0].message).toContain('blank');
1090+
expect(judge(apiFlow({ secret: 12345 }))[0].message).toContain('a number, not a string');
1091+
// The runtime trims before judging; a padded real secret is usable.
1092+
expect(judge(apiFlow({ secret: ' s3cret ' }))).toEqual([]);
1093+
});
1094+
1095+
it('judges a disabled flow too — the engine refuses it whatever its status', () => {
1096+
for (const status of ['obsolete', 'draft']) {
1097+
expect(
1098+
judge(apiFlow({}, { status })).map((f) => f.rule),
1099+
status,
1100+
).toContain(FLOW_API_TRIGGER_SECRET_MISSING);
1101+
}
1102+
});
1103+
1104+
it('judges a flow with no start node, located at `nodes`', () => {
1105+
const findings = judge({ name: 'no_start_api', type: 'api', status: 'active', nodes: [{ id: 'end', type: 'end' }] });
1106+
expect(findings.map((f) => f.rule)).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]);
1107+
expect(findings[0].where).toBe('flow "no_start_api"');
1108+
expect(findings[0].path).toBe('flows[0].nodes');
1109+
expect(findings[0].message).toContain('no start node');
1110+
});
1111+
1112+
it('matches the engine where precedence binds another trigger — each paired with the shape that fires', () => {
1113+
// Measured against the BUILT `AutomationEngine.registerFlow` at
1114+
// f11b5f20a2: each of these registers with no secret, because
1115+
// `deriveTriggerBinding` binds the higher-precedence trigger and never
1116+
// asks for one. The `type === 'api' || triggerType === 'api'` disjunction
1117+
// would refuse all five.
1118+
const boundElsewhere: Array<[string, Record<string, unknown>]> = [
1119+
['api + config.schedule', apiFlow({ schedule: { type: 'interval', intervalMs: 60000 } })],
1120+
['api + record-* token', apiFlow({ objectName: 'app_candidate', triggerType: 'record-after-create' })],
1121+
['api + array record token', apiFlow({ objectName: 'app_candidate', triggerType: ['record-after-create'] })],
1122+
[
1123+
'api + timeRelative descriptor',
1124+
apiFlow({ timeRelative: { object: 'app_candidate', dateField: 'due_at', withinDays: 3 } }),
1125+
],
1126+
[
1127+
'schedule + triggerType api',
1128+
apiFlow({ triggerType: 'api', schedule: { type: 'interval', intervalMs: 60000 } }, { type: 'schedule' }),
1129+
],
1130+
];
1131+
for (const [label, flow] of boundElsewhere) {
1132+
expect(judge(flow).map((f) => f.rule), label).not.toContain(FLOW_API_TRIGGER_SECRET_MISSING);
1133+
}
1134+
// A SCALAR `timeRelative` is not routed to the sweep, so the flow falls
1135+
// through to `api` — measured REFUSED by the engine — and both facts are
1136+
// reported, at two paths.
1137+
const scalar = judge(apiFlow({ timeRelative: 'daily' })).map((f) => f.rule);
1138+
expect(scalar).toContain(FLOW_API_TRIGGER_SECRET_MISSING);
1139+
expect(scalar).toContain(FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE);
1140+
// Remove the higher-precedence sibling and the same flow is refused again.
1141+
expect(judge(apiFlow({})).map((f) => f.rule)).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]);
1142+
});
1143+
1144+
it('the id is the published slug', () => {
1145+
expect(FLOW_API_TRIGGER_SECRET_MISSING).toBe('flow-api-trigger-secret-missing');
1146+
});
1147+
});
1148+
10111149
// ── #5762 — the family's severity map ────────────────────────────────────
10121150
//
10131151
// The rules in this file were reviewed as ONE family and split on a single
@@ -1109,6 +1247,21 @@ describe('validateFlowTriggerReadiness', () => {
11091247
],
11101248
},
11111249
],
1250+
[
1251+
FLOW_API_TRIGGER_SECRET_MISSING,
1252+
'error',
1253+
{
1254+
objects: [candidateObject],
1255+
flows: [
1256+
{
1257+
name: 'unsigned_hook',
1258+
type: 'api',
1259+
status: 'active',
1260+
nodes: [{ id: 'start', type: 'start', config: { hookId: 'intake' } }],
1261+
},
1262+
],
1263+
},
1264+
],
11121265
// ── The controls. Both are hedged, and the hedge is the whole reason the
11131266
// promotion above is not "everything in this file is an error now".
11141267
[
@@ -1199,6 +1352,16 @@ describe('validateFlowTriggerReadiness', () => {
11991352
},
12001353
],
12011354
},
1355+
// [#20553] A signed inbound flow is what a correct `api` flow looks
1356+
// like — the secret rule must not move this floor.
1357+
{
1358+
name: 'order_intake',
1359+
type: 'api',
1360+
status: 'active',
1361+
nodes: [
1362+
{ id: 'start', type: 'start', config: { hookId: 'intake', secret: 'whsec_4f9a' } },
1363+
],
1364+
},
12021365
],
12031366
}),
12041367
).toEqual([]);

0 commit comments

Comments
 (0)