Skip to content

Commit f381e8f

Browse files
committed
fix(service-automation): the flow toggle records which admin switched a packaged flow
The flow toggle door now hands `toggleFlow` its caller (`{ userId }`, the contract's `caller`), and the automation engine writes the `sys_metadata_activation` row under that user, elevated, through the store's `setActive(row, { context })` channel. The engine stamps created_by and updated_by from it and the audit plugin records the same id, so both rows name the administrator. Only the id crosses; with no caller the write stays unattributed. The dogfood actor file gains the flow door's case. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5pwL1eE2BziiUM28wm
1 parent 1080f07 commit f381e8f

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)