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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/30009-api-trigger-declared-signature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@objectstack/trigger-api': patch
'@objectstack/lint': patch
'@objectstack/skills': patch
---

feat(trigger-api): an `api` hook verifies the vendor signature its start node declares (`config.signature`)

Clause-②: yes (widening)
Metadata-protocol: none

- **`@objectstack/trigger-api`.** `start()` reads the hook's one verifier with `apiTriggerVerifierOf(binding.config)` (`@objectstack/spec/automation`). A start node that declares its sender's scheme (`config.signature`: DocuSign Connect's base64 `X-DocuSign-Signature-1`, a Stripe-style `t=…,v1=…`, a GitHub-style `sha256=`) now admits a correctly signed post that was answered `401` before. That scheme REPLACES the `x-objectstack-signature` check for its hook: the declared header is read on both faces of the door (the self-hosted mount and `handleInboundHook`) with `Headers.get`, so a repeated field is its one joined value; the HMAC-SHA256 is computed under the key `config.secretRef` names (read at every post through the engine's reader, `503` while it has no value), over the raw body or `<t>.<raw body>`; each digest is decoded strictly to 32 bytes and compared with `timingSafeEqual`, any one of several `v1` elements matching (a key roll); and a timestamped scheme is held to `HTTP_SIGNATURE_TOLERANCE_SECONDS` from `@objectstack/core`. A body-only scheme has no replay window, as the contract states. A valid `x-objectstack-signature` admits nothing on such a hook, and a post without the declared header is refused. A declaration the spec answers `unusable` arms a hook that refuses every post before any secret is read and never falls back to `x-objectstack-signature`; a declared scheme bound without the engine's `config.secretRef` reader is refused at `start()`. Every failure is the same `401 INVALID_SIGNATURE` with its own cause in the server log (`declared-header-absent`, `declared-header-malformed`, `declared-prefix-missing`, `declared-no-digest`, `declared-mismatch`, `declared-outside-window`, `declared-unusable`), never the key, the header value or the body. A hook that declares no scheme is verified exactly as before.
- **`@objectstack/lint`.** `validateFlowApiTriggerSecret` grades an `api` hook by that same reader: a usable declaration is ready on its `config.secretRef` alone, and an unusable one is the new error `flow-api-trigger-signature-unusable` (with its `os explain` entry), located at the key the spec refuses. It fires only on a declaration the flow parse already refuses, so no stack that passed before is refused now.
- **`@objectstack/skills`.** The automation and api skills teach `config.signature` where they said a vendor-signed callback cannot be admitted.
51 changes: 51 additions & 0 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,57 @@ 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 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.
</Callout>

#### An inbound hook that verifies its sender's own signature — `config.signature`

By default an `api` flow's hook admits a post only by ObjectStack's own
`x-objectstack-signature`. A vendor that signs its callbacks its own way
(DocuSign Connect's `X-DocuSign-Signature-1`, a Stripe-style `t=…,v1=…`
header, a GitHub-style `sha256=` header) is admitted by declaring how it signs
on the start node, as `config.signature`, beside the `config.secretRef` that
names the key:

```ts
export const EnvelopeStatus = defineFlow({
name: 'envelope_status', label: 'Envelope status', type: 'api', runAs: 'system',
nodes: [
{ id: 'start', type: 'start', label: 'DocuSign Connect', config: {
hookId: 'docusign',
secretRef: 'DOCUSIGN_CONNECT_KEY',
signature: { signs: 'body', header: 'X-DocuSign-Signature-1', algorithm: 'hmac-sha256', encoding: 'base64' },
} },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});
```

`signs` says what the sender signs, and picks the form:

| `signs` | The sender signs | The header carries | Replay window |
|:--|:--|:--|:--|
| `body` | the raw body alone (DocuSign Connect; GitHub-style with `prefix: 'sha256='`) | the one digest, after `prefix` when one is declared | none: the sender signs no time, so a captured post verifies again while the key is unchanged |
| `timestamp.body` | `<t>.<raw body>` (Stripe-style) | `NAME=value` elements: one `timestampElement` (`t`) and one or more `digestElement`s (`v1`, one per key while the sender rolls its key) | the same 300 seconds as `x-objectstack-signature` |

`header` is matched without regard to case; a field the sender repeats is
read as its one value, the copies joined with `, `, so a body-only header sent
twice does not verify. `algorithm` is `hmac-sha256`, `encoding` is `hex` or
`base64`, and the digest must decode to the 32 bytes of the HMAC, compared in
constant time over the body exactly as received. The key is only ever the one
`config.secretRef` names, read at every post like any hook secret (`503` while
it has no value); a literal `secret` beside `signature` is refused.

A declared scheme **replaces** the `x-objectstack-signature` check for its hook:
the hook has that one verifier, so a post that carries only a valid
`x-objectstack-signature` is refused, and so is one without the declared
header. Every refusal is the same `401 INVALID_SIGNATURE` an unsigned post
gets, with its cause in the server log only. A declaration the flow parse
refuses (an unknown algorithm, a reserved header such as `authorization` or
`x-objectstack-signature`, a timestamped scheme without its elements) never
falls back to the platform check: `os validate` reports it as
`flow-api-trigger-signature-unusable`, and a hook armed with it admits no post.
The declared keys are listed in
[api-trigger-signature](/docs/references/automation/api-trigger-signature).

**Script:**

The built-in `script` executor never evaluates an arbitrary JavaScript string —
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export {
FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
FLOW_TRIGGER_UNROUTABLE,
FLOW_API_TRIGGER_SECRET_MISSING,
FLOW_API_TRIGGER_SIGNATURE_UNUSABLE,
} from './validate-flow-trigger-readiness.js';
export type {
FlowTriggerReadinessFinding,
Expand Down
27 changes: 27 additions & 0 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2066,6 +2066,32 @@ const FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION: RuleExplanation = {
],
};

const FLOW_API_TRIGGER_SIGNATURE_UNUSABLE_EXPLANATION: RuleExplanation = {
rule: 'flow-api-trigger-signature-unusable',
covers: 'why a hook with a broken sender signature admits nothing',
paragraphs: [
'An inbound api hook may declare how a third-party sender signs its posts, in its start node\'s ' +
'`config.signature`: the header the sender writes its digest in, `hmac-sha256`, `hex` or `base64`, and ' +
'what is signed, the raw body (`signs: \'body\'`, as DocuSign Connect signs it) or `<t>.<raw body>` ' +
'(`signs: \'timestamp.body\'`, a Stripe-style `t=…,v1=…` header). The key is the one ' +
'`config.secretRef` names, never a literal. A declared scheme REPLACES the `x-objectstack-signature` ' +
'check for its hook: the hook has that one verifier and never reads the platform header.',
'The finding means the declaration is present and the spec refuses it: an unknown algorithm or ' +
'encoding, a reserved header name, a timestamped scheme without its elements, no `config.secretRef`, ' +
'or a literal `config.secret` beside it. The verdict names the key the spec refuses at; the flow ' +
'parse prints the refusal itself in full.',
'A broken declaration never falls back to `x-objectstack-signature` and is never admitted unsigned. The ' +
'automation engine\'s flow parse refuses the flow at registration, and a trigger handed it anyway ' +
'refuses every post to its hook with the one `401 INVALID_SIGNATURE` every unverified post gets, so ' +
'the flow never receives a post. Fix the declaration, or remove `config.signature` to verify ' +
'`x-objectstack-signature` under the hook\'s secret.',
'A usable declaration is ready: the hook verifies the sender\'s header under the named key, a ' +
'timestamped scheme inside the same 300-second window `x-objectstack-signature` has, a body-only ' +
'scheme with no replay window because the sender signs no time.',
FLOW_GATE_REACH,
],
};

// `flow-trigger-unknown-object` lives in the same rule file but is not one of
// the five: its verdict depends on packages a build cannot see, so it warns —
// except under a package closure the runtime publish gate states, where the
Expand Down Expand Up @@ -5654,6 +5680,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
[FLOW_TRIGGER_UNROUTABLE_EXPLANATION.rule]: FLOW_TRIGGER_UNROUTABLE_EXPLANATION,
[FLOW_TRIGGER_UNKNOWN_EVENT_EXPLANATION.rule]: FLOW_TRIGGER_UNKNOWN_EVENT_EXPLANATION,
[FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION.rule]: FLOW_API_TRIGGER_SECRET_MISSING_EXPLANATION,
[FLOW_API_TRIGGER_SIGNATURE_UNUSABLE_EXPLANATION.rule]: FLOW_API_TRIGGER_SIGNATURE_UNUSABLE_EXPLANATION,
[FLOW_TRIGGER_UNKNOWN_OBJECT_EXPLANATION.rule]: FLOW_TRIGGER_UNKNOWN_OBJECT_EXPLANATION,
[FLOW_INPUT_UNKNOWN_OBJECT_EXPLANATION.rule]: FLOW_INPUT_UNKNOWN_OBJECT_EXPLANATION,
[VALIDATION_RULE_REGEX_UNCOMPILABLE_EXPLANATION.rule]: VALIDATION_RULE_REGEX_UNCOMPILABLE_EXPLANATION,
Expand Down
113 changes: 112 additions & 1 deletion packages/lint/src/validate-flow-trigger-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
FLOW_TRIGGER_UNROUTABLE,
FLOW_API_TRIGGER_SECRET_MISSING,
FLOW_API_TRIGGER_SIGNATURE_UNUSABLE,
validateFlowApiTriggerSecret as validateFlowApiTriggerSecretUnrecorded,
schemaRefusalHead,
} from './validate-flow-trigger-readiness.js';
Expand All @@ -30,6 +31,7 @@ const NEVER_FIRE_IDS: readonly string[] = [
FLOW_TIME_RELATIVE_DESCRIPTOR_UNROUTABLE,
FLOW_TRIGGER_UNROUTABLE,
FLOW_API_TRIGGER_SECRET_MISSING,
FLOW_API_TRIGGER_SIGNATURE_UNUSABLE,
FLOW_TRIGGER_UNKNOWN_EVENT,
];
const fired: Array<{ rule: string; message: string; hint: string }> = [];
Expand Down Expand Up @@ -1521,6 +1523,31 @@ describe('validateFlowTriggerReadiness', () => {
],
},
],
// [#30009] A declared sender scheme the spec refuses: never-fire, like a missing secret.
[
FLOW_API_TRIGGER_SIGNATURE_UNUSABLE,
'error',
{
objects: [candidateObject],
flows: [
{
name: 'broken_vendor_hook',
type: 'api',
status: 'active',
nodes: [
{
id: 'start',
type: 'start',
config: {
secretRef: 'DOCUSIGN_CONNECT_KEY',
signature: { signs: 'body', header: 'X-DocuSign-Signature-1', algorithm: 'hmac-sha1', encoding: 'base64' },
},
},
],
},
],
},
],
// ── The controls. Both are hedged, and the hedge is the whole reason the
// promotion above is not "everything in this file is an error now".
[
Expand Down Expand Up @@ -1753,6 +1780,89 @@ describe('validateFlowTriggerReadiness', () => {
});
});

// [#30009] A start node that declares its sender's own scheme (`config.signature`)
// is graded by the hook's ONE verifier, read through the spec's
// `apiTriggerVerifierOf` (the reader the engine and the trigger share): ready on
// the key `config.secretRef` names and nothing else, and never ready when the
// reader answers `unusable`.
describe('[#30009] api trigger readiness reads the hook\'s one verifier (config.signature)', () => {
const DOCUSIGN = { signs: 'body', header: 'X-DocuSign-Signature-1', algorithm: 'hmac-sha256', encoding: 'base64' };
const STRIPE = {
signs: 'timestamp.body', header: 'Stripe-Signature', algorithm: 'hmac-sha256', encoding: 'hex',
timestampElement: 't', digestElement: 'v1',
};
const vendorFlow = (config: Record<string, unknown>, type = 'api') => ({
name: 'vendor_hook',
label: 'Vendor hook',
type,
status: 'active',
nodes: [
{ id: 'start', type: 'start', label: 'Start', config },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});
const judge = (flow: Record<string, unknown>) => cliFlowFamily({ objects: [candidateObject], flows: [flow] });
const rules = (flow: Record<string, unknown>) => judge(flow).map((f) => [f.rule, f.severity, f.path]);

it('a usable declaration keyed by secretRef is ready, on both arms, at the CLI and the runtime publish gate', () => {
for (const signature of [DOCUSIGN, STRIPE, { ...DOCUSIGN, encoding: 'hex', prefix: 'sha256=' }]) {
const flow = vendorFlow({ hookId: 'vendor', secretRef: 'VENDOR_WEBHOOK_KEY', signature });
expect(judge(flow), JSON.stringify(signature)).toEqual([]);
expect(runRuntimeAuthoringRules({ type: 'flow', item: flow }).errors, JSON.stringify(signature)).toEqual([]);
}
});

it('an unusable declaration is never ready: one error, at the key the spec refuses', () => {
const cases: Array<[string, Record<string, unknown>, string]> = [
['an algorithm the spec refuses', { secretRef: 'K', signature: { ...DOCUSIGN, algorithm: 'hmac-sha1' } }, 'config.signature.algorithm'],
['a reserved header', { secretRef: 'K', signature: { ...DOCUSIGN, header: 'x-objectstack-signature' } }, 'config.signature.header'],
['a timestamped scheme with no timestamp element', { secretRef: 'K', signature: { ...STRIPE, timestampElement: undefined } }, 'config.signature.timestampElement'],
['no key reference', { signature: DOCUSIGN }, 'config.signature'],
['a literal secret beside it (an inline key)', { secret: 'whsec_inline', signature: DOCUSIGN }, 'config.signature'],
['a literal secret AND a secretRef beside it', { secret: 'whsec_inline', secretRef: 'K', signature: DOCUSIGN }, 'config.signature'],
['a secretRef that is not a usable name', { secretRef: 'OS_AUTH_SECRET', signature: DOCUSIGN }, 'config.secretRef'],
];
for (const [label, config, keyPath] of cases) {
const findings = judge(vendorFlow(config)).filter((f) => f.rule === FLOW_API_TRIGGER_SIGNATURE_UNUSABLE);
expect(findings.map((f) => [f.severity, f.path]), label).toEqual([['error', `flows[0].nodes[0].${keyPath}`]]);
expect(findings[0]!.message, label).toContain(keyPath);
expect(findings[0]!.message, label).toContain(`type: 'api'`);
// Never the missing-secret id beside it: one verdict per hook.
expect(judge(vendorFlow(config)).map((f) => f.rule), label).not.toContain(FLOW_API_TRIGGER_SECRET_MISSING);
// And never let through at the runtime publish gate.
expect(
runRuntimeAuthoringRules({ type: 'flow', item: vendorFlow(config) }).errors.map((f) => f.rule),
label,
).toContain(FLOW_API_TRIGGER_SIGNATURE_UNUSABLE);
}
});

it('a declared scheme is graded on its key reference alone: a withheld-and-stored literal secret does not make it ready', () => {
const flow = vendorFlow({ signature: DOCUSIGN });
const findings = validateFlowApiTriggerSecret(
{ objects: [candidateObject], flows: [flow] },
{ restoredCredentialPaths: new Set(['flows[0].nodes[0].config.secret']) },
);
expect(findings.map((f) => [f.rule, f.path])).toEqual([[FLOW_API_TRIGGER_SIGNATURE_UNUSABLE, 'flows[0].nodes[0].config.signature']]);
});

it('CONTROL: a flow that binds no api trigger is not graded by this rule, and a hook with no declaration keeps its own verdicts', () => {
expect(rules(vendorFlow({ secretRef: 'K', signature: { ...DOCUSIGN, algorithm: 'hmac-sha1' } }, 'autolaunched'))
.map(([rule]) => rule)).not.toContain(FLOW_API_TRIGGER_SIGNATURE_UNUSABLE);
expect(rules(vendorFlow({ hookId: 'vendor' }))).toEqual([[FLOW_API_TRIGGER_SECRET_MISSING, 'error', 'flows[0].nodes[0].config.secret']]);
expect(judge(vendorFlow({ secretRef: 'VENDOR_WEBHOOK_KEY' }))).toEqual([]);
});

it('`os explain flow-api-trigger-signature-unusable` says what a declared scheme is and why a broken one admits nothing', () => {
expect(FLOW_API_TRIGGER_SIGNATURE_UNUSABLE).toBe('flow-api-trigger-signature-unusable');
const text = explanationOf(FLOW_API_TRIGGER_SIGNATURE_UNUSABLE);
for (const fact of ['config.signature', 'config.secretRef', 'REPLACES', 'never falls back', '401 INVALID_SIGNATURE', '300-second']) {
expect(text, fact).toContain(fact);
}
});
});

describe('[#22161] one-line verdicts — the never-fire family', () => {
it('every verdict the cases above fired is one line of at most 200 characters', () => {
// The coverage control first: each of the five ids fired at least once, so
Expand Down Expand Up @@ -1798,9 +1908,10 @@ describe('[#22161] one-line verdicts — the never-fire family', () => {
[FLOW_TRIGGER_UNROUTABLE]: ['`resolveTriggerBinding`', 'manual — nothing to bind', 'one higher than its bound count', 'RESOLVED type', 'record-{before,after}-{create,insert,update,delete,write}'],
[FLOW_TRIGGER_UNKNOWN_EVENT]: ['record-{before,after}-{create,insert,update,delete,write}', 'bind-time warn', 'multi-event shape', '`record-after-write`'],
[FLOW_API_TRIGGER_SECRET_MISSING]: ['ADR-0041', 'HMAC', '`registerFlow`', 'whatever the flow\'s `status`', 'never receives a post', 'withholds'],
[FLOW_API_TRIGGER_SIGNATURE_UNUSABLE]: ['REPLACES', 'never falls back', 'flow parse refuses the flow at registration', 'names the key the spec refuses at'],
};

it('covers exactly the five ids', () => {
it('covers exactly the never-fire ids', () => {
expect(Object.keys(MOVED).sort()).toEqual([...NEVER_FIRE_IDS].sort());
});

Expand Down
Loading
Loading