From bf2762993cb2de5c2da6ea4ef99ddd73aac68792 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:51:27 +0000 Subject: [PATCH 01/10] feat(spec): an api flow's start node may name its hook secret by reference (config.secretRef) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01NZJKktPWaz2EZJ95a9XZa8 --- .../automation/api-trigger-secret-ref.test.ts | 84 +++++++++++ .../automation/api-trigger-secret-ref.zod.ts | 139 ++++++++++++++++++ packages/spec/src/automation/flow.zod.ts | 10 ++ packages/spec/src/automation/index.ts | 2 + 4 files changed, 235 insertions(+) create mode 100644 packages/spec/src/automation/api-trigger-secret-ref.test.ts create mode 100644 packages/spec/src/automation/api-trigger-secret-ref.zod.ts diff --git a/packages/spec/src/automation/api-trigger-secret-ref.test.ts b/packages/spec/src/automation/api-trigger-secret-ref.test.ts new file mode 100644 index 00000000000..f74c7e0f9cf --- /dev/null +++ b/packages/spec/src/automation/api-trigger-secret-ref.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { apiTriggerSecretRefOf, apiTriggerSecretRefRefusals } from './api-trigger-secret-ref.zod'; +import { FlowSchema } from './flow.zod'; + +// `config.secretRef` — an `api` flow's start node names its inbound hook's +// secret instead of carrying it, so an app package can ship the flow without +// publishing the credential. These pins hold the contract half: the accepted +// form parses whole, and each refusal lands on the start node's `secretRef`. + +function flow(type: string, config: Record) { + return { + name: 'srm_heartbeat', + label: 'Heartbeat', + type, + nodes: [ + { id: 'start', type: 'start', label: 'Start', config }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }; +} + +function refusalAt(result: ReturnType) { + expect(result.success).toBe(false); + const issues = result.error!.issues.filter((i) => i.path.join('.') === 'nodes.0.config.secretRef'); + expect(issues).toHaveLength(1); + return issues[0]!; +} + +describe('config.secretRef — an api flow names its hook secret', () => { + it('a type api flow whose start node names its secret parses whole, the reference served as written', () => { + const result = FlowSchema.safeParse(flow('api', { hookId: 'heartbeat', secretRef: 'SRM_HEARTBEAT_SECRET' })); + expect(result.success).toBe(true); + expect(result.data!.nodes[0]!.config).toEqual({ hookId: 'heartbeat', secretRef: 'SRM_HEARTBEAT_SECRET' }); + }); + + it("a start node triggerType: 'api' binds the hook too, so the reference is read there", () => { + expect(FlowSchema.safeParse(flow('autolaunched', { triggerType: 'api', secretRef: '_HOOK_KEY_2' })).success).toBe(true); + }); + + it('refuses a reference that is not a name — blank, spaced, a literal-looking value, a non-string', () => { + for (const bad of ['', ' SRM_SECRET', 'srm-secret', 'whsec_9f8e/7d6c', 42, null, { env: 'X' }]) { + const issue = refusalAt(FlowSchema.safeParse(flow('api', { secretRef: bad }))); + expect(issue.code).toBe('custom'); + } + }); + + it('refuses a name in the platform OS_ namespace, whatever its case', () => { + for (const reserved of ['OS_AUTH_SECRET', 'os_auth_secret']) { + expect(refusalAt(FlowSchema.safeParse(flow('api', { secretRef: reserved }))).message).toMatch(/`OS_` namespace/); + } + }); + + it('refuses secretRef beside a literal secret — the cleared form included', () => { + for (const secret of ['literal-value', '']) { + const issue = refusalAt(FlowSchema.safeParse(flow('api', { secret, secretRef: 'SRM_HEARTBEAT_SECRET' }))); + expect(issue.message).toMatch(/both `config\.secret` and `config\.secretRef`/); + } + }); + + it('refuses a reference on a flow that binds no api trigger — nothing would read it', () => { + const issue = refusalAt(FlowSchema.safeParse(flow('autolaunched', { secretRef: 'SRM_HEARTBEAT_SECRET' }))); + expect(issue.message).toMatch(/binds no inbound `api` trigger/); + // A record token outranks `type: 'api'` in the binding chain, so that flow binds the record trigger. + refusalAt(FlowSchema.safeParse(flow('api', { + objectName: 'task', triggerType: 'record-after-create', secretRef: 'SRM_HEARTBEAT_SECRET', + }))); + }); + + it('apiTriggerSecretRefOf answers the usable name only', () => { + expect(apiTriggerSecretRefOf({ secretRef: 'SRM_HEARTBEAT_SECRET' })).toBe('SRM_HEARTBEAT_SECRET'); + expect(apiTriggerSecretRefOf({ secretRef: 'OS_AUTH_SECRET' })).toBeUndefined(); + expect(apiTriggerSecretRefOf({ secretRef: '' })).toBeUndefined(); + expect(apiTriggerSecretRefOf({ secret: 'x' })).toBeUndefined(); + expect(apiTriggerSecretRefOf(undefined)).toBeUndefined(); + }); + + it('a start node without secretRef raises nothing here', () => { + expect(apiTriggerSecretRefRefusals(flow('api', { secret: 'x' }))).toEqual([]); + expect(apiTriggerSecretRefRefusals('not a flow')).toEqual([]); + }); +}); diff --git a/packages/spec/src/automation/api-trigger-secret-ref.zod.ts b/packages/spec/src/automation/api-trigger-secret-ref.zod.ts new file mode 100644 index 00000000000..9595d223a73 --- /dev/null +++ b/packages/spec/src/automation/api-trigger-secret-ref.zod.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { resolveFlowTriggerKind } from './flow-trigger-kind'; + +/** + * # An inbound `api` hook's secret, by reference + * + * An `api` flow's start node arms an inbound hook that verifies every post + * against a per-flow HMAC secret (ADR-0041). Until this key the only way to + * declare that secret in a definition was the literal `config.secret`, so an + * app package could not ship an `api` flow without publishing its credential: + * a packaged flow cannot be overlaid, so the write-only flow credential store + * the metadata save door fills is out of a package's reach. + * + * `config.secretRef` names the secret instead of carrying it. The deployment + * supplies the value under that name, and the automation engine reads it at + * every inbound post through the same credential resolver a declarative + * connector's `auth.credentialRef` goes through (ADR-0097 §3): an environment + * variable of that name in the open tier, a secrets service where the host + * wires one. A post that arrives while the name resolves to no value is + * refused as unavailable, never verified against nothing. + * + * The reference is NOT a credential: it is served with the definition like any + * other key, and only the literal `config.secret` is withheld on read. + * + * Three refusals, each anchored on the start node's `config`: + * + * - a reference that is not a name — {@link ApiTriggerSecretRefSchema}; + * - a start node that declares both `secret` and `secretRef` — two sources for + * one credential, and no reader could say which one an author meant; + * - a reference on a flow that binds no inbound `api` trigger — nothing would + * ever read it, so a deployment would fill a name for nothing. + */ + +/** The start-node `config` key that names an inbound hook's secret. */ +export const API_TRIGGER_SECRET_REF_KEY = 'secretRef'; + +/** + * The names ObjectStack owns (Prime Directive #9's `OS_` prefix). A hook secret + * is handed to whoever signs the posts, so a platform secret named here — + * `OS_AUTH_SECRET`, say — would be handed out with it. + */ +export const API_TRIGGER_SECRET_REF_RESERVED_PREFIX = 'OS_'; + +/** An environment-variable-shaped name: a letter or `_`, then letters, digits and `_`. */ +const SECRET_REF_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * The reference itself: the name a deployment fills, never the secret. A + * letter or `_` first, then letters, digits and `_` (an environment variable's + * shape, which is what the open tier resolves it as), and never in the + * platform's own `OS_` namespace. + */ +export const ApiTriggerSecretRefSchema = z + .string() + .regex( + SECRET_REF_NAME, + '`config.secretRef` names the inbound hook\'s secret — a letter or `_` first, then letters, digits and `_` ' + + '(an environment variable\'s name, such as `SRM_HEARTBEAT_SECRET`). Write the name the deployment fills, ' + + 'never the secret itself.', + ) + .refine( + (name) => !name.toUpperCase().startsWith(API_TRIGGER_SECRET_REF_RESERVED_PREFIX), + '`config.secretRef` may not name a variable in the platform\'s `OS_` namespace: whoever signs the posts holds ' + + 'the hook secret, so a platform secret named here would be handed out with it. Name a variable of your own ' + + '(`SRM_HEARTBEAT_SECRET`).', + ) + .describe( + 'The name of the inbound hook\'s HMAC secret, filled by the deployment and read at every post through the ' + + 'automation credential resolver (an environment variable of that name in the open tier). In place of a ' + + 'literal `secret`, never beside it; not in the `OS_` namespace.', + ); + +/** + * The usable reference on a start node's `config`, or `undefined` when the key + * is absent or does not parse. The one reader the lint rule and the engine + * share, so neither restates the grammar. + */ +export function apiTriggerSecretRefOf(config: unknown): string | undefined { + if (!config || typeof config !== 'object' || Array.isArray(config)) return undefined; + if (!Object.prototype.hasOwnProperty.call(config, API_TRIGGER_SECRET_REF_KEY)) return undefined; + const parsed = ApiTriggerSecretRefSchema.safeParse((config as Record)[API_TRIGGER_SECRET_REF_KEY]); + return parsed.success ? parsed.data : undefined; +} + +/** One refusal, at a path relative to the flow (`['nodes', 0, 'config', 'secretRef']`). */ +export interface ApiTriggerSecretRefRefusal { + readonly path: ReadonlyArray; + readonly message: string; +} + +/** + * Every refusal the start nodes of `flow` raise for `config.secretRef` — the + * three in this module's header. Structural, like {@link resolveFlowTriggerKind}: + * a value that is not a flow yields none. + */ +export function apiTriggerSecretRefRefusals(flow: unknown): ApiTriggerSecretRefRefusal[] { + if (!flow || typeof flow !== 'object') return []; + const nodes = (flow as { nodes?: unknown }).nodes; + if (!Array.isArray(nodes)) return []; + const bindsApi = resolveFlowTriggerKind(flow) === 'api'; + const out: ApiTriggerSecretRefRefusal[] = []; + nodes.forEach((node, index) => { + if (!node || typeof node !== 'object' || (node as { type?: unknown }).type !== 'start') return; + const config = (node as { config?: unknown }).config; + if (!config || typeof config !== 'object' || Array.isArray(config)) return; + if (!Object.prototype.hasOwnProperty.call(config, API_TRIGGER_SECRET_REF_KEY)) return; + const path = ['nodes', index, 'config', API_TRIGGER_SECRET_REF_KEY]; + const parsed = ApiTriggerSecretRefSchema.safeParse((config as Record)[API_TRIGGER_SECRET_REF_KEY]); + if (!parsed.success) { + out.push({ + path, + message: parsed.error.issues[0]?.message ?? '`config.secretRef` must be a string naming the secret.', + }); + return; + } + if (Object.prototype.hasOwnProperty.call(config, 'secret')) { + out.push({ + path, + message: + 'The start node declares both `config.secret` and `config.secretRef` — two sources for one inbound hook ' + + 'secret. Keep `secretRef` (the deployment fills the name) and remove the literal `secret`, which would ' + + 'publish the credential with the flow.', + }); + return; + } + if (!bindsApi) { + out.push({ + path, + message: + '`config.secretRef` names an inbound hook\'s secret, but this flow binds no inbound `api` trigger, so ' + + 'nothing ever reads it. Declare `type: \'api\'` (or start-node `triggerType: \'api\'`) for a flow that ' + + 'receives signed posts, or remove `secretRef`.', + }); + } + }); + return out; +} diff --git a/packages/spec/src/automation/flow.zod.ts b/packages/spec/src/automation/flow.zod.ts index 44760d6c9f1..eb01b00f699 100644 --- a/packages/spec/src/automation/flow.zod.ts +++ b/packages/spec/src/automation/flow.zod.ts @@ -26,6 +26,7 @@ import { strictObject } from '../shared/strict-object'; import { collectFlowGraphs, parseFlowNodeRegions } from './control-flow.zod'; import { predicateSlotRefusal, resolveFlowNodeExpressions } from './flow-node-expression-paths'; import { flowNodeConfigRefusals } from './flow-node-config-refusals'; +import { apiTriggerSecretRefRefusals } from './api-trigger-secret-ref.zod'; // [#23543 F3] The loose `config` keys of the two sibling-block node types — one // table, shared with the two D2 lifts that still run at rehydration. import { siblingBlockLooseConfigRefusals } from './flow-sibling-block-loose-keys'; @@ -1644,6 +1645,15 @@ export const FlowSchema = lazySchema(() => strictObject( }); } + // An inbound `api` hook's secret named by reference (`config.secretRef`) + // instead of carried as a literal — so an app package can ship an `api` flow + // without publishing its credential. A start node is top-level only, so the + // flow's own `nodes` is the whole walk; the three refusals and why each + // exists are `api-trigger-secret-ref.zod.ts`'s header. + for (const refusal of apiTriggerSecretRefRefusals(flow)) { + ctx.addIssue({ code: 'custom', path: [...refusal.path], message: refusal.message }); + } + // An `assignment` node's targets are bindings (#22572), and the `$` names are // the flow engine's at every binding door — `assignments: { $record: … }` // would overwrite the trigger record for the rest of the run. No executor diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index b7064459cf3..a44b90d5f9b 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -44,6 +44,8 @@ export * from './approval.zod'; // (Prime Directive #12); the #4480 template cluster fell the same way. export * from './time-relative-trigger.zod'; export * from './flow-trigger-kind'; +// An `api` flow's hook secret named by reference — the deployment fills it. +export * from './api-trigger-secret-ref.zod'; // The acting-organization declaration a time-triggered flow carries, and the // one refusal sentence the schedule trigger and the time-relative sweep both // say it with (commit ecdfc9411). ⛔ `FlowSchema` does NOT emit that sentence: the key is From 5ac7a2f295cb407c6f9fa9ed55cadaf2124ad42f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 16:58:13 +0000 Subject: [PATCH 02/10] feat(lint): flow-api-trigger-secret-missing accepts a secret named by config.secretRef Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01NZJKktPWaz2EZJ95a9XZa8 --- packages/lint/src/rule-explanations.ts | 6 ++++++ .../validate-flow-trigger-readiness.test.ts | 17 +++++++++++++++++ .../src/validate-flow-trigger-readiness.ts | 19 +++++++++++++++++-- .../spec/json-schema.manifest/automation.json | 1 + .../automation/api-trigger-secret-ref.zod.ts | 8 ++++++-- 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index a6d3a1b91fd..06cd46512dd 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -1858,6 +1858,12 @@ const FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION: RuleExplanation = { 'seconds from its own clock, so a captured post stops verifying. A usable secret is a string that is ' + 'not blank after trimming, on the start node\'s `config.secret`; absent, blank and non-string are one ' + 'verdict.', + 'A flow that ships in a package names its secret instead of carrying it: `config.secretRef` holds the ' + + 'name the deployment fills (an environment variable of that name in the open tier, or the entry the ' + + 'host\'s credential resolver serves), never in the platform\'s `OS_` namespace and never beside a ' + + 'literal `secret`. The engine reads the name at every post, so the value never appears in the ' + + 'definition or the repository; a post that arrives while the name resolves to nothing is answered ' + + '503, never verified against nothing.', 'The automation engine refuses to register a flow bound to the api trigger without one — a ' + 'hardcoded check in `registerFlow`, before any trigger is consulted and whatever the flow\'s ' + '`status` — and the trigger refuses to arm it, so the flow never receives a post. The `/automation` ' + diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts index 67c98d41169..48c2e8c76bb 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.test.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -1131,6 +1131,23 @@ describe('validateFlowTriggerReadiness', () => { expect(judge(apiFlow({ hookId: 'intake', secret: 'whsec_4f9a' }))).toEqual([]); }); + it('passes the same flow when it names its secret by reference — the form a package ships', () => { + expect(judge(apiFlow({ hookId: 'intake', secretRef: 'SRM_HEARTBEAT_SECRET' }))).toEqual([]); + // Through the runtime publish gate too: no restored position is needed for a reference. + expect( + runRuntimeAuthoringRules({ type: 'flow', item: apiFlow({ secretRef: 'SRM_HEARTBEAT_SECRET' }) }).errors, + ).toEqual([]); + }); + + it('a reference that is not a usable name is still no secret, and the remedy names secretRef', () => { + for (const secretRef of ['', 'OS_AUTH_SECRET', 'not a name', 42]) { + const findings = judge(apiFlow({ secretRef })); + expect(findings.map((f) => f.rule), JSON.stringify(secretRef)).toEqual([FLOW_API_TRIGGER_SECRET_MISSING]); + expect(findings[0].message).toContain('config.secretRef is not a usable name'); + } + expect(judge(apiFlow({ hookId: 'intake' }))[0].hint).toContain('config.secretRef'); + }); + it('passes an `autolaunched` flow — the explicit-only form needs no secret', () => { expect(judge(apiFlow({}, { type: 'autolaunched' }))).toEqual([]); // …and the same flow flipped to `api` with nothing else changed fails. diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index ba23081de9e..b84b5c6eb82 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -172,7 +172,9 @@ // round trip is not. import { + API_TRIGGER_SECRET_REF_KEY, TimeRelativeTriggerSchema, + apiTriggerSecretRefOf, resolveFlowTriggerKind, } from '@objectstack/spec/automation'; import { recordsOf, suggestName } from './object-graph.js'; @@ -274,6 +276,11 @@ export const FLOW_TRIGGER_UNROUTABLE = 'flow-trigger-unroutable'; * the one-line judgement — a string, non-empty after `trim()`, on the start * node's `config` — and a spec-level predicate all three could share would be * its own change, editing both runtime copies to read it. + * + * A secret NAMED instead of carried — `config.secretRef`, which the deployment + * fills and the engine resolves at every post — is usable too, and that half + * IS the spec's: `apiTriggerSecretRefOf` is the one reader of the name, shared + * with the engine, so this rule restates no grammar for it. */ export const FLOW_API_TRIGGER_SECRET_MISSING = 'flow-api-trigger-secret-missing'; @@ -344,6 +351,14 @@ function renderTriggerToken(v: unknown): string { */ function describeUnusableSecret(start: { node: AnyRec } | undefined, config: AnyRec): string | undefined { if (!start) return 'it has no start node, so nothing declares a config.secret'; + // A secret named by reference (`config.secretRef`) is usable when the spec's + // own reader accepts the name: the deployment fills it and the engine reads + // it per post. A malformed one is the schema's refusal, and still no secret. + if (Object.prototype.hasOwnProperty.call(config, API_TRIGGER_SECRET_REF_KEY) && config.secret === undefined) { + return apiTriggerSecretRefOf(config) !== undefined + ? undefined + : `its start node's config.secretRef is not a usable name`; + } const secret = config.secret; if (typeof secret === 'string') { return secret.trim() !== '' ? undefined : `its start node's config.secret is blank`; @@ -1013,8 +1028,8 @@ export function validateFlowApiTriggerSecret( // [#22161] One remedy line; the signature format and the 300-second // window are `os explain flow-api-trigger-secret-missing`. hint: - `Set a non-blank string config.secret on the start node and sign each post with it, or make a flow ` + - `that never receives posts type: 'autolaunched' with no triggerType: 'api'`, + `Name the secret in the start node's config.secretRef (the deployment fills it) and sign each post, or ` + + `make a flow that never receives posts type: 'autolaunched'`, }); }); return findings; diff --git a/packages/spec/json-schema.manifest/automation.json b/packages/spec/json-schema.manifest/automation.json index 061c47cfd03..2475ad78cdb 100644 --- a/packages/spec/json-schema.manifest/automation.json +++ b/packages/spec/json-schema.manifest/automation.json @@ -5,6 +5,7 @@ "automation/ActionCategory", "automation/ActionDescriptor", "automation/ActionParadigm", + "automation/ApiTriggerSecretRef", "automation/ApprovalDecision", "automation/ApprovalEscalation", "automation/ApprovalNodeApprover", diff --git a/packages/spec/src/automation/api-trigger-secret-ref.zod.ts b/packages/spec/src/automation/api-trigger-secret-ref.zod.ts index 9595d223a73..f2333ec4996 100644 --- a/packages/spec/src/automation/api-trigger-secret-ref.zod.ts +++ b/packages/spec/src/automation/api-trigger-secret-ref.zod.ts @@ -46,6 +46,9 @@ export const API_TRIGGER_SECRET_REF_RESERVED_PREFIX = 'OS_'; /** An environment-variable-shaped name: a letter or `_`, then letters, digits and `_`. */ const SECRET_REF_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; +/** Not {@link API_TRIGGER_SECRET_REF_RESERVED_PREFIX}, in any case. */ +const NOT_RESERVED = /^(?![Oo][Ss]_)/; + /** * The reference itself: the name a deployment fills, never the secret. A * letter or `_` first, then letters, digits and `_` (an environment variable's @@ -60,8 +63,9 @@ export const ApiTriggerSecretRefSchema = z '(an environment variable\'s name, such as `SRM_HEARTBEAT_SECRET`). Write the name the deployment fills, ' + 'never the secret itself.', ) - .refine( - (name) => !name.toUpperCase().startsWith(API_TRIGGER_SECRET_REF_RESERVED_PREFIX), + // A pattern, not a `refine`, so the published JSON Schema carries the rule too. + .regex( + NOT_RESERVED, '`config.secretRef` may not name a variable in the platform\'s `OS_` namespace: whoever signs the posts holds ' + 'the hook secret, so a platform secret named here would be handed out with it. Name a variable of your own ' + '(`SRM_HEARTBEAT_SECRET`).', From b5c70707e495f8b356dcdd07a72a7786fdd4675e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:01:40 +0000 Subject: [PATCH 03/10] feat(service-automation,trigger-api): resolve an api flow's config.secretRef per post through the credential resolver Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01NZJKktPWaz2EZJ95a9XZa8 --- .../src/api-trigger-secret-ref.test.ts | 157 ++++++++++++++++++ .../src/credential-resolver.ts | 22 +++ .../services/service-automation/src/engine.ts | 35 +++- .../src/flow-credential-channel.test.ts | 20 +++ .../src/flow-credential-channel.ts | 5 +- .../src/flow-credential-projection.ts | 13 +- .../src/flow-definitions.ts | 28 +++- .../src/flow-registration-checks.ts | 5 +- .../services/service-automation/src/plugin.ts | 26 +-- .../trigger-api/src/api-trigger.test.ts | 7 + .../triggers/trigger-api/src/api-trigger.ts | 11 +- 11 files changed, 297 insertions(+), 32 deletions(-) create mode 100644 packages/services/service-automation/src/api-trigger-secret-ref.test.ts create mode 100644 packages/services/service-automation/src/credential-resolver.ts diff --git a/packages/services/service-automation/src/api-trigger-secret-ref.test.ts b/packages/services/service-automation/src/api-trigger-secret-ref.test.ts new file mode 100644 index 00000000000..f74e396df5e --- /dev/null +++ b/packages/services/service-automation/src/api-trigger-secret-ref.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An `api` flow whose start node NAMES its inbound hook secret + * (`config.secretRef`) instead of carrying it — the form an app package ships, + * so no credential is published with it. The engine registers it on the + * strength of the reference and hands its trigger a binding that resolves the + * name through the credential resolver at every post. + * + * Pinned against a recording `api` trigger: the reader resolves at every call + * (a rotation reaches the next post); a name with no value REJECTS with the + * ADR-0112 unavailable pair, naming the flow and the reference and never a + * value; the default resolver reads the environment; the channel is never + * asked, even where it holds the position; and a reference the spec refuses is + * refused at registration rather than armed. + * + * Every value is a probe sentinel, not a credential. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { AutomationEngine } from './engine.js'; +import type { FlowCredentialSource, FlowTrigger, FlowTriggerBinding } from './engine.js'; +import { FlowCredentialUnresolvableError } from './flow-credential-channel.js'; + +const REF = 'PIN_HOOK_SECRET_REF_4B1E'; +const FILLED = 'pin-ref-filled-0d93'; +const ROTATED = 'pin-ref-rotated-71a8'; +const HELD = 'pin-ref-channel-held-c6f2'; + +function quiet() { + return { debug() {}, info() {}, warn() {}, error() {} } as any; +} + +function inbound(name: string, startConfig: Record) { + return { + name, + label: name, + type: 'api', + status: 'active', + nodes: [ + { id: 'begin', type: 'start', label: 'Start', config: { hookId: 'h1', ...startConfig } }, + { id: 'finish', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'begin', target: 'finish' }], + }; +} + +describe('config.secretRef — an api flow names its hook secret; the deployment fills it', () => { + let started: FlowTriggerBinding[]; + const trigger: FlowTrigger = { type: 'api', start: (b) => { started.push(b); }, stop: () => {} }; + const saved = process.env[REF]; + + beforeEach(() => { + started = []; + delete process.env[REF]; + }); + afterEach(() => { + if (saved === undefined) delete process.env[REF]; + else process.env[REF] = saved; + }); + + function engineWith(values: Map, asked: string[] = []): AutomationEngine { + const engine = new AutomationEngine(quiet(), undefined, { + credentialResolver: (ref) => { + asked.push(ref); + return values.get(ref); + }, + }); + engine.registerTrigger(trigger); + return engine; + } + + it('registers with no secret in its definition, and its binding resolves the name at every post', async () => { + const values = new Map([[REF, FILLED]]); + const asked: string[] = []; + const engine = engineWith(values, asked); + await engine.registerFlow('ref_flow', inbound('ref_flow', { secretRef: REF }) as never); + + expect(started).toHaveLength(1); + const binding = started[0]!; + expect(binding.config).toEqual({ hookId: 'h1', secretRef: REF }); + expect(typeof binding.resolveSecret).toBe('function'); + // Registration asked nothing: the name is read per post, never at bind. + expect(asked).toEqual([]); + + expect(await binding.resolveSecret!()).toBe(FILLED); + values.set(REF, ROTATED); + expect(await binding.resolveSecret!()).toBe(ROTATED); + expect(asked).toEqual([REF, REF]); + }); + + it('a name with no value rejects every post as unavailable — naming the flow and the reference, never a value', async () => { + const values = new Map(); + const engine = engineWith(values); + await engine.registerFlow('ref_flow', inbound('ref_flow', { secretRef: REF }) as never); + const binding = started[0]!; + + for (const unfilled of [undefined, '', ' ']) { + if (unfilled === undefined) values.delete(REF); + else values.set(REF, unfilled); + const err = await binding.resolveSecret!().then( + () => { throw new Error('resolved'); }, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(FlowCredentialUnresolvableError); + expect((err as FlowCredentialUnresolvableError).code).toBe('SERVICE_UNAVAILABLE'); + expect((err as FlowCredentialUnresolvableError).status).toBe(503); + expect((err as Error).message).toContain(`Flow 'ref_flow'`); + expect((err as Error).message).toContain(`'${REF}'`); + } + // Filled later, the very same binding verifies the next post. + values.set(REF, FILLED); + expect(await binding.resolveSecret!()).toBe(FILLED); + }); + + it('the default resolver reads the environment variable of that name', async () => { + const engine = new AutomationEngine(quiet()); + engine.registerTrigger(trigger); + await engine.registerFlow('env_flow', inbound('env_flow', { secretRef: REF }) as never); + const binding = started[0]!; + + await expect(binding.resolveSecret!()).rejects.toBeInstanceOf(FlowCredentialUnresolvableError); + process.env[REF] = FILLED; + expect(await binding.resolveSecret!()).toBe(FILLED); + }); + + it('the channel is never asked for a referenced secret, even where it holds that position', async () => { + const values = new Map([[REF, FILLED]]); + const engine = engineWith(values); + const channelAsked: string[] = []; + const source: FlowCredentialSource = { + holds: () => true, + held: () => [{ nodeId: 'begin', key: 'secret' }], + resolve: async (f, n, k) => { channelAsked.push(`${f}/${n}/${k}`); return HELD; }, + }; + engine.setFlowCredentialSource(source); + await engine.registerFlow('ref_flow', inbound('ref_flow', { secretRef: REF }) as never); + + expect(await started[0]!.resolveSecret!()).toBe(FILLED); + expect(channelAsked).toEqual([]); + }); + + it('refuses at registration a reference the spec refuses — never arms it', () => { + const engine = engineWith(new Map([[REF, FILLED]])); + const refused: Array<[string, Record]> = [ + ['reserved', { secretRef: 'OS_AUTH_SECRET' }], + ['blank', { secretRef: '' }], + ['beside a literal', { secretRef: REF, secret: 'pin-literal-ignored' }], + ]; + for (const [label, config] of refused) { + // `registerFlow` parses first and throws the parse's refusal, located at the reference. + expect(() => engine.registerFlow(`bad_${label.replace(/ /g, '_')}`, inbound('bad', config) as never), label) + .toThrow(/"secretRef"/); + } + expect(started).toEqual([]); + }); +}); diff --git a/packages/services/service-automation/src/credential-resolver.ts b/packages/services/service-automation/src/credential-resolver.ts new file mode 100644 index 00000000000..b252e5eb073 --- /dev/null +++ b/packages/services/service-automation/src/credential-resolver.ts @@ -0,0 +1,22 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Resolves a credential REFERENCE to its plaintext secret, or `undefined`/empty + * when unknown. Two references go through it: a declarative connector's + * `auth.credentialRef` (at boot — an unresolved one is a hard boot error), and + * an `api` flow's start-node `config.secretRef` (at every inbound post — an + * unresolved one refuses that post as unavailable). May be async so an + * enterprise resolver can hit a vault. The open-tier default reads from + * `process.env` (see {@link defaultEnvCredentialResolver}). + */ +export type CredentialResolver = (ref: string) => string | undefined | Promise; + +/** + * Open-tier credential resolver (ADR-0097 §3, ADR-0015 open/enterprise line): + * a reference is the name of an environment variable. This is the + * degraded-but-honest story for environments without a managed secrets service — + * static credentials from env/config are open source; managed vaulting, OAuth2 + * refresh, and per-tenant connection lifecycle are the enterprise tier. + */ +export const defaultEnvCredentialResolver: CredentialResolver = (ref) => + typeof process !== 'undefined' ? process.env?.[ref] : undefined; diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 45686efa721..d64532cdeec 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -69,6 +69,7 @@ import { summarizeRun } from './run-summary.js'; // provider factory's text), so it renders it as structured `meta` rather than // interpolating it into the log message. See ./thrown-cause-diagnostics.ts. import { describeThrownForLog } from './thrown-cause-diagnostics.js'; +import { defaultEnvCredentialResolver, type CredentialResolver } from './credential-resolver.js'; // [#22450] The one bundle-locale negotiation rule (exact → case-insensitive → // base language → variant expansion), asked about the locales the i18n service // holds — the same question objectql's `negotiatedMessageLocale` asks it. @@ -556,11 +557,14 @@ export interface FlowTriggerBinding { /** * [#20790] api: the inbound hook's secret, read at VERIFICATION time — * present whenever the flow has one, whether its definition carries it as - * a literal (a packaged flow) or the write-only credential channel holds it - * (every flow stored through the metadata save door; `config` then carries - * none). The channel's row wins where one exists. Never cached: a rotation - * applies to the next post. Rejects when a held secret does not come back — - * the trigger answers that post as unavailable, never as verified. + * a literal (a packaged flow), names it by `config.secretRef` (the + * deployment fills the name; read through the credential resolver), or the + * write-only credential channel holds it (every flow stored through the + * metadata save door; `config` then carries none). The channel's row wins + * over a literal where one exists. Never cached: a rotation applies to the + * next post. Rejects when a held secret does not come back, or a named one + * resolves to nothing — the trigger answers that post as unavailable, + * never as verified. */ readonly resolveSecret?: () => Promise; } @@ -930,6 +934,15 @@ export interface AutomationEngineOptions { * it under an `isolated` posture. */ scheduledWorkPolicy?: ScheduledWorkPolicy | (() => ScheduledWorkPolicy); + /** + * Resolves an `api` flow's start-node `config.secretRef` — the name of its + * inbound hook secret, which the deployment fills — at every post. The + * automation plugin hands its own `credentialResolver` here, so a + * connector's `credentialRef` and a hook's `secretRef` resolve through one + * resolver. Absent: {@link defaultEnvCredentialResolver}, an environment + * variable of that name. + */ + credentialResolver?: CredentialResolver; } /** @@ -2690,6 +2703,8 @@ export class AutomationEngine implements IAutomationService { private readonly runSummaryLog: RunSummaryLogLevel; /** [#19834] Per-kernel policy override; see {@link AutomationEngineOptions.scheduledWorkPolicy}. */ private readonly scheduledWorkPolicy: AutomationEngineOptions['scheduledWorkPolicy']; + /** Reads an `api` flow's `config.secretRef` per post — {@link AutomationEngineOptions.credentialResolver}. */ + private readonly credentialResolver: CredentialResolver; private logger: Logger; /** * Runs paused at a node, keyed by runId (ADR-0019). Process-local copy of @@ -2808,6 +2823,7 @@ export class AutomationEngine implements IAutomationService { this.maxLogSize = options?.maxLogSize ?? DEFAULT_MAX_EXECUTION_LOG_SIZE; this.runSummaryLog = options?.runSummaryLog ?? 'info'; this.scheduledWorkPolicy = options?.scheduledWorkPolicy; + this.credentialResolver = options?.credentialResolver ?? defaultEnvCredentialResolver; } /** @@ -4727,6 +4743,14 @@ export class AutomationEngine implements IAutomationService { * after its last refresh (boot, `kernel:ready`, `metadata:reloaded`, every * channel write in this process) loses to the literal until the next * refresh, exactly as at the `http` node. + * + * A secret NAMED by `config.secretRef` (the form an app package ships, so + * no credential is published with it) yields a reader that asks + * {@link credentialResolver} for that name at every post, and never the + * channel. A name that resolves to nothing rejects with + * `FlowCredentialUnresolvableError`, which the trigger answers `503` and + * logs naming the flow and the reference; a reference the spec refuses + * yields no reader, so the flow is refused at registration. */ private hookSecretResolver( flowName: string, @@ -4737,6 +4761,7 @@ export class AutomationEngine implements IAutomationService { const readFlowCredentialSource = () => this.flowCredentialSource; const host: ThisParameterType = { get flowCredentialSource() { return readFlowCredentialSource(); }, + credentialResolver: this.credentialResolver, }; return hookSecretResolver.call(host, flowName, startNodeId, config); } diff --git a/packages/services/service-automation/src/flow-credential-channel.test.ts b/packages/services/service-automation/src/flow-credential-channel.test.ts index 53da162b51e..6eeb15d5863 100644 --- a/packages/services/service-automation/src/flow-credential-channel.test.ts +++ b/packages/services/service-automation/src/flow-credential-channel.test.ts @@ -200,6 +200,26 @@ describe('[#20790] the per-position write rule', () => { expect(await channelRows(ql)).toEqual([]); }); + it('a start node that names its secret by config.secretRef loses the held row, and the gate is told nothing', async () => { + const ql = await boot(); + const channel = new FlowCredentialChannel(() => ql as never); + await channel.store({ name: 'pin_inbound', state: 'active', body: inbound({ hook: HOOK, sign: SIGN }) }); + + const referenced: any = inbound(); + startNodeOf(referenced).config.secretRef = 'PIN_INBOUND_HOOK_SECRET'; + expect(await channel.heldPaths({ name: 'pin_inbound', state: 'active', item: referenced })).toEqual([ + 'nodes.1.config.signingSecret', + ]); + const stored = await channel.store({ name: 'pin_inbound', state: 'active', body: referenced }); + // The reference is stored as written — it is a name, not a credential. + expect(startNodeOf(stored).config).toEqual({ triggerType: 'api', hookId: 'h1', secretRef: 'PIN_INBOUND_HOOK_SECRET' }); + expect(channel.holds('pin_inbound', 'begin', 'secret')).toBe(false); + expect(await channel.resolve('pin_inbound', 'begin', 'secret')).toBeUndefined(); + // The other credential is untouched. + expect(await channel.resolve('pin_inbound', 'callout', 'signingSecret')).toBe(SIGN); + expect((await channelRows(ql)).map((r) => r.credential_key)).toEqual(['signingSecret']); + }); + it('the runtime gate is told where a withheld credential is held — never where one is cleared', async () => { const ql = await boot(); const channel = new FlowCredentialChannel(() => ql as never); diff --git a/packages/services/service-automation/src/flow-credential-channel.ts b/packages/services/service-automation/src/flow-credential-channel.ts index 06f7e65e07b..b012444438d 100644 --- a/packages/services/service-automation/src/flow-credential-channel.ts +++ b/packages/services/service-automation/src/flow-credential-channel.ts @@ -30,7 +30,10 @@ * - the cleared form `''` → the channel's row is removed and `''` is stored, * so "cleared" stays distinguishable from "withheld"; * - a row whose position the definition no longer has (the node removed, or - * its kind changed) → removed: a credential never outlives its position. + * its kind changed) → removed: a credential never outlives its position; + * - the start node's secret NAMED instead (`config.secretRef`, no `secret`) + * → removed too: the deployment fills that name and the engine resolves it + * per post, so a stored literal would only be a stale credential at rest. * * Every write of a VALUE comes first: with no crypto provider the engine * refuses the first one before anything — channel row or stored row — is diff --git a/packages/services/service-automation/src/flow-credential-projection.ts b/packages/services/service-automation/src/flow-credential-projection.ts index 7bc6d37eed0..6538fd6ec37 100644 --- a/packages/services/service-automation/src/flow-credential-projection.ts +++ b/packages/services/service-automation/src/flow-credential-projection.ts @@ -112,7 +112,7 @@ * (Prime Directive #2). */ -import { FLOW_REGION_SLOTS_BY_TYPE } from '@objectstack/spec/automation'; +import { API_TRIGGER_SECRET_REF_KEY, FLOW_REGION_SLOTS_BY_TYPE } from '@objectstack/spec/automation'; import { registerMetadataTypeRedactor } from '@objectstack/spec/kernel'; import type { MetadataRedactionResult, MetadataTypeRedactor } from '@objectstack/spec/kernel'; @@ -182,7 +182,11 @@ function isPlainRecord(value: unknown): value is Record { * - `cleared` — {@link FLOW_CREDENTIAL_CLEARED}: no credential, on purpose; * - `value` — a non-empty string: an explicit credential (a literal); * - `unusable` — anything else: no credential a door can use, and the engine - * refuses it where one is required. + * refuses it where one is required; + * - `referenced` — the start node's secret is absent and NAMED instead + * (`config.secretRef`): the deployment fills the name and the + * engine resolves it per post, so the channel holds nothing + * here — a row it held is removed, like a cleared one. */ export interface FlowCredentialPosition { /** Dotted, item-relative (`nodes.0.config.secret`) — the path spelling `redactedKeys` and the runtime gate use. */ @@ -190,7 +194,7 @@ export interface FlowCredentialPosition { readonly nodeId: string; readonly nodeType: string; readonly key: string; - readonly form: 'absent' | 'cleared' | 'value' | 'unusable'; + readonly form: 'absent' | 'cleared' | 'value' | 'unusable' | 'referenced'; /** The literal, for `form: 'value'` only. */ readonly value?: string; } @@ -213,7 +217,8 @@ function collectNodePosition(node: unknown, path: string, out: FlowCredentialPos const has = !!config && Object.prototype.hasOwnProperty.call(config, key); const raw = has ? config![key] : undefined; const form: FlowCredentialPosition['form'] = !has - ? 'absent' + ? (key === FLOW_HOOK_SECRET_KEY && !!config + && Object.prototype.hasOwnProperty.call(config, API_TRIGGER_SECRET_REF_KEY) ? 'referenced' : 'absent') : raw === FLOW_CREDENTIAL_CLEARED ? 'cleared' : typeof raw === 'string' diff --git a/packages/services/service-automation/src/flow-definitions.ts b/packages/services/service-automation/src/flow-definitions.ts index 1ecaa6084f8..94b953b85a8 100644 --- a/packages/services/service-automation/src/flow-definitions.ts +++ b/packages/services/service-automation/src/flow-definitions.ts @@ -19,7 +19,8 @@ import type { ConversionNotice, ConversionTodoNotice } from '@objectstack/spec'; import { applyConversionsToFlow } from '@objectstack/spec'; import { FLOW_HOOK_SECRET_KEY, flowCredentialClassLabel, flowCredentialPositions } from './flow-credential-projection.js'; import type { FlowParsed } from '@objectstack/spec/automation'; -import { FlowSchema, validateControlFlow } from '@objectstack/spec/automation'; +import { API_TRIGGER_SECRET_REF_KEY, FlowSchema, apiTriggerSecretRefOf, validateControlFlow } from '@objectstack/spec/automation'; +import { FlowCredentialUnresolvableError } from './flow-credential-channel.js'; // The values below (`CONVERSIONS_NOT_REPLAYED_AT_REHYDRATION`, // `flowRegistrationTodo` and `graftConditionEnvelopes`) are read only inside // the function bodies, at call time, never while a module is evaluating, so the @@ -70,6 +71,7 @@ export interface FlowDefinitionsHost { readonly triggers: AutomationEngine['triggers']; readonly flowCredentialSource: AutomationEngine['flowCredentialSource']; readonly flowVersionHistory: AutomationEngine['flowVersionHistory']; + readonly credentialResolver: AutomationEngine['credentialResolver']; } /** The body of {@link AutomationEngine.resolveFunction}, moved unedited; that method's docblock is its contract. */ @@ -336,13 +338,35 @@ export function flowCredentialHoldings(this: Pick, + this: Pick, flowName: string, startNodeId: string | undefined, config: Record, ): (() => Promise) | undefined { const source = this.flowCredentialSource; const written = Object.prototype.hasOwnProperty.call(config, FLOW_HOOK_SECRET_KEY); + // A secret NAMED by `config.secretRef`: the deployment fills the name, and + // it is read through the credential resolver at every post — never the + // channel, whose row for this position the save door removes once the + // definition names its secret instead. A name with no value refuses that + // post as unavailable; a reference the spec does not accept (or one beside + // a literal, which `FlowSchema` refuses) yields no reader, so the flow is + // refused at registration rather than armed on a guess. + if (Object.prototype.hasOwnProperty.call(config, API_TRIGGER_SECRET_REF_KEY)) { + const ref = apiTriggerSecretRefOf(config); + if (ref === undefined || written) return undefined; + const resolve = this.credentialResolver; + return async () => { + const value = await resolve(ref); + if (typeof value === 'string' && value.trim() !== '') return value; + throw new FlowCredentialUnresolvableError( + `Flow '${flowName}': its inbound hook secret is named by \`config.secretRef\` '${ref}', which resolved ` + + 'to no value, so every post is refused until the deployment fills it. Set the ' + + `'${ref}' environment variable, or serve '${ref}' from the host's credential resolver ` + + '(AutomationServicePluginOptions.credentialResolver).', + ); + }; + } const literal = typeof config.secret === 'string' && config.secret.trim() !== '' ? config.secret : undefined; if (written && literal === undefined) return undefined; if (!source || startNodeId === undefined) { diff --git a/packages/services/service-automation/src/flow-registration-checks.ts b/packages/services/service-automation/src/flow-registration-checks.ts index 621cd68e49a..70ab76342be 100644 --- a/packages/services/service-automation/src/flow-registration-checks.ts +++ b/packages/services/service-automation/src/flow-registration-checks.ts @@ -355,8 +355,9 @@ export function validateApiTriggerSecret(this: Pick resolveInsidePackageRoot(packageRoot, relativePath, 'paths'); } -/** - * Resolves a declarative connector `credentialRef` to its plaintext secret, or - * `undefined`/empty when unknown (which the materializer turns into a hard boot - * error). May be async so an enterprise resolver can hit a vault. The open-tier - * default reads from `process.env` (see {@link defaultEnvCredentialResolver}). - */ -export type CredentialResolver = (ref: string) => string | undefined | Promise; - -/** - * Open-tier credential resolver (ADR-0097 §3, ADR-0015 open/enterprise line): - * a `credentialRef` is the name of an environment variable. This is the - * degraded-but-honest story for environments without a managed secrets service — - * static credentials from env/config are open source; managed vaulting, OAuth2 - * refresh, and per-tenant connection lifecycle are the enterprise tier. - */ -export const defaultEnvCredentialResolver: CredentialResolver = (ref) => - typeof process !== 'undefined' ? process.env?.[ref] : undefined; +// The credential resolver lives in ./credential-resolver.ts so the engine reads +// the same one for an `api` flow's `config.secretRef`; re-exported unchanged. +export { defaultEnvCredentialResolver, type CredentialResolver }; /** * Retry backoff for **degraded** declarative instances (#3017) — provider-bound @@ -952,6 +943,7 @@ export class AutomationServicePlugin implements Plugin { maxLogSize: this.options.maxLogSize, runSummaryLog: this.options.runSummaryLog, scheduledWorkPolicy: this.options.scheduledWorkPolicy, + credentialResolver: this.options.credentialResolver, }); // [#20790] The write-only flow credential channel — the engine reads a diff --git a/packages/triggers/trigger-api/src/api-trigger.test.ts b/packages/triggers/trigger-api/src/api-trigger.test.ts index 9880c725a23..8e5cefd36cb 100644 --- a/packages/triggers/trigger-api/src/api-trigger.test.ts +++ b/packages/triggers/trigger-api/src/api-trigger.test.ts @@ -244,6 +244,13 @@ describe('ApiTrigger — a secret read at verification time', () => { } }); + it('a secret named by config.secretRef arms only through the engine\'s reader — the trigger never reads the name', () => { + const t = new ApiTrigger(() => makeFakeQueue(), logger as any); + expect(() => t.start({ flowName: 'ref_only', config: { secretRef: 'PIN_TRIGGER_REF' } }, async () => {})) + .toThrow(/config\.secretRef/); + expect(t.listHooks()).toEqual([]); + }); + it('a wrong hook id is refused before any secret is read', async () => { let reads = 0; const { t } = armWith(async () => { reads += 1; return HELD; }); diff --git a/packages/triggers/trigger-api/src/api-trigger.ts b/packages/triggers/trigger-api/src/api-trigger.ts index aff2541dd93..96225269d3a 100644 --- a/packages/triggers/trigger-api/src/api-trigger.ts +++ b/packages/triggers/trigger-api/src/api-trigger.ts @@ -227,6 +227,13 @@ export function verifySignature(secret: string, rawBody: string, header: string * credential store instead, and the engine's binding reads it * per post through `resolveSecret`; a post whose secret cannot * be read is answered `503`, never verified against nothing. + * - `secretRef` — the NAME of the secret, in place of `secret`, for a flow + * that ships in a package: the deployment fills the name and + * the automation engine's `resolveSecret` reads it at every + * post, answering a name with no value `503` like an + * unreadable held secret. This trigger never reads the name + * itself — a host that binds without that engine has no + * `resolveSecret`, and the flow is refused as above. */ export class ApiTrigger implements FlowTrigger { readonly type = 'api'; @@ -259,7 +266,9 @@ export class ApiTrigger implements FlowTrigger { throw new Error( `[trigger-api] flow '${binding.flowName}' not armed: its start node declares no \`config.secret\`. ` + `An inbound hook is armed only with a per-flow secret that every post is HMAC-verified ` + - `against (ADR-0041) — set a non-blank \`config.secret\` on the flow's start node.`, + `against (ADR-0041) — set a non-blank \`config.secret\` on the flow's start node, or name ` + + `the secret in \`config.secretRef\` and bind the flow through the automation engine, which ` + + `resolves that name at every post.`, ); } const queue = `${QUEUE_PREFIX}:${binding.flowName}`; From 33b6f45d0877f32f728c6003976b76dc2d4a64d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:02:10 +0000 Subject: [PATCH 04/10] chore(showcase): the inbound webhook flow names its secret (SHOWCASE_WEBHOOK_SECRET) instead of carrying a literal Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01NZJKktPWaz2EZJ95a9XZa8 --- docs/qa/platform-checklist/areas/automation.json | 6 +++--- examples/app-showcase/src/automation/flows/index.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/qa/platform-checklist/areas/automation.json b/docs/qa/platform-checklist/areas/automation.json index dcc8cfa9e43..bd886011e85 100644 --- a/docs/qa/platform-checklist/areas/automation.json +++ b/docs/qa/platform-checklist/areas/automation.json @@ -465,7 +465,7 @@ "on-demand chain: POST /api/v1/automation//trigger for showcase_batch_reminders {params:{tasks:[…]}} (loop), showcase_inquiry_purge (get_record+decision+delete_record — seed at least one closed showcase_inquiry first), showcase_release_signoff {params:{items:[…]}} (map)", "browser chain: run the Tasks row action showcase_bulk_reassign → showcase_reassign_wizard (screen + update_record)", "approval chain: PATCH a showcase_expense_report to submitted (approval via showcase_expense_signoff); PATCH a showcase_project budget above 100000 to open showcase_budget_approval, then POST /api/v1/approvals/requests/:id/revise so the run parks at the approval_revise node", - "webhook chain: POST /api/v1/automation/hooks/showcase_inbound_task_webhook/intake with a valid x-objectstack-signature HMAC (secret 'showcase-webhook-secret') to exercise create_record", + "webhook chain: POST /api/v1/automation/hooks/showcase_inbound_task_webhook/intake with a valid x-objectstack-signature HMAC to exercise create_record — the flow names its secret (config.secretRef: 'SHOWCASE_WEBHOOK_SECRET'), so boot with that environment variable set to a value of your choosing and sign with it; unset, the hook answers 503", "for EACH variant: GET the owning flow's runs (GET /api/v1/automation//runs + /runs/:runId) and locate a step whose nodeType equals the variant; record run id, step status, and — for suspending types — the paused-then-resumed transition", "screenshot the Runs panel of one composite run (showcase_project_escalation or showcase_fan_out_notify) showing the container-nested steps", "unknown-type probe (the late-binding contract, ruled status quo in #7545): POST /api/v1/automation a flow whose one non-start node carries type 'bogus_node' — capture the HTTP status and the server log record the registration emits, then POST /api/v1/automation//trigger and read GET /api/v1/automation//runs/:runId" @@ -617,10 +617,10 @@ "time_relative" ], "steps": [ - "boot the showcase on its own port and DB (dogfood §0) with OS_AUTOMATION_SCHEDULED_WORK_ENABLED=1 and OS_PORT exported (fixtures.requires — without the first, the schedule and time_relative variants can never fire); sign in as the dev admin", + "boot the showcase on its own port and DB (dogfood §0) with OS_AUTOMATION_SCHEDULED_WORK_ENABLED=1, OS_PORT and SHOWCASE_WEBHOOK_SECRET (any value; the api flow's config.secretRef names it) exported (fixtures.requires — without the first, the schedule and time_relative variants can never fire; without the last, the api hook answers 503); sign in as the dev admin", "record_change (create leg): POST /api/v1/data/showcase_task with priority='urgent' — showcase_urgent_task_alert fires via record-after-write with previous == null (#3427)", "record_change (update leg): POST a normal-priority task, then PATCH priority→'urgent'; also move a task to done — create it with status 'in_review' and its required project (task_status_flow allows done only from in_review, and a create is not transition-checked), then PATCH status→'done' — and confirm a further save WITHOUT a transition fires nothing", - "api: POST /api/v1/automation/hooks/showcase_inbound_task_webhook/intake with JSON {title, assignee, project} and header x-objectstack-signature: t=,v1=.', key 'showcase-webhook-secret'> — signHttpBodyAt(rawBody, 'showcase-webhook-secret', Math.floor(Date.now() / 1000)) from @objectstack/core writes it, and the door refuses a t more than 300 s from the server clock — expect a 202 ACK; then find the created showcase_task", + "api: POST /api/v1/automation/hooks/showcase_inbound_task_webhook/intake with JSON {title, assignee, project} and header x-objectstack-signature: t=,v1=.', key = the value the boot exported as SHOWCASE_WEBHOOK_SECRET, which the flow's config.secretRef names> — signHttpBodyAt(rawBody, process.env.SHOWCASE_WEBHOOK_SECRET, Math.floor(Date.now() / 1000)) from @objectstack/core writes it, and the door refuses a t more than 300 s from the server clock — expect a 202 ACK; then find the created showcase_task", "schedule: wait up to ~60s for showcase_scheduled_digest (interval 60000ms) to tick; wait a second interval for a second tick", "screen: from /apps/showcase_app/showcase_task run the row action showcase_bulk_reassign, fill the dialog, submit", "autolaunched: POST /api/v1/automation/showcase_batch_reminders/trigger {params:{tasks:[…]}}", diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index a47c39e3548..eac7accd0f0 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -1588,7 +1588,7 @@ export const ReleaseSignoffFlow = defineFlow({ * or a schedule. The start node's config arms the hook: * * POST /api/v1/automation/hooks/showcase_inbound_task_webhook/intake - * x-objectstack-signature: t=,v1=.", key below> + * x-objectstack-signature: t=,v1=.", key = $SHOWCASE_WEBHOOK_SECRET> * { "title": "...", "assignee": "...", "project": "" } * * `signHttpBodyAt(rawBody, secret, Math.floor(Date.now() / 1000))` from @@ -1619,8 +1619,11 @@ export const InboundTaskWebhookFlow = defineFlow({ config: { triggerType: 'api', hookId: 'intake', - // Demo secret — real deployments inject this from configuration. - secret: 'showcase-webhook-secret', + // The secret is NAMED, never written here: the deployment sets the + // SHOWCASE_WEBHOOK_SECRET environment variable (or serves that name + // from its credential resolver), and the engine reads it at every + // post. Until it is set, every post is refused 503. + secretRef: 'SHOWCASE_WEBHOOK_SECRET', }, }, { From 0c81e6e6b7127d1d371d502ebdd52c300a1086f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:03:32 +0000 Subject: [PATCH 05/10] docs: config.secretRef in the flows and connectors guides, the automation skill, and the changeset Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01NZJKktPWaz2EZJ95a9XZa8 --- .changeset/25675-api-flow-secret-ref.md | 33 +++++++++++++++++++++++++ content/docs/automation/connectors.mdx | 5 ++++ content/docs/automation/flows.mdx | 2 +- skills/objectstack-automation/SKILL.md | 2 +- 4 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 .changeset/25675-api-flow-secret-ref.md diff --git a/.changeset/25675-api-flow-secret-ref.md b/.changeset/25675-api-flow-secret-ref.md new file mode 100644 index 00000000000..a6361beff59 --- /dev/null +++ b/.changeset/25675-api-flow-secret-ref.md @@ -0,0 +1,33 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/service-automation': minor +'@objectstack/trigger-api': minor +--- + +feat(spec): an `api` flow's start node may name its inbound hook secret (`config.secretRef`) instead of carrying it + +An app package can now ship an inbound `api` flow without publishing its credential. The start node names the secret, the deployment fills that name, and the engine reads it at every post: + +```ts +{ + id: 'start', + type: 'start', + config: { triggerType: 'api', hookId: 'heartbeat', secretRef: 'SRM_HEARTBEAT_SECRET' }, +} +``` + +- The name resolves through the automation plugin's `credentialResolver`, the one a declarative connector's `auth.credentialRef` already uses: in the open tier it is the environment variable of that name (`process.env.SRM_HEARTBEAT_SECRET`); a host that wires a secrets service serves it from there. +- It is read per post, never cached, so a rotation applies to the next post. A post that arrives while the name has no value is refused `503 SERVICE_UNAVAILABLE` and is never verified; the server log names the flow and the reference, never a value. +- `FlowSchema` refuses a reference that is not a name (a letter or `_`, then letters, digits and `_`), one in the platform's `OS_` namespace (whoever signs the posts holds the hook secret, so a platform secret named there would be handed out with it), one beside a literal `secret`, and one on a flow that binds no inbound `api` trigger. +- `os validate` (`flow-api-trigger-secret-missing`) and the engine's registration check accept a usable reference in place of a literal, and the remedy they print now names `config.secretRef`. +- The reference is a name, not a credential: a flow read serves it as written. Saving a flow through the metadata API with a reference removes any hook secret the write-only flow credential store held for that start node. +- New exports from `@objectstack/spec/automation`: `ApiTriggerSecretRefSchema`, `apiTriggerSecretRefOf`, `apiTriggerSecretRefRefusals`, `API_TRIGGER_SECRET_REF_KEY`, `API_TRIGGER_SECRET_REF_RESERVED_PREFIX`. `AutomationEngineOptions` gains `credentialResolver`. + +A literal `config.secret` still works. To move a flow off one, replace `secret: ''` with `secretRef: 'YOUR_NAME'` and set `YOUR_NAME` in the deployment's environment. + +Clause-②: yes (widening) + +Metadata-protocol: added + +Ruling-ref: 6386630545 diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index c5fc9630139..dcf58c3f307 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -235,6 +235,11 @@ The enterprise tier swaps in a vault/KMS-backed resolver (`AutomationServicePluginOptions.credentialResolver`) without changing what you author: the entry still just names a ref. +The same resolver serves an `api` flow's start-node `config.secretRef`, the +name of its inbound hook secret. That one is read at every post rather than at +boot, so a name with no value refuses each post with `503` instead of failing +the boot ([Flows](/docs/automation/flows)). + Inlining a secret is rejected when the stack is validated. Writing the runtime shape on a provider-bound instance — diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index ec95565e2e2..bf9aac488eb 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -515,7 +515,7 @@ the call, so a `durable` callout enqueues exactly the request the inline call would send. -A flow definition, including an `http` node's `url` and `headers`, is served to every member who can read flows. A token, API key or signed webhook url written there is readable by all of them. Route an outbound credential by where it sits, and call the connector with a `connector_action` node instead — see [Connectors](/docs/automation/connectors#authentication). A credential in a header goes to a declarative connector with `bearer`, `basic` or `api-key` auth, whose `auth.credentialRef` names the secret. A key in the query string goes to `api-key` auth with `paramName`. No `credentialRef` variant carries a secret in the url path, so an incoming-webhook url (whose path is the secret) cannot be routed that way: call the service through a token-authenticated connector instead, such as the `slack` connector, whose bot token is supplied to the plugin by host code rather than written in the flow (it is registered by that plugin, not declared as a `connectors:` instance). Otherwise such a url is served with the definition. Only `signingSecret` and a start node's `secret` are kept out of a flow saved through the metadata API: the metadata save door stores each in a write-only, encrypted flow credential store, a read never returns it, and the engine reads it only to verify an inbound post or sign an outbound request. A packaged flow keeps its literal in its package source. The literal is withheld when the definition is served, and a credential store row for that flow, where one exists, wins when the engine verifies or signs. `url` and `headers` are stored and served as written. +A flow definition, including an `http` node's `url` and `headers`, is served to every member who can read flows. A token, API key or signed webhook url written there is readable by all of them. Route an outbound credential by where it sits, and call the connector with a `connector_action` node instead — see [Connectors](/docs/automation/connectors#authentication). A credential in a header goes to a declarative connector with `bearer`, `basic` or `api-key` auth, whose `auth.credentialRef` names the secret. A key in the query string goes to `api-key` auth with `paramName`. No `credentialRef` variant carries a secret in the url path, so an incoming-webhook url (whose path is the secret) cannot be routed that way: call the service through a token-authenticated connector instead, such as the `slack` connector, whose bot token is supplied to the plugin by host code rather than written in the flow (it is registered by that plugin, not declared as a `connectors:` instance). Otherwise such a url is served with the definition. Only `signingSecret` and a start node's `secret` are kept out of a flow saved through the metadata API: the metadata save door stores each in a write-only, encrypted flow credential store, a read never returns it, and the engine reads it only to verify an inbound post or sign an outbound request. A flow that ships in a package names its inbound hook secret instead of carrying it: the start node's `config.secretRef` holds the name the deployment fills (an environment variable of that name in the open tier, resolved through the same credential resolver as a connector's `auth.credentialRef`), and the engine reads it at every post, so no credential is published with the package. A post that arrives while the name has no value is answered `503`, never verified. The reference is a name, not a credential: it is served as written, it may not sit beside a literal `secret`, and it may not name a variable in the platform's `OS_` namespace. A packaged flow that carries a literal keeps it in its package source; the literal is withheld when the definition is served, and a credential store row for that flow, where one exists, wins when the engine verifies or signs. `url` and `headers` are stored and served as written. **Script:** diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index 7e85ab9c1aa..62c168d4027 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -346,7 +346,7 @@ node `config`** (the start `config` is a free-form record with no Zod shape): | `config` key | Purpose | |:-------------|:--------| | `hookId` | URL path token (default `'default'`). **Rotate it to revoke** a leaked endpoint | -| `secret` | HMAC-SHA256 shared secret. **Required** — without a non-blank one the flow is refused at registration (`os validate` too) and never armed at boot; the signature goes in `x-objectstack-signature` | +| `secretRef` / `secret` | The HMAC-SHA256 shared secret, **required** as one of the two — without it the flow is refused at registration (`os validate` too). A package ships `secretRef: 'NAME'`, the name the deployment fills (an env var in the open tier, never `OS_*`), read per post — unfilled ⇒ `503`; never commit a literal `secret` | - **Signature:** `t=,v1=` — `v1` = HMAC-SHA256 of `.` under `secret`; refused when `t` is over 300 s from the server clock. - **Idempotency:** `x-idempotency-key` dedupes retries — author the flow to be idempotent (delivery is at-least-once). From a2cd88806f20751e2385dedc8e54c52147d2ba7b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:20:24 +0000 Subject: [PATCH 06/10] chore(spec): the ApiTriggerSecretRef type alias and its regenerated artifacts Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01NZJKktPWaz2EZJ95a9XZa8 --- .../automation/api-trigger-secret-ref.mdx | 59 +++++++++++++++++++ content/docs/references/automation/index.mdx | 3 +- content/docs/references/automation/meta.json | 1 + content/docs/references/index.mdx | 9 +-- packages/spec/api-surface/automation.json | 7 +++ packages/spec/declaration-map/automation.json | 2 + packages/spec/export-origins/automation.json | 7 +++ .../automation/api-trigger-secret-ref.zod.ts | 2 + .../references/_index.md | 1 + 9 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 content/docs/references/automation/api-trigger-secret-ref.mdx diff --git a/content/docs/references/automation/api-trigger-secret-ref.mdx b/content/docs/references/automation/api-trigger-secret-ref.mdx new file mode 100644 index 00000000000..447c9fd9462 --- /dev/null +++ b/content/docs/references/automation/api-trigger-secret-ref.mdx @@ -0,0 +1,59 @@ +--- +title: Api Trigger Secret Ref — Automation Protocol +navTitle: Api Trigger Secret Ref +description: "An api flow's start node arms an inbound hook that verifies every post against a per-flow HMAC secret." +--- + +{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} + +## An inbound `api` hook's secret, by reference + +An `api` flow's start node arms an inbound hook that verifies every post +against a per-flow HMAC secret (ADR-0041). Until this key the only way to +declare that secret in a definition was the literal `config.secret`, so an +app package could not ship an `api` flow without publishing its credential: +a packaged flow cannot be overlaid, so the write-only flow credential store +the metadata save door fills is out of a package's reach. + +`config.secretRef` names the secret instead of carrying it. The deployment +supplies the value under that name, and the automation engine reads it at +every inbound post through the same credential resolver a declarative +connector's `auth.credentialRef` goes through (ADR-0097 §3): an environment +variable of that name in the open tier, a secrets service where the host +wires one. A post that arrives while the name resolves to no value is +refused as unavailable, never verified against nothing. + +The reference is NOT a credential: it is served with the definition like any +other key, and only the literal `config.secret` is withheld on read. + +Three refusals, each anchored on the start node's `config`: + + - a reference that is not a name — `ApiTriggerSecretRefSchema`; + - a start node that declares both `secret` and `secretRef` — two sources for + one credential, and no reader could say which one an author meant; + - a reference on a flow that binds no inbound `api` trigger — nothing would + ever read it, so a deployment would fill a name for nothing. + + +**Source:** `packages/spec/src/automation/api-trigger-secret-ref.zod.ts` + + +## TypeScript Usage + +```typescript +import { ApiTriggerSecretRefSchema } from '@objectstack/spec/automation'; +import type { ApiTriggerSecretRef } from '@objectstack/spec/automation'; + +// Validate data +const result = ApiTriggerSecretRefSchema.parse(data); +``` + +--- + +## ApiTriggerSecretRef + +The name of the inbound hook's HMAC secret, filled by the deployment and read at every post through the automation credential resolver (an environment variable of that name in the open tier). In place of a literal `secret`, never beside it; not in the `OS_` namespace. + + +--- + diff --git a/content/docs/references/automation/index.mdx b/content/docs/references/automation/index.mdx index 439f2c5b34f..402ee94e3f8 100644 --- a/content/docs/references/automation/index.mdx +++ b/content/docs/references/automation/index.mdx @@ -1,7 +1,7 @@ --- title: Automation Protocol — schema reference navTitle: Automation Protocol -description: "The ObjectStack Automation Protocol in 13 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." +description: "The ObjectStack Automation Protocol in 14 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -9,6 +9,7 @@ description: "The ObjectStack Automation Protocol in 13 reference pages: every s This section contains all protocol schemas for the automation layer of ObjectStack. + diff --git a/content/docs/references/automation/meta.json b/content/docs/references/automation/meta.json index 65aabf1f154..37b4edca403 100644 --- a/content/docs/references/automation/meta.json +++ b/content/docs/references/automation/meta.json @@ -13,6 +13,7 @@ "---Approvals & Jobs---", "approval", "---More---", + "api-trigger-secret-ref", "builtin-node-config", "flow-function", "io-node-config", diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index b41e3eda667..fb20c56a6a1 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1548 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1549 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -22,7 +22,7 @@ counts are sums of the rows they head. Regenerate with | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 32 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. | -| [Automation Protocol](/docs/references/automation) | 13 | 72 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | +| [Automation Protocol](/docs/references/automation) | 14 | 73 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 178 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 285 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1548** | 14 protocol modules | +| **Total** | **198** | **1549** | 14 protocol modules | --- @@ -106,12 +106,13 @@ REST contracts, endpoints, routing, realtime, batch, discovery. ## Automation Protocol -**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **13 pages, 72 schemas** +**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 73 schemas** Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | File | Schemas | | :--- | :--- | +| [`api-trigger-secret-ref.zod.ts`](/docs/references/automation/api-trigger-secret-ref) | `ApiTriggerSecretRef` | | [`approval.zod.ts`](/docs/references/automation/approval) | `ApprovalDecision`, `ApprovalEscalation`, `ApprovalNodeApprover`, `ApprovalNodeConfig`, `ApproverType`, `DecisionOutputDef` | | [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` | | [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `EndConfig`, `FlowCollectionSlot`, `FlowValueSlot`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` | diff --git a/packages/spec/api-surface/automation.json b/packages/spec/api-surface/automation.json index 48fb6a35e89..cc6d1246070 100644 --- a/packages/spec/api-surface/automation.json +++ b/packages/spec/api-surface/automation.json @@ -2,6 +2,8 @@ "description": "Every exported `name (kind)` of one published entry point of @objectstack/spec — the breadth half of the ADR-0059 backward-compatibility gate. Sharded by entry point (#5837) so two PRs touching different entry points never share a file. Reads the BUILT dist/*.d.ts: regenerate with `pnpm --filter @objectstack/spec gen:api-surface` after a real build.", "entry": "./automation", "exports": [ + "API_TRIGGER_SECRET_REF_KEY (const)", + "API_TRIGGER_SECRET_REF_RESERVED_PREFIX (const)", "APPROVAL_BRANCH_LABELS (const)", "APPROVAL_NODE_TYPE (const)", "APPROVAL_REVISE_NODE_TYPE (const)", @@ -19,6 +21,9 @@ "ActionDescriptorSchema (const)", "ActionParadigm (type)", "ActionParadigmSchema (const)", + "ApiTriggerSecretRef (type)", + "ApiTriggerSecretRefRefusal (interface)", + "ApiTriggerSecretRefSchema (const)", "ApprovalDecision (const)", "ApprovalDecision (type)", "ApprovalEscalation (type)", @@ -283,6 +288,8 @@ "WebhookTriggerType (const)", "WebhookTriggerType (type)", "analyzeRegion (function)", + "apiTriggerSecretRefOf (function)", + "apiTriggerSecretRefRefusals (function)", "approverTypeIsOrgScoped (function)", "builtinNodeConfigKeysJudged (function)", "canonicalApproverType (function)", diff --git a/packages/spec/declaration-map/automation.json b/packages/spec/declaration-map/automation.json index b331ad941ca..c88ced4b5c9 100644 --- a/packages/spec/declaration-map/automation.json +++ b/packages/spec/declaration-map/automation.json @@ -8,6 +8,8 @@ "ActionDescriptorSchema": "automation/ActionDescriptor", "ActionParadigm": "automation/ActionParadigm", "ActionParadigmSchema": "automation/ActionParadigm", + "ApiTriggerSecretRef": "automation/ApiTriggerSecretRef", + "ApiTriggerSecretRefSchema": "automation/ApiTriggerSecretRef", "ApprovalDecision": "automation/ApprovalDecision", "ApprovalEscalation": "automation/ApprovalEscalation", "ApprovalEscalationSchema": "automation/ApprovalEscalation", diff --git a/packages/spec/export-origins/automation.json b/packages/spec/export-origins/automation.json index 027c2491c3a..87c2d1fc5d6 100644 --- a/packages/spec/export-origins/automation.json +++ b/packages/spec/export-origins/automation.json @@ -2,6 +2,8 @@ "description": "Which SOURCE DECLARATION each name exported by one public entry point of @objectstack/spec resolves to, after its alias chain is unwound: `# ()`. Two exports share an origin string iff they are the same declaration — so equal origins across two entries are a harmless re-export, and different origins under one name are the #4411 dual-source trap. Generated from src/ (no build needed) and read by the export-surface pin tests, which compare against it instead of each building their own ts.createProgram — that was ~55s of compilation per CI lap and a non-deterministic timeout that ejected unrelated PRs from the merge queue (#4796). Sharded by entry point (#5837) so two retirement PRs never share a file. Carries NO line numbers: the pins asserted the line as `\\d+`, and recording it would rewrite this artifact on every edit that shifts a line in any .zod.ts. Regenerate with `pnpm --filter @objectstack/spec gen:export-origins` and read the diff.", "entry": "./automation", "exports": { + "API_TRIGGER_SECRET_REF_KEY": "src/automation/api-trigger-secret-ref.zod.ts#API_TRIGGER_SECRET_REF_KEY (const)", + "API_TRIGGER_SECRET_REF_RESERVED_PREFIX": "src/automation/api-trigger-secret-ref.zod.ts#API_TRIGGER_SECRET_REF_RESERVED_PREFIX (const)", "APPROVAL_BRANCH_LABELS": "src/automation/approval.zod.ts#APPROVAL_BRANCH_LABELS (const)", "APPROVAL_NODE_TYPE": "src/automation/approval.zod.ts#APPROVAL_NODE_TYPE (const)", "APPROVAL_REVISE_NODE_TYPE": "src/automation/approval.zod.ts#APPROVAL_REVISE_NODE_TYPE (const)", @@ -19,6 +21,9 @@ "ActionDescriptorSchema": "src/automation/node-executor.zod.ts#ActionDescriptorSchema (const)", "ActionParadigm": "src/automation/node-executor.zod.ts#ActionParadigm (type)", "ActionParadigmSchema": "src/automation/node-executor.zod.ts#ActionParadigmSchema (const)", + "ApiTriggerSecretRef": "src/automation/api-trigger-secret-ref.zod.ts#ApiTriggerSecretRef (type)", + "ApiTriggerSecretRefRefusal": "src/automation/api-trigger-secret-ref.zod.ts#ApiTriggerSecretRefRefusal (interface)", + "ApiTriggerSecretRefSchema": "src/automation/api-trigger-secret-ref.zod.ts#ApiTriggerSecretRefSchema (const)", "ApprovalDecision": "src/automation/approval.zod.ts#ApprovalDecision (type)", "ApprovalEscalation": "src/automation/approval.zod.ts#ApprovalEscalation (type)", "ApprovalEscalationParsed": "src/automation/approval.zod.ts#ApprovalEscalationParsed (type)", @@ -277,6 +282,8 @@ "WebhookSchema": "src/automation/webhook.zod.ts#WebhookSchema (const)", "WebhookTriggerType": "src/automation/webhook.zod.ts#WebhookTriggerType (type)", "analyzeRegion": "src/automation/control-flow.zod.ts#analyzeRegion (function)", + "apiTriggerSecretRefOf": "src/automation/api-trigger-secret-ref.zod.ts#apiTriggerSecretRefOf (function)", + "apiTriggerSecretRefRefusals": "src/automation/api-trigger-secret-ref.zod.ts#apiTriggerSecretRefRefusals (function)", "approverTypeIsOrgScoped": "src/automation/approval.zod.ts#approverTypeIsOrgScoped (function)", "builtinNodeConfigKeysJudged": "src/automation/flow-node-config-refusals.ts#builtinNodeConfigKeysJudged (function)", "canonicalApproverType": "src/automation/approval.zod.ts#canonicalApproverType (function)", diff --git a/packages/spec/src/automation/api-trigger-secret-ref.zod.ts b/packages/spec/src/automation/api-trigger-secret-ref.zod.ts index f2333ec4996..7a61daf4baf 100644 --- a/packages/spec/src/automation/api-trigger-secret-ref.zod.ts +++ b/packages/spec/src/automation/api-trigger-secret-ref.zod.ts @@ -76,6 +76,8 @@ export const ApiTriggerSecretRefSchema = z 'literal `secret`, never beside it; not in the `OS_` namespace.', ); +export type ApiTriggerSecretRef = z.infer; + /** * The usable reference on a start node's `config`, or `undefined` when the key * is absent or does not parse. The one reader the lint rule and the engine diff --git a/skills/objectstack-automation/references/_index.md b/skills/objectstack-automation/references/_index.md index 51c2c60c649..50407a022f4 100644 --- a/skills/objectstack-automation/references/_index.md +++ b/skills/objectstack-automation/references/_index.md @@ -21,6 +21,7 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies +- `node_modules/@objectstack/spec/src/automation/api-trigger-secret-ref.zod.ts` — An inbound `api` hook's secret, by reference - `node_modules/@objectstack/spec/src/automation/control-flow.zod.ts` — Structured control-flow constructs (ADR-0031) — the **native + AI-authored** - `node_modules/@objectstack/spec/src/automation/schemaless-node-config.zod.ts` — Config contracts for the **descriptor-schemaless** builtins whose designer - `node_modules/@objectstack/spec/src/data/context-tokens.zod.ts` — Context Tokens — the declarative placeholders that resolve against the From 82e167b70eb6984b7de56aa666d7ec7733628e65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:04:48 +0000 Subject: [PATCH 07/10] fix(spec): ADR-0122 pin, quick-reference count, and a docblock that opens without a level-1 heading Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01NZJKktPWaz2EZJ95a9XZa8 --- .../docs/getting-started/quick-reference.mdx | 2 +- .../automation/api-trigger-secret-ref.zod.ts | 4 ++-- .../spec/src/type-alias-convention.pin.test.ts | 17 ++++++++++++++--- skills/objectstack-automation/SKILL.md | 4 ++-- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 65cfafca3e4..1ea2b087d35 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -150,7 +150,7 @@ REST endpoints, real-time subscriptions, and discovery. | **[Metadata](/docs/references/api/metadata)** | `metadata.zod.ts` | Metadata | API metadata endpoints | | **[Storage](/docs/references/api/storage)** | `storage.zod.ts` | Storage | API storage operations | -## Automation Protocol (3 of 13 schemas) +## Automation Protocol (3 of 14 schemas) Flows, approvals, and integrations. A record's lifecycle transitions are a `state_machine` validation rule on the object (see [State Machine](/docs/protocol/objectql/state-machine)). diff --git a/packages/spec/src/automation/api-trigger-secret-ref.zod.ts b/packages/spec/src/automation/api-trigger-secret-ref.zod.ts index 7a61daf4baf..e92bc293655 100644 --- a/packages/spec/src/automation/api-trigger-secret-ref.zod.ts +++ b/packages/spec/src/automation/api-trigger-secret-ref.zod.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { resolveFlowTriggerKind } from './flow-trigger-kind'; /** - * # An inbound `api` hook's secret, by reference + * An inbound `api` hook's secret, by reference. * * An `api` flow's start node arms an inbound hook that verifies every post * against a per-flow HMAC secret (ADR-0041). Until this key the only way to @@ -76,7 +76,7 @@ export const ApiTriggerSecretRefSchema = z 'literal `secret`, never beside it; not in the `OS_` namespace.', ); -export type ApiTriggerSecretRef = z.infer; +export type ApiTriggerSecretRef = z.input; /** * The usable reference on a start node's `config`, or `undefined` when the key diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 449f65f1f35..a9971f38565 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -275,9 +275,11 @@ import type * as M188 from './ai/build-progress.zod.js'; import type * as M189 from './system/fate-supplement.zod.js'; // [#23287] The ADR-0069 posture vocabulary and its scope -- next free index. import type * as M190 from './kernel/execution-context.zod.js'; +// An api flow's hook secret named by reference (`config.secretRef`) -- new module, next free index. +import type * as M191 from './automation/api-trigger-secret-ref.zod.js'; // --------------------------------------------------------------------------- -// 789 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 790 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -710,6 +712,9 @@ export type Iso_api_websocket__WebSocketMessageSchema = Assert, z.infer< typeof M36.WebSocketMessageType > >>; export type Iso_api_websocket__WebSocketPresenceStatus = Assert, z.infer< typeof M36.WebSocketPresenceStatus > >>; +// automation/api-trigger-secret-ref.zod.ts +export type Iso_automation_apiTriggerSecretRef__ApiTriggerSecretRefSchema = Assert, z.infer< typeof M191.ApiTriggerSecretRefSchema > >>; + // automation/approval.zod.ts export type Iso_automation_approval__ApprovalDecision = Assert, z.infer< typeof M37.ApprovalDecision > >>; export type Iso_automation_approval__ApprovalNodeApproverSchema = Assert, z.infer< typeof M37.ApprovalNodeApproverSchema > >>; @@ -1703,7 +1708,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 789 isomorphic pins', () => { + it('still declares all 790 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2562,7 +2567,13 @@ describe('ADR-0122 type-alias convention', () => { // extracted from `InstalledPackageSourceSchema`, so it is pinned here // (Iso_stack__ArtifactPackageSourceSchema) rather than given a `…Parsed` // synonym. +1 added. - expect(pins).toHaveLength(789); + // + // 789 -> 790: `ApiTriggerSecretRefSchema` + // (`automation/api-trigger-secret-ref.zod.ts`), the name an `api` flow's + // start node gives its hook secret — a plain string, so it is pinned here + // (Iso_automation_apiTriggerSecretRef__ApiTriggerSecretRefSchema) rather + // than given a `…Parsed` synonym. +1 added. + expect(pins).toHaveLength(790); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index 62c168d4027..c61a30294f6 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -346,9 +346,9 @@ node `config`** (the start `config` is a free-form record with no Zod shape): | `config` key | Purpose | |:-------------|:--------| | `hookId` | URL path token (default `'default'`). **Rotate it to revoke** a leaked endpoint | -| `secretRef` / `secret` | The HMAC-SHA256 shared secret, **required** as one of the two — without it the flow is refused at registration (`os validate` too). A package ships `secretRef: 'NAME'`, the name the deployment fills (an env var in the open tier, never `OS_*`), read per post — unfilled ⇒ `503`; never commit a literal `secret` | +| `secretRef` / `secret` | HMAC-SHA256 shared secret, **required** — without one the flow is refused at registration (`os validate` too). Ship `secretRef: 'NAME'` (an env var the deployment fills, not `OS_*`; unfilled ⇒ `503`), never a literal | -- **Signature:** `t=,v1=` — `v1` = HMAC-SHA256 of `.` under `secret`; refused when `t` is over 300 s from the server clock. +- **Signature:** `x-objectstack-signature: t=,v1=` — `v1` = HMAC-SHA256 of `.` under the secret; refused when `t` is over 300 s from the server clock. - **Idempotency:** `x-idempotency-key` dedupes retries — author the flow to be idempotent (delivery is at-least-once). - **Queue-backed:** the endpoint ACKs `202` and enqueues; the flow runs on the consumer, never in-band. - The JSON body surfaces to the flow as the trigger record (`record.*` / bare fields) plus `params`. From 1abed922f63ae1d941eeff0074633989c59d0b4d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:10:09 +0000 Subject: [PATCH 08/10] chore(spec): regenerate the reference page and skill references index Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01NZJKktPWaz2EZJ95a9XZa8 --- content/docs/references/automation/api-trigger-secret-ref.mdx | 4 ++-- skills/objectstack-automation/references/_index.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/automation/api-trigger-secret-ref.mdx b/content/docs/references/automation/api-trigger-secret-ref.mdx index 447c9fd9462..76ff692e6e9 100644 --- a/content/docs/references/automation/api-trigger-secret-ref.mdx +++ b/content/docs/references/automation/api-trigger-secret-ref.mdx @@ -1,12 +1,12 @@ --- title: Api Trigger Secret Ref — Automation Protocol navTitle: Api Trigger Secret Ref -description: "An api flow's start node arms an inbound hook that verifies every post against a per-flow HMAC secret." +description: "An inbound api hook's secret, by reference. An api flow's start node arms an inbound hook that verifies every post against a per-flow HMAC secret." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} -## An inbound `api` hook's secret, by reference +An inbound `api` hook's secret, by reference. An `api` flow's start node arms an inbound hook that verifies every post against a per-flow HMAC secret (ADR-0041). Until this key the only way to diff --git a/skills/objectstack-automation/references/_index.md b/skills/objectstack-automation/references/_index.md index 50407a022f4..4a56e7aafaa 100644 --- a/skills/objectstack-automation/references/_index.md +++ b/skills/objectstack-automation/references/_index.md @@ -21,7 +21,7 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies -- `node_modules/@objectstack/spec/src/automation/api-trigger-secret-ref.zod.ts` — An inbound `api` hook's secret, by reference +- `node_modules/@objectstack/spec/src/automation/api-trigger-secret-ref.zod.ts` — An inbound `api` hook's secret, by reference. - `node_modules/@objectstack/spec/src/automation/control-flow.zod.ts` — Structured control-flow constructs (ADR-0031) — the **native + AI-authored** - `node_modules/@objectstack/spec/src/automation/schemaless-node-config.zod.ts` — Config contracts for the **descriptor-schemaless** builtins whose designer - `node_modules/@objectstack/spec/src/data/context-tokens.zod.ts` — Context Tokens — the declarative placeholders that resolve against the From 8a23b3ada688d0d212bb91a40058b9bd3ad2f78b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 18:15:33 +0000 Subject: [PATCH 09/10] docs(spec): llms.txt schema counts for the new automation module Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01NZJKktPWaz2EZJ95a9XZa8 --- packages/spec/llms.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt index ce80e20d499..02279212134 100644 --- a/packages/spec/llms.txt +++ b/packages/spec/llms.txt @@ -77,7 +77,7 @@ const query = { --- -## 3. Schema Inventory by Domain (205 schemas) +## 3. Schema Inventory by Domain (206 schemas) Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number @@ -90,7 +90,7 @@ here is verifiable from the installed package. | data | 30 | Object, Field, Picklist, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube | | api | 31 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage) | | ui | 18 | View, App, Action, Dashboard, Page, Chart, Component, Animation | -| automation | 13 | Flow, Approval, BPMN Interop, Control Flow, Webhook, Schedule Organization | +| automation | 14 | Flow, Approval, BPMN Interop, Control Flow, Webhook, Schedule Organization | | shared | 16 | Enums, HTTP, Identifiers, Mapping, Metadata Types, Connector Auth, Retry Policy, Conditional Formatting, Value Domain, Epoch Instant (EpochMs), Duration (DurationMs / DurationSeconds) | | ai | 12 | Agent, Build Progress, Conversation, Knowledge Source/Document, Model Registry, MCP, Skill, Tool | | identity | 5 | Identity, Organization, Position, SCIM, Eval User | From 088e442390cafbc51ac080fb3721626e440dfacf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 19:23:03 +0000 Subject: [PATCH 10/10] chore(skills): move the automation skill's secretRef row to its own governed PR Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01NZJKktPWaz2EZJ95a9XZa8 --- skills/objectstack-automation/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index c61a30294f6..7e85ab9c1aa 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -346,9 +346,9 @@ node `config`** (the start `config` is a free-form record with no Zod shape): | `config` key | Purpose | |:-------------|:--------| | `hookId` | URL path token (default `'default'`). **Rotate it to revoke** a leaked endpoint | -| `secretRef` / `secret` | HMAC-SHA256 shared secret, **required** — without one the flow is refused at registration (`os validate` too). Ship `secretRef: 'NAME'` (an env var the deployment fills, not `OS_*`; unfilled ⇒ `503`), never a literal | +| `secret` | HMAC-SHA256 shared secret. **Required** — without a non-blank one the flow is refused at registration (`os validate` too) and never armed at boot; the signature goes in `x-objectstack-signature` | -- **Signature:** `x-objectstack-signature: t=,v1=` — `v1` = HMAC-SHA256 of `.` under the secret; refused when `t` is over 300 s from the server clock. +- **Signature:** `t=,v1=` — `v1` = HMAC-SHA256 of `.` under `secret`; refused when `t` is over 300 s from the server clock. - **Idempotency:** `x-idempotency-key` dedupes retries — author the flow to be idempotent (delivery is at-least-once). - **Queue-backed:** the endpoint ACKs `202` and enqueues; the flow runs on the consumer, never in-band. - The JSON body surfaces to the flow as the trigger record (`record.*` / bare fields) plus `params`.