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
12 changes: 12 additions & 0 deletions .changeset/27414-flow-save-actor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/runtime': patch
---

fix(runtime): creating, updating or cloning a flow over `/automation` now records which user saved it

Clause-②: no
Metadata-protocol: none

`POST /api/v1/automation`, `PUT /api/v1/automation/:name` and `POST /api/v1/automation/:name/clone` store the flow as a `sys_metadata` row. Until now that row named nobody: `created_by` and `updated_by`, and `recorded_by` on its `sys_metadata_history` entry, were null even when a signed-in administrator made the request. The `/meta` door already recorded the user. All three `/automation` doors now pass the signed-in user's id as the save's actor, so the row names that user, the same way `PUT /api/v1/meta/flow/:name` does. A request with no user, such as the engine's own system call, still saves with no actor, as before.

The `sys_audit_log` row for the same write is not changed by this fix. It still has a null `user_id` on every metadata door, `/meta` included.
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ describe('[#20862] the create and update doors save what they register', () => {
expect(dataOf(response)?.name).toBe(NEW);
expect(h.registerFlow).toHaveBeenCalledWith(NEW, body);
expect(h.saveMetaItem).toHaveBeenCalledTimes(1);
expect(h.saveMetaItem).toHaveBeenCalledWith({ type: 'flow', name: NEW, item: body });
expect(h.saveMetaItem).toHaveBeenCalledWith({ type: 'flow', name: NEW, item: body, actor: 'u_admin' });
expect(h.registerFlow.mock.invocationCallOrder[0]).toBeLessThan(h.saveMetaItem.mock.invocationCallOrder[0]!);
});

Expand All @@ -119,7 +119,7 @@ describe('[#20862] the create and update doors save what they register', () => {

expect(statusOf(response)).toBe(200);
expect(h.registerFlow).toHaveBeenCalledWith(CUSTOMER, definition);
expect(h.saveMetaItem).toHaveBeenCalledWith({ type: 'flow', name: CUSTOMER, item: definition });
expect(h.saveMetaItem).toHaveBeenCalledWith({ type: 'flow', name: CUSTOMER, item: definition, actor: 'u_admin' });
expect(h.held(CUSTOMER)).toEqual(definition);
});

Expand Down
141 changes: 141 additions & 0 deletions packages/runtime/src/domains/automation-flow-save-actor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#27414, ADR-0073 D3] The `/automation` definition doors record who saved.
*
* `POST /`, `PUT /:name` and `POST /:name/clone` persist through one path,
* `registerAndSaveFlow`, which called `protocol.saveMetaItem` with no `actor`
* although the execution context it had just admitted carried the caller's
* `userId`. So the `sys_metadata` row a signed-in admin created, updated or
* cloned recorded `null` in `created_by` / `updated_by` and in
* `sys_metadata_history.recorded_by`. The save now passes the caller's id the
* way the toggle door does (`activationCaller`), and nothing when the context
* has no user.
*
* ## The rig
*
* One REAL store, as in `meta-save-actor.test.ts`: better-sqlite3 `:memory:`,
* the real `sys_metadata*` objects and a real
* `ObjectStackProtocolImplementation`, so the claim is about the rows, read
* back after every save. The automation service is a fake that holds the
* flows it is given; the door is driven through the real `HttpDispatcher`.
*
* The CONTROL is a caller with no user (`isSystem`, the engine's own
* self-invocation shape): its save records null, as before.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata';

import { HttpDispatcher } from '../http-dispatcher.js';
import type { HttpProtocolContext } from '../http-dispatcher.js';

/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */
const TEST_PACKAGE_ID = 'objectstack-test';
const SOURCE = 'customer_source_flow';

const definitionOf = (name: string, label: string) => ({
name,
label,
type: 'autolaunched',
nodes: [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});

/** An author holds `manage_metadata`, so every door below admits the write. */
const author = (userId: string): HttpProtocolContext => ({
request: {},
executionContext: { userId, systemPermissions: ['manage_metadata'] },
} as HttpProtocolContext);
/** CONTROL: engine self-invocation, admitted by `isSystem`, with no user to record. */
const SYSTEM = (): HttpProtocolContext => ({ request: {}, executionContext: { isSystem: true } } as HttpProtocolContext);

interface Recorded { created_by: unknown; updated_by: unknown; recorded_by: unknown[] }

const liveEngines: ObjectQL[] = [];
afterEach(async () => {
while (liveEngines.length) {
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
}
});

async function boot() {
const engine = new ObjectQL();
liveEngines.push(engine);
engine.registerDriver(new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
}), true);
await engine.init();
engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID);
engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID);
engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID);
await engine.syncSchemas();
const protocol = new ObjectStackProtocolImplementation(engine as any);

const flows = new Map<string, unknown>([[SOURCE, definitionOf(SOURCE, 'Source')]]);
const automation = {
handlerReady: true,
registerFlow: (name: string, definition: unknown) => { flows.set(name, definition); return definition; },
unregisterFlow: (name: string) => { flows.delete(name); },
getFlow: async (name: string) => flows.get(name) ?? null,
listFlows: async () => [...flows.keys()],
};
const services: Record<string, unknown> = { protocol, automation };
const resolve = (name: string): unknown => services[name];
const kernel: any = { getService: resolve, getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve } };

const recorded = async (name: string): Promise<Recorded> => {
const rows = await engine.find('sys_metadata', { where: { type: 'flow', name } });
expect(rows ?? [], `one sys_metadata row for ${name}`).toHaveLength(1);
const history = await engine.find('sys_metadata_history', { where: { type: 'flow', name } });
return {
created_by: (rows as any[])[0].created_by ?? null,
updated_by: (rows as any[])[0].updated_by ?? null,
recorded_by: [...(history ?? [])]
.sort((a: any, b: any) => Number(a.version) - Number(b.version))
.map((h: any) => h.recorded_by ?? null),
};
};
return { dispatcher: new HttpDispatcher(kernel), recorded };
}

const statusOf = (r: { response?: { status?: number } }) => r.response?.status;

describe('[#27414] the /automation definition doors record the user who saved', () => {
it('POST / records the creator; PUT /:name by another user records that user as the updater', async () => {
const h = await boot();
expect(statusOf(await h.dispatcher.handleAutomation('', 'POST', definitionOf('new_flow', 'v1'), author('u_author'), undefined))).toBe(200);
expect(await h.recorded('new_flow')).toEqual({ created_by: 'u_author', updated_by: 'u_author', recorded_by: ['u_author'] });

const definition = definitionOf('new_flow', 'v2');
expect(statusOf(await h.dispatcher.handleAutomation('/new_flow', 'PUT', { definition }, author('u_editor'), undefined))).toBe(200);
expect(await h.recorded('new_flow')).toEqual({ created_by: 'u_author', updated_by: 'u_editor', recorded_by: ['u_author', 'u_editor'] });
}, 60_000);

it('POST /:name/clone records the admin who cloned on the copy', async () => {
const h = await boot();
const result = await h.dispatcher.handleAutomation(
`/${SOURCE}/clone`, 'POST', { name: 'my_copy', label: 'My Copy' }, author('u_admin'), undefined,
);
expect(statusOf(result)).toBe(200);
expect(await h.recorded('my_copy')).toEqual({ created_by: 'u_admin', updated_by: 'u_admin', recorded_by: ['u_admin'] });
}, 60_000);

it('CONTROL: a caller with no user records null on all three doors, never a label', async () => {
const h = await boot();
expect(statusOf(await h.dispatcher.handleAutomation('', 'POST', definitionOf('sys_flow', 'v1'), SYSTEM(), undefined))).toBe(200);
expect(statusOf(await h.dispatcher.handleAutomation('/sys_flow', 'PUT', { definition: definitionOf('sys_flow', 'v2') }, SYSTEM(), undefined))).toBe(200);
expect(await h.recorded('sys_flow')).toEqual({ created_by: null, updated_by: null, recorded_by: [null, null] });

expect(statusOf(await h.dispatcher.handleAutomation(`/${SOURCE}/clone`, 'POST', { name: 'sys_copy', label: 'System Copy' }, SYSTEM(), undefined))).toBe(200);
expect(await h.recorded('sys_copy')).toEqual({ created_by: null, updated_by: null, recorded_by: [null] });
}, 60_000);
});
12 changes: 11 additions & 1 deletion packages/runtime/src/domains/automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1916,6 +1916,8 @@ async function flowWriteRefusal(
* What is saved is the definition as the caller's door built it — never the
* engine's parsed copy, whose materialized schema defaults would freeze on
* disk (the save canonicalizes it the way it does every `/meta` flow write).
* It is saved under the caller's user id as `actor` [#27414], as the `/meta`
* door's save is.
*/
async function registerAndSaveFlow(
deps: DomainHandlerDeps,
Expand All @@ -1938,8 +1940,16 @@ async function registerAndSaveFlow(
};
}
if (typeof store?.saveMetaItem === 'function') {
// [#27414, ADR-0073 D3] The save names who asked: the caller's user id
// as the save's `actor`, which the store records in
// `sys_metadata.created_by` / `updated_by` and the row's history. Read
// through the toggle door's `activationCaller`: the signed-in user's
// id off the execution context and nothing else, never off the body,
// so the flow doors cannot disagree about who a write names. A context
// with no user passes no actor, an unattributed write as before.
const { userId: actor } = activationCaller(context);
try {
await store.saveMetaItem({ type: FLOW_METADATA_TYPE, name, item: definition });
await store.saveMetaItem({ type: FLOW_METADATA_TYPE, name, item: definition, ...(actor ? { actor } : {}) });
} catch (e) {
restoreFlowRegistration(automationService, name, held);
return {
Expand Down
Loading