Skip to content
Merged
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
33 changes: 33 additions & 0 deletions .changeset/25675-api-flow-secret-ref.md
Original file line number Diff line number Diff line change
@@ -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: '<value>'` with `secretRef: 'YOUR_NAME'` and set `YOUR_NAME` in the deployment's environment.

Clause-②: yes (widening)

Metadata-protocol: added

Ruling-ref: 6386630545
5 changes: 5 additions & 0 deletions content/docs/automation/connectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 —

Expand Down
2 changes: 1 addition & 1 deletion content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ the call, so a `durable` callout enqueues exactly the request the inline call
would send.

<Callout type="warn" title="Do not put a secret in an http node's url or headers">
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.
</Callout>

**Script:**
Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/quick-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

Expand Down
59 changes: 59 additions & 0 deletions content/docs/references/automation/api-trigger-secret-ref.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
title: Api Trigger Secret Ref — Automation Protocol
navTitle: Api Trigger Secret Ref
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 `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.

<Callout type="info">
**Source:** `packages/spec/src/automation/api-trigger-secret-ref.zod.ts`
</Callout>

## 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.


---

3 changes: 2 additions & 1 deletion content/docs/references/automation/index.mdx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
---
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/. */}

This section contains all protocol schemas for the automation layer of ObjectStack.

<Cards>
<Card href="/docs/references/automation/api-trigger-secret-ref" title="Api Trigger Secret Ref" description="Source: packages/spec/src/automation/api-trigger-secret-ref.zod.ts" />
<Card href="/docs/references/automation/approval" title="Approval" description="Source: packages/spec/src/automation/approval.zod.ts" />
<Card href="/docs/references/automation/bpmn-interop" title="Bpmn Interop" description="Source: packages/spec/src/automation/bpmn-interop.zod.ts" />
<Card href="/docs/references/automation/builtin-node-config" title="Builtin Node Config" description="Source: packages/spec/src/automation/builtin-node-config.zod.ts" />
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/automation/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"---Approvals & Jobs---",
"approval",
"---More---",
"api-trigger-secret-ref",
"builtin-node-config",
"flow-function",
"io-node-config",
Expand Down
9 changes: 5 additions & 4 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -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/. */}
Expand All @@ -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. |
Expand All @@ -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 |

---

Expand Down Expand Up @@ -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` |
Expand Down
6 changes: 3 additions & 3 deletions docs/qa/platform-checklist/areas/automation.json
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@
"on-demand chain: POST /api/v1/automation/<name>/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/<name>/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/<that flow>/trigger and read GET /api/v1/automation/<that flow>/runs/:runId"
Expand Down Expand Up @@ -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=<unix seconds>,v1=<HMAC-SHA256 of '<t>.<raw body>', 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=<unix seconds>,v1=<HMAC-SHA256 of '<t>.<raw body>', 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:[…]}}",
Expand Down
Loading
Loading