Skip to content

Commit 8c0306f

Browse files
fix(service-automation): the flow toggle records which admin switched a packaged flow (#26605)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XTXjaSjCiT9wP1UtogZiA8r)_ Fixes #26474. This is the flow-toggle half. The action and security halves landed in #26551, and the contract's `caller` parameter on `IAutomationService.toggleFlow` landed in #26545 (closing #26506). Seat ruling `6434876664`, letter A, item 2; claim `6437525635`. Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么:** 管理员通过 `POST /api/v1/automation/:name/toggle` 关掉或打开一个打包的 flow 时,激活台账行的 `created_by` / `updated_by` 和对应审计行的 `user_id` / `actor` 现在记的是这位管理员,不再是空。至此 #26474 的三个开关门(action、安全目录、flow)都能说出是谁操作的。 - **为什么改:** flow 门经由 `IAutomationService.toggleFlow` 写台账,以前这个契约带不了调用者,只能用不带用户的系统身份写入。spec 卡 #26506 已在 #26545 补上 `caller` 参数,本 PR 把它接通。 - **风险与回滚:** 写入仍是提权写(权限不变),只多带了用户 id;`caller` 只取 `userId`,不带职位、权限、租户。没有调用者的写入照旧用系统身份。回滚 = revert 本 PR。 - **先看:** `packages/services/service-automation/src/flow-definitions.ts`(`flowActivationWriteOptions` 与写入处)、`packages/runtime/src/domains/activation-gate.ts`(`activationCaller`)、`packages/runtime/src/domains/automation.ts` 的 toggle 门。 - **两分钟验证:** `pnpm --filter @objectstack/dogfood exec vitest run test/activation-ledger-actor.dogfood.test.ts`(先按 CI 建好 dogfood 依赖闭包),预期 7/7 通过,flow 用例的台账行与审计行都写着登录管理员的 id。 - **需要你做的:** 无,席位落地。 ## What changes - **`@objectstack/runtime`:** the flow toggle door calls `toggleFlow(name, enabled, activationCaller(context))`. The new `activationCaller(context)` in `domains/activation-gate.ts` returns `{ userId }` from the caller's execution context, or `{}` when there is no user. It carries nothing else: no positions, permissions or tenant. The door's two authority gates have already judged the caller, and the contract says `caller` is attribution only. - **`@objectstack/service-automation`:** `AutomationEngine.toggleFlow(name, enabled, caller?)` implements the contract's `caller` and hands it to the moved body in `flow-definitions.ts`. There, `flowActivationWriteOptions(caller)` builds the ledger write's options: `{ context: { isSystem: true, positions: [], permissions: [], userId } }`. That is the store's own grant-less system context plus the caller's id, and only `caller.userId` is read. No id (no caller, `{}` or an empty string) means no options, so the store keeps writing under its own system context and the row names nobody. `FlowActivationStore.setActive(row, options?)` takes the optional `MetadataActivationWriteOptions` from `@objectstack/core`, the same channel #26551 gave the shared store. `ObjectStoreFlowActivationStore` already inherits that behaviour from core's `ObjectStoreMetadataActivationStore`. **Mechanism.** It is the same as the other two doors. The elevation is kept, because the ledger object is engine-owned and only an elevated write reaches it. The caller's id rides in that context, so the engine stamps `created_by` / `updated_by` from `userId` and the audit plugin records `user_id` / `actor` from the same session. No actor is stamped by hand. The flow door does not reuse `activationWriteContext` (the full caller context, elevated), because the contract narrows `caller` to `userId` and the automation service builds the context on its side of the seam. **Every `toggleFlow` caller.** `git grep` over `*.ts` / `*.tsx` outside `packages/spec` finds one in-repo caller, the runtime door in `packages/runtime/src/domains/automation.ts`, which now passes its caller. There is no internal caller without a user. `ui/`, `apps/`, `examples/` and `packages/client` call no `toggleFlow`. ## Why Card #26474 (QA source #26468): on a stock showcase, a signed-in admin switched off a packaged flow, and the ledger row's `created_by` / `updated_by` and its audit row's `user_id` / `actor` were null. The engine wrote through the ledger store's constant system context, and `toggleFlow(name, enabled)` had no way to say who asked. ## Tests Red first, measured on `main` `1080f074` source with the new tests in the tree and a build of that source: | Suite | On `main` | After the fix | |:--|:--|:--| | service-automation `flow-toggle-caller-attribution.test.ts` (new, 5 cases) | 2 failed (the caller's id never reached the store's options); the 3 unattributed controls (no caller, `{}`, empty id) passed | 5/5 pass | | runtime `automation-toggle-caller.test.ts` (new, 2 cases) | 2 failed (`toggleFlow` was called with two arguments) | 2/2 pass | | dogfood `activation-ledger-actor.dogfood.test.ts`, new case "a packaged flow" (`showcase_scheduled_digest`, off then on; ledger row and audit row) | 2 failed (`expected null to be` the admin id); the 5 action and position cases passed | 7/7 pass | Existing pins that read `toggleFlow`'s two arguments now take `expect.anything()` as the third (`http-dispatcher.test.ts` and 7 files under `packages/runtime/src/domains/`). <details><summary>Ablations (one behaviour, one mutation each; every restore proved blob == HEAD and an empty `git diff HEAD`)</summary> Run with `scripts/ablation-replace.mjs` against the source-level suites: 1. `flow-definitions.ts`: the write's options replaced by `undefined`. Service suite: 2 failed, 3 passed (the controls). 2. `automation.ts`: the door passes `undefined` for `caller`. Door suite: 2 failed. 3. `activation-gate.ts`: `activationCaller` spreads the whole execution context into the caller. Door suite: 2 failed. 4. `flow-definitions.ts`: the write's context spreads the caller object (grants smuggled in). Service suite: 1 failed (the "only the id crosses" case). </details> Package suites at head `f381e8f4`, under the verify lock: - `pnpm --filter @objectstack/runtime test`: 458 files, 7430 passed, 19 skipped. - service-automation `vitest run`: 212 files, 2730 passed. - `typecheck` for service-automation and runtime: exit 0. - Dogfood `activation-ledger-actor` and `packaged-activation-ledger-reach`: 2 files, 18 passed, on a rebuilt tree. The dogfood regression gate does not run on PRs or in the merge queue. The dogfood numbers above are the local run. ## Declarations - **`Clause-②: yes (widening)`.** `AutomationEngine.toggleFlow`'s new `caller` implements an already-declared contract parameter, which on its own would be `no`. But `FlowActivationStore`, exported from `@objectstack/service-automation`'s entry, gains an optional `options` parameter on `setActive`, so the package's published surface widens. A store that implements the one-argument form still type-checks and keeps working. `activationCaller` is not exported from `@objectstack/runtime`'s entry. This is the same measure #26551 used for the shared store. - **`Metadata-protocol: none`.** Nothing a metadata author writes changes. ## Verification Gates are derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on this diff (67 commands), plus `eslint --no-inline-config` scoped to the touched files. All were run at `f381e8f4`, and every exit code was captured before any pipe. `--ran` reconciliation: **67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN**. - `pnpm check:dual-build-cjs-loads` first exited 3 (prerequisite not met: no full build, so nothing was measured). After a full `turbo run build` under the lock it exited 0. - Not local: the CI-only type-check lanes and the Build Core job, which are read from this PR's check runs. <details><summary>Every gate, with its exit code</summary> - `node scripts/check-adr-0087-registration.mjs --base origin/main` :: exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test` :: exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs` :: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-adoption.mjs` :: exit 0 - `node scripts/check-comment-mask-adoption.mjs --self-test` :: exit 0 - `node scripts/check-comment-mask-corpus.mjs` :: exit 0 - `node scripts/check-dts-emitted.mjs --self-test` :: exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main` :: exit 0 - `node scripts/check-empty-changeset.mjs --self-test` :: exit 0 - `node scripts/check-issue-citations.mjs` :: exit 0 - `node scripts/check-plugin-teardown-shape.mjs` :: exit 0 - `node scripts/check-plugin-teardown-shape.mjs --self-test` :: exit 0 - `node scripts/check-tenant-audit-census.mjs` :: exit 0 - `node scripts/check-tenant-audit-census.mjs --self-test` :: exit 0 - `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` :: exit 0 - `node scripts/ci/plan-test-core-shards.mjs --self-test` :: exit 0 - `node scripts/ci/prune-turbo-cache.mjs --self-test` :: exit 0 - `node scripts/docs-audit/check-affected-docs.mjs` :: exit 0 - `node scripts/docs-audit/check-drift-comment.mjs` :: exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test` :: exit 0 - `node scripts/release-pending-publish.mjs --self-test` :: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys` :: exit 0 - `pnpm --filter @objectstack/spec run check:empty-state` :: exit 0 - `pnpm --filter @objectstack/spec run check:liveness` :: exit 0 - `pnpm --filter @objectstack/spec run check:strictness-ledger` :: exit 0 - `pnpm --filter @objectstack/spec run check:variant-docs` :: exit 0 - `pnpm check:changeset-gate-self-tests` :: exit 0 - `pnpm check:ci-filter-parity` :: exit 0 - `pnpm check:closed-ceremony-text` :: exit 0 - `pnpm check:cross-package-test-inputs` :: exit 0 - `pnpm check:dispatcher-error-vocabulary` :: exit 0 - `pnpm check:doc-authoring` :: exit 0 - `pnpm check:driver-memory-census` :: exit 0 - `pnpm check:dts-closure` :: exit 0 - `pnpm check:dual-build-cjs-loads` :: exit 0 - `pnpm check:durability-log-level` :: exit 0 - `pnpm check:engine-double-contract` :: exit 0 - `pnpm check:error-status-conformance` :: exit 0 - `pnpm check:gitlink-declared` :: exit 0 - `pnpm check:issue-citations` :: exit 0 - `pnpm check:keyed-text-bounds` :: exit 0 - `pnpm check:lean-entry-closure` :: exit 0 - `pnpm check:logger-receiver-detach` :: exit 0 - `pnpm check:nul-bytes` :: exit 0 - `pnpm check:objectql-double-limit` :: exit 0 - `pnpm check:org-identifier` :: exit 0 - `pnpm check:page-declaration-shape` :: exit 0 - `pnpm check:platform-object-tenancy-census` :: exit 0 - `pnpm check:pm-changeset-deadline-census` :: exit 0 - `pnpm check:published-files` :: exit 0 - `pnpm check:query-options-erasure` :: exit 0 - `pnpm check:refd-timer-probe` :: exit 0 - `pnpm check:registry-log-declared` :: exit 0 - `pnpm check:rest-log-spy-declared` :: exit 0 - `pnpm check:route-envelope` :: exit 0 - `pnpm check:slot-lookup` :: exit 0 - `pnpm check:sourcemap-no-sources-content` :: exit 0 - `pnpm check:system-context-census` :: exit 0 - `pnpm check:test-source-alias` :: exit 0 - `pnpm check:tier-file-adoption` :: exit 0 - `pnpm check:type-check-coverage` :: exit 0 - `pnpm check:type-check-debt` :: exit 0 - `pnpm check:undeclared-dep-imports` :: exit 0 - `pnpm check:watch-hint-literal` :: exit 0 - `pnpm check:where-matcher` :: exit 0 - `pnpm exec eslint --no-inline-config packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts packages/runtime/src/domains/activation-gate.ts packages/runtime/src/domains/automation-activation-posture-gate.test.ts packages/runtime/src/domains/automation-body-validation.test.ts packages/runtime/src/domains/automation-packaged-base-lock.test.ts packages/runtime/src/domains/automation-toggle-caller.test.ts packages/runtime/src/domains/automation-toggle-deny-message.test.ts packages/runtime/src/domains/automation-toggle-unknown-flow.test.ts packages/runtime/src/domains/automation-write-capability-gate.test.ts packages/runtime/src/domains/automation.ts packages/runtime/src/http-dispatcher.test.ts packages/services/service-automation/src/engine.ts packages/services/service-automation/src/flow-definitions.ts packages/services/service-automation/src/flow-toggle-caller-attribution.test.ts ` :: exit 0 </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (Scoped to the touched files; the repo-wide run is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The 维护者速读 block is the maintainer-facing Chinese channel.) --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5pwL1eE2BziiUM28wm Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c34e5d commit 8c0306f

16 files changed

Lines changed: 269 additions & 44 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(service-automation): switching a packaged flow off now records which administrator did it
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
The flow toggle (`POST /api/v1/automation/:name/toggle`) writes a row in the activation ledger (`sys_metadata_activation`). Until now, that row and its audit entry named nobody: `created_by` / `updated_by` on the ledger row and `user_id` / `actor` on the `sys_audit_log` row were null, even when a signed-in administrator made the request. The action and security catalog doors were fixed in the previous release; the flow door reaches the ledger through `IAutomationService.toggleFlow`, which could not carry the caller until its `caller` parameter landed.
12+
13+
- `@objectstack/runtime`: the flow toggle door passes `toggleFlow(name, enabled, { userId })`, the signed-in user's id and nothing else.
14+
- `@objectstack/service-automation`: `AutomationEngine.toggleFlow(name, enabled, caller?)` implements the contract's `caller`. The ledger write runs as that user, elevated and with no positions or permissions, so the engine and the audit plugin record the user's id on both rows. Only `userId` is read from `caller`. With no caller, or a caller without an id, the write stays unattributed, as before. `FlowActivationStore.setActive(row, options?)` takes the same optional `{ context }` as the shared activation store in `@objectstack/core`; a store that ignores it keeps working.

‎packages/qa/dogfood/test/activation-ledger-actor.dogfood.test.ts‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,23 @@
77
// ## The measurement this file turns around
88
//
99
// Stock showcase, fresh database, signed in as the dev admin. Switching off a
10-
// packaged action (`POST /api/v1/actions/_activation/:object/:action`) and a
11-
// declared position (`POST /api/v1/security/_activation/position/:name`)
12-
// answered 200 and wrote the row — and all four actor fields read null. The
13-
// doors wrote through the activation store's constant system context, which
14-
// names nobody, so neither the engine's audit stamps nor the audit plugin had a
15-
// user to record. The same request window's own sign-in rows did carry the
16-
// admin's id.
10+
// packaged action (`POST /api/v1/actions/_activation/:object/:action`), a
11+
// declared position (`POST /api/v1/security/_activation/position/:name`) and a
12+
// packaged flow (`POST /api/v1/automation/:name/toggle`) answered 200 and wrote
13+
// the row — and all four actor fields read null. The doors wrote through the
14+
// activation store's constant system context, which names nobody, so neither
15+
// the engine's audit stamps nor the audit plugin had a user to record. The same
16+
// request window's own sign-in rows did carry the admin's id.
1717
//
1818
// What only a boot can measure: the stamp lands through the REAL engine (the
1919
// unit suites pin the context each door hands down, not what the engine does
2020
// with it) and the audit row is the REAL audit plugin's, reading the same
2121
// session. The showcase boot composes no audit plugin of its own, so this file
2222
// adds `AuditPlugin`, as `objectstack serve` composes it.
2323
//
24-
// ⛔ The flow toggle (`POST /api/v1/automation/:name/toggle`) is not here: it
25-
// reaches the ledger through `IAutomationService.toggleFlow(name, enabled)`, a
26-
// contract with no caller channel yet.
24+
// The flow door reaches the ledger through the automation service, not the
25+
// engine: `IAutomationService.toggleFlow`'s `caller` carries the admin's id
26+
// across that seam, so its case is the one that proves the id survives it.
2727

2828
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
2929
import { type VerifyStack } from '@objectstack/verify';
@@ -40,6 +40,8 @@ const ACTION = 'showcase_mark_done';
4040
const ACTION_OBJECT = 'showcase_task';
4141
/** A position the showcase declares and nobody needs switched on during this file. */
4242
const POSITION = 'auditor';
43+
/** A packaged showcase flow that no other flow calls as a subflow, so the §7.3 guard never refuses it. */
44+
const FLOW = 'showcase_scheduled_digest';
4345

4446
interface Engine {
4547
find(object: string, options?: unknown): Promise<Array<Record<string, unknown>>>;
@@ -89,6 +91,7 @@ describe('the activation ledger names the administrator who switched an item', (
8991
for (const door of [
9092
{ label: 'a packaged action', type: 'action', name: ACTION, path: `/actions/_activation/${ACTION_OBJECT}/${ACTION}` },
9193
{ label: 'a declared position', type: 'position', name: POSITION, path: `/security/_activation/position/${POSITION}` },
94+
{ label: 'a packaged flow', type: 'flow', name: FLOW, path: `/automation/${FLOW}/toggle` },
9295
] as const) {
9396
it(`${door.label}: switching it OFF names the admin on the row and on its audit row`, async () => {
9497
const res = await stack.apiAs(token, 'POST', door.path, { enabled: false });

‎packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ for (const posture of ['group', 'isolated'] as const) {
256256
expect({ nameRead, gate: statusOf(response) === 200, rung }).toEqual({
257257
nameRead: true, gate: true, rung: true,
258258
});
259-
expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false);
259+
expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false, expect.anything());
260260
});
261261
});
262262
}

‎packages/runtime/src/domains/activation-gate.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,10 +439,25 @@ export function refuseUngrantedActivationAuthoring(
439439
* bare system context, which the store falls back to, names nobody.
440440
*
441441
* Shared by every door that writes a `sys_metadata_activation` row for a
442-
* caller, so the doors cannot disagree about who a row names.
442+
* caller, so the doors cannot disagree about who a row names. The flow door is
443+
* the exception that proves it: its write happens behind
444+
* `IAutomationService.toggleFlow`, so it hands over {@link activationCaller}
445+
* and the automation service builds the context.
443446
*/
444447
export function activationWriteContext(context: HttpProtocolContext): MetadataActivationWriteContext {
445448
const ec: unknown = context?.executionContext;
446449
const caller = ec && typeof ec === 'object' ? (ec as Record<string, unknown>) : {};
447450
return { ...caller, isSystem: true };
448451
}
452+
453+
/**
454+
* [#26474] Who an activation door's caller is, in the shape
455+
* `IAutomationService.toggleFlow`'s `caller` declares: the signed-in user's id
456+
* and nothing else. No positions, permissions or tenant: the gates above have
457+
* already judged the caller, and the channel is attribution only. A context
458+
* with no user yields `{}`, an unattributed write.
459+
*/
460+
export function activationCaller(context: HttpProtocolContext): { userId?: string } {
461+
const userId = (context?.executionContext as { userId?: unknown } | undefined)?.userId;
462+
return typeof userId === 'string' && userId !== '' ? { userId } : {};
463+
}

‎packages/runtime/src/domains/automation-activation-posture-gate.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ describe('ADR-0126 §5 — the activation write is operator-gated in walled post
156156
const { response } = await toggle(h, TENANT_ADMIN());
157157

158158
expect(statusOf(response)).toBe(200);
159-
expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false);
159+
expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false, expect.anything());
160160
});
161161

162162
it('so may the platform operator', async () => {
@@ -165,7 +165,7 @@ describe('ADR-0126 §5 — the activation write is operator-gated in walled post
165165
const { response } = await toggle(h, PLATFORM_OPERATOR());
166166

167167
expect(statusOf(response)).toBe(200);
168-
expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false);
168+
expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false, expect.anything());
169169
});
170170

171171
it('no tenancy service at all behaves like `single` (ADR-0093 D4/D5)', async () => {
@@ -223,7 +223,7 @@ describe('ADR-0126 §5 — the activation write is operator-gated in walled post
223223
const { response } = await toggle(h, PLATFORM_OPERATOR());
224224

225225
expect(statusOf(response)).toBe(200);
226-
expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false);
226+
expect(h.toggleFlow).toHaveBeenCalledWith(FLOW, false, expect.anything());
227227
});
228228

229229
it('[#15981] REFUSES a tenant admin holding only a MINTED `platform_admin` name', async () => {

‎packages/runtime/src/domains/automation-body-validation.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,14 @@ describe('#3899 ③ — POST /automation/:name/toggle is { enabled?: boolean },
129129
const result = await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', { enabled: false }, CTX);
130130
expect(result.response?.status).toBe(200);
131131
expect(result.response?.body?.data ?? result.response?.body).toMatchObject({ enabled: false });
132-
expect(spies.toggleFlow).toHaveBeenCalledWith('welcome_flow', false);
132+
expect(spies.toggleFlow).toHaveBeenCalledWith('welcome_flow', false, expect.anything());
133133
});
134134

135135
it('keeps the documented legacy shape: an empty body enables', async () => {
136136
const { dispatcher, spies } = makeDispatcher();
137137
const result = await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', undefined, CTX);
138138
expect(result.response?.status).toBe(200);
139-
expect(spies.toggleFlow).toHaveBeenCalledWith('welcome_flow', true);
139+
expect(spies.toggleFlow).toHaveBeenCalledWith('welcome_flow', true, expect.anything());
140140
});
141141
});
142142

‎packages/runtime/src/domains/automation-packaged-base-lock.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ describe('what the lock leaves open', () => {
365365
`/${PACKAGED}/toggle`, 'POST', { enabled: false }, AUTHOR(), undefined,
366366
);
367367
expect(statusOf(response)).toBe(200);
368-
expect(h.toggleFlow).toHaveBeenCalledWith(PACKAGED, false);
368+
expect(h.toggleFlow).toHaveBeenCalledWith(PACKAGED, false, expect.anything());
369369
});
370370

371371
it('[ADR-0131 D6] the operator hatch (OS_METADATA_WRITABLE) opens this door exactly as it opens /meta: not at all', async () => {
@@ -396,7 +396,7 @@ describe('what the lock leaves open', () => {
396396
`/${PACKAGED}/toggle`, 'POST', { enabled: false }, AUTHOR(), undefined,
397397
);
398398
expect(statusOf(toggle.response)).toBe(200);
399-
expect(h.toggleFlow).toHaveBeenCalledWith(PACKAGED, false);
399+
expect(h.toggleFlow).toHaveBeenCalledWith(PACKAGED, false, expect.anything());
400400
});
401401
});
402402

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#26474] `POST /automation/:name/toggle` hands the service who asked.
5+
*
6+
* The flow door called `toggleFlow(name, enabled)` with no caller, so the
7+
* activation-ledger row it writes, and that row's audit entry, named nobody.
8+
* `IAutomationService.toggleFlow` now declares `caller` (`{ userId }`, #26545):
9+
* a door that serves a person passes that person's id, and nothing else — no
10+
* positions, permissions or tenant, since the door has already judged the
11+
* caller's authority and the write itself runs elevated.
12+
*/
13+
14+
import { describe, it, expect, vi } from 'vitest';
15+
16+
import { HttpDispatcher } from '../http-dispatcher.js';
17+
18+
function makeDispatcher() {
19+
const toggleFlow = vi.fn(async (_name: string, _on: boolean, _caller?: { userId?: string }) => undefined);
20+
const services: Record<string, unknown> = {
21+
automation: {
22+
getFlow: async (name: string) => (name === 'welcome_flow' ? { name, trigger: { type: 'manual' } } : null),
23+
toggleFlow,
24+
},
25+
};
26+
const resolve = (name: string) => services[name];
27+
const kernel: any = {
28+
getService: resolve,
29+
getServiceAsync: async (name: string) => resolve(name),
30+
context: { getService: resolve },
31+
};
32+
return { dispatcher: new HttpDispatcher(kernel), toggleFlow };
33+
}
34+
35+
/** The toggle is a `manage_metadata` write (commit 266436a7f); the caller holds it. */
36+
const ctxOf = (executionContext: Record<string, unknown>) => ({
37+
request: {},
38+
executionContext: { systemPermissions: ['manage_metadata'], ...executionContext },
39+
}) as any;
40+
41+
describe('#26474 — the flow toggle door passes its caller to toggleFlow', () => {
42+
it('names the signed-in user, in both directions', async () => {
43+
const { dispatcher, toggleFlow } = makeDispatcher();
44+
const ctx = ctxOf({ userId: 'u_admin' });
45+
46+
const off = await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', { enabled: false }, ctx);
47+
expect(off.response?.status).toBe(200);
48+
expect(toggleFlow).toHaveBeenLastCalledWith('welcome_flow', false, { userId: 'u_admin' });
49+
50+
const on = await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', { enabled: true }, ctx);
51+
expect(on.response?.status).toBe(200);
52+
expect(toggleFlow).toHaveBeenLastCalledWith('welcome_flow', true, { userId: 'u_admin' });
53+
});
54+
55+
it('passes the id and nothing else of the caller\'s context', async () => {
56+
const { dispatcher, toggleFlow } = makeDispatcher();
57+
const ctx = ctxOf({ userId: 'u_admin', positions: ['admin'], tenantId: 'org_1', isSystem: false });
58+
59+
await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', { enabled: false }, ctx);
60+
61+
const caller = toggleFlow.mock.calls.at(-1)?.[2];
62+
expect(caller).toEqual({ userId: 'u_admin' });
63+
});
64+
});

‎packages/runtime/src/domains/automation-toggle-deny-message.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,11 +298,11 @@ describe('#11666 — the enablement door refuses in its own words', () => {
298298

299299
const off = await h.dispatch('POST', `/automation/${FLOW}/toggle`, { enabled: false });
300300
expect(statusOf(off)).toBe(200);
301-
expect(h.toggleFlow).toHaveBeenLastCalledWith(FLOW, false);
301+
expect(h.toggleFlow).toHaveBeenLastCalledWith(FLOW, false, expect.anything());
302302

303303
const on = await h.dispatch('POST', `/automation/${FLOW}/toggle`, { enabled: true });
304304
expect(statusOf(on)).toBe(200);
305-
expect(h.toggleFlow).toHaveBeenLastCalledWith(FLOW, true);
305+
expect(h.toggleFlow).toHaveBeenLastCalledWith(FLOW, true, expect.anything());
306306
});
307307

308308
it('an entitled caller still writes definitions', async () => {

‎packages/runtime/src/domains/automation-toggle-unknown-flow.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,18 +112,18 @@ describe('#7535 — toggling a flow that does not exist is 404, not 500', () =>
112112
const off = await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', { enabled: false }, CTX);
113113
expect(off.response?.status).toBe(200);
114114
expect(off.response?.body?.data ?? off.response?.body).toMatchObject({ name: 'welcome_flow', enabled: false });
115-
expect(spies.toggleFlow).toHaveBeenLastCalledWith('welcome_flow', false);
115+
expect(spies.toggleFlow).toHaveBeenLastCalledWith('welcome_flow', false, expect.anything());
116116
expect(enabled.get('welcome_flow')).toBe(false);
117117

118118
const on = await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', { enabled: true }, CTX);
119119
expect(on.response?.status).toBe(200);
120-
expect(spies.toggleFlow).toHaveBeenLastCalledWith('welcome_flow', true);
120+
expect(spies.toggleFlow).toHaveBeenLastCalledWith('welcome_flow', true, expect.anything());
121121
expect(enabled.get('welcome_flow')).toBe(true);
122122

123123
// The documented legacy shape: a bodyless toggle enables.
124124
const legacy = await dispatcher.handleAutomation('/welcome_flow/toggle', 'POST', undefined, CTX);
125125
expect(legacy.response?.status).toBe(200);
126-
expect(spies.toggleFlow).toHaveBeenLastCalledWith('welcome_flow', true);
126+
expect(spies.toggleFlow).toHaveBeenLastCalledWith('welcome_flow', true, expect.anything());
127127
});
128128

129129
it('leaves #3899 strict-body validation intact — a bad body is still a located 400', async () => {
@@ -171,6 +171,6 @@ describe('#7535 — toggling a flow that does not exist is 404, not 500', () =>
171171

172172
const result = await dispatcher.handleAutomation('/whatever/toggle', 'POST', { enabled: false }, CTX);
173173
expect(result.response?.status).toBe(200);
174-
expect(toggleFlow).toHaveBeenCalledWith('whatever', false);
174+
expect(toggleFlow).toHaveBeenCalledWith('whatever', false, expect.anything());
175175
});
176176
});

0 commit comments

Comments
 (0)