From 579c6a50d5c7eabcf129a188c8ce5f9a063a3e0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 12:35:15 +0000 Subject: [PATCH 1/5] fix(runtime): package doors record the session user, never a body actor Discard-drafts, commit revert, rollback, adopt-orphans, duplicate and the publish-drafts app unhide save now record the request's authenticated user through one helper (packageWriteActor), the rule resolveMetaWriteActor states for the single-item door. A body actor is ignored, not refused. Claude-Session: https://claude.ai/code/session_01QUs3BvyWc2j7Vh5KBnJtam Co-authored-by: Claude --- packages/client/src/index.ts | 15 ++++++---- packages/runtime/src/domains/packages.ts | 38 ++++++++++++++++++++---- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 9a2dde7dff0..f078a559bb1 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -3032,7 +3032,7 @@ export class ObjectStackClient { }, /** ADR-0033: drop every pending draft bound to the package. */ - discardDrafts: async (id: string, opts?: { actor?: string }): Promise => { + discardDrafts: async (id: string, opts?: { /** @deprecated Ignored: the server records the signed-in caller, never a name the request supplies. */ actor?: string }): Promise => { const route = this.getRoute('packages'); const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/discard-drafts`, { method: 'POST', @@ -3053,7 +3053,7 @@ export class ObjectStackClient { }, /** ADR-0067: revert ONE commit (the revert is itself a commit). */ - revertCommit: async (id: string, commitId: string, opts?: { actor?: string }): Promise => { + revertCommit: async (id: string, commitId: string, opts?: { /** @deprecated Ignored: the server records the signed-in caller, never a name the request supplies. */ actor?: string }): Promise => { const route = this.getRoute('packages'); const res = await this.fetch( `${this.baseUrl}${route}/${encodeURIComponent(id)}/commits/${encodeURIComponent(commitId)}/revert`, @@ -3063,7 +3063,7 @@ export class ObjectStackClient { }, /** ADR-0067: roll back through all commits newer than `commitId`. */ - rollback: async (id: string, commitId: string, opts?: { actor?: string }): Promise => { + rollback: async (id: string, commitId: string, opts?: { /** @deprecated Ignored: the server records the signed-in caller, never a name the request supplies. */ actor?: string }): Promise => { const route = this.getRoute('packages'); const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/rollback`, { method: 'POST', @@ -3096,7 +3096,7 @@ export class ObjectStackClient { }, /** ADR-0070 D5: bulk-rebind package-less (orphaned) metadata into this base. */ - adoptOrphans: async (id: string, opts?: { actor?: string }): Promise => { + adoptOrphans: async (id: string, opts?: { /** @deprecated Ignored: the server records the signed-in caller, never a name the request supplies. */ actor?: string }): Promise => { const route = this.getRoute('packages'); const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}/adopt-orphans`, { method: 'POST', @@ -3113,7 +3113,12 @@ export class ObjectStackClient { duplicate: async ( id: string, targetPackageId: string, - opts?: { targetName?: string; targetNamespace?: string; actor?: string }, + opts?: { + targetName?: string; + targetNamespace?: string; + /** @deprecated Ignored: the server records the signed-in caller, never a name the request supplies. */ + actor?: string; + }, ): Promise => { // ⚠ `success` on the returned payload is the OPERATION's verdict // (false for a partial or empty duplicate) — the envelope-level diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 24535658be0..42aca551739 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -343,6 +343,32 @@ function requireManageMetadata(deps: DomainHandlerDeps, context: HttpProtocolCon return null; } +/** + * [#28414] The identity a package write door records on the history rows and + * package commits its protocol call writes: the request's authenticated user + * id, read off the execution context the capability gate above admitted, or + * nothing for a caller with none (a system context), so the protocol records + * the actor-less `null` (#4556) — the rule `RestServer.resolveMetaWriteActor` + * states for the single-item door and the `/meta` dispatcher's save door + * follows (`meta.ts`). + * + * ⛔ Never read off the body or a header. The doors used to pass a body + * `actor` straight to the protocol, so any caller holding `manage_metadata` + * could sign another person's name into the record an administrator reads to + * decide what to roll back and whom to ask. A body `actor` is ignored, not + * refused: a client still sending one keeps working and is recorded as + * itself. No caller acts on behalf of another identity through these doors + * (measured: the console and the SDK send none, the CLI calls none); a job + * that must is served server-side by calling the protocol with its own + * `actor`, never by trusting a body. + * + * One helper for every door; spread its answer into the protocol request. + */ +function packageWriteActor(context: HttpProtocolContext): { actor?: string } { + const userId = (context?.executionContext as { userId?: unknown } | undefined)?.userId; + return typeof userId === 'string' && userId ? { actor: userId } : {}; +} + /** * ADR-0106 D4 READ gate for the `/packages` domain (#7033 / #7023). * @@ -2071,7 +2097,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin name: app.name, item, packageId: id, - ...(body?.actor ? { actor: body.actor } : {}), + ...packageWriteActor(_context), }); flipped.push(app.name); } @@ -2236,7 +2262,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin try { const result = await protocol.discardPackageDrafts({ packageId: id, - ...(body?.actor ? { actor: body.actor } : {}), + ...packageWriteActor(_context), }); // The twin of `publish-drafts`' 404: an empty discard is the // answer for a package that exists — see `packageIsUnknown`. @@ -2298,7 +2324,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin try { const result = await protocol.revertCommit({ commitId, - ...(body?.actor ? { actor: body.actor } : {}), + ...packageWriteActor(_context), }); return { handled: true, response: deps.success(result) }; } catch (e: any) { @@ -2324,7 +2350,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin try { const result = await protocol.rollbackToPackageCommit({ commitId: String(body.commitId), - ...(body?.actor ? { actor: body.actor } : {}), + ...packageWriteActor(_context), }); return { handled: true, response: deps.success(result) }; } catch (e: any) { @@ -2419,7 +2445,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin try { const result = await protocol.reassignOrphanedMetadata({ targetPackageId: id, - ...(body?.actor ? { actor: body.actor } : {}), + ...packageWriteActor(_context), }); return { handled: true, response: deps.success(result) }; } catch (e: any) { @@ -2464,7 +2490,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin targetPackageId, ...(typeof body?.targetName === 'string' ? { targetName: body.targetName } : {}), ...(typeof body?.targetNamespace === 'string' ? { targetNamespace: body.targetNamespace } : {}), - ...(body?.actor ? { actor: body.actor } : {}), + ...packageWriteActor(_context), }); return { handled: true, response: deps.success(result) }; } catch (e: any) { From 09d96497565efd45e6d67460f66d2346525c8db0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 12:44:04 +0000 Subject: [PATCH 2/5] test(runtime): pin the session actor on each package write door Claude-Session: https://claude.ai/code/session_01QUs3BvyWc2j7Vh5KBnJtam Co-authored-by: Claude --- .../28414-package-doors-session-actor.md | 15 ++ .../packages-write-actor-session.test.ts | 150 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 .changeset/28414-package-doors-session-actor.md create mode 100644 packages/runtime/src/domains/packages-write-actor-session.test.ts diff --git a/.changeset/28414-package-doors-session-actor.md b/.changeset/28414-package-doors-session-actor.md new file mode 100644 index 00000000000..5ec8624aa2a --- /dev/null +++ b/.changeset/28414-package-doors-session-actor.md @@ -0,0 +1,15 @@ +--- +'@objectstack/runtime': patch +'@objectstack/client': patch +--- + +fix(runtime): the package doors record the signed-in caller in metadata history, never a name the request body supplies + +Clause-②: no +Metadata-protocol: none + +Metadata history and package commits are what an administrator reads to decide what to roll back and whom to ask. Five package doors — `POST /packages/:id/discard-drafts`, `POST /packages/:id/commits/:commitId/revert`, `POST /packages/:id/rollback`, `POST /packages/:id/adopt-orphans` and `POST /packages/:id/duplicate` — and the app re-save that `POST /packages/:id/publish-drafts` performs to unhide a package's apps used to record whatever `actor` the request body named, so any caller allowed to manage metadata could make a change look as if someone else made it. + +They now record the request's authenticated user, the same identity the single-item metadata doors record. A body `actor` is ignored, not refused: a client that still sends one keeps working and is recorded as itself. A system call with no user keeps recording no actor (`null`), as before. + +In `@objectstack/client`, the `actor` option of `packages.discardDrafts`, `packages.revertCommit`, `packages.rollback`, `packages.adoptOrphans` and `packages.duplicate` is marked deprecated: the server ignores it. Drop it from your calls; nothing else changes. diff --git a/packages/runtime/src/domains/packages-write-actor-session.test.ts b/packages/runtime/src/domains/packages-write-actor-session.test.ts new file mode 100644 index 00000000000..007f60b0fba --- /dev/null +++ b/packages/runtime/src/domains/packages-write-actor-session.test.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#28414] The package write doors record the request's authenticated user on + * the history rows and package commits their protocol call writes — never a + * name the request body supplies. + * + * Measured before this change, driving `dispatch()`: each door below passed + * `body.actor` straight into its protocol request, so a caller holding + * `manage_metadata` who sent `{ actor: 'u_someone_else' }` had the protocol + * stamp `u_someone_else` into `sys_metadata_history.recorded_by` and the + * package commit. The single-item door already took the session identity + * (`RestServer.resolveMetaWriteActor`); these doors now share one helper, + * `packageWriteActor`, that reads the same identity. + * + * Both halves per door: the session user is what the protocol receives when + * the body names another user, and a caller with no user id (a system + * context) keeps the actor-less fallback — no `actor` key, so the protocol + * records `null` (#4556) — whether or not its body names one. + * + * The publish-drafts door's own `publishPackageDrafts` call is not pinned + * here (#28406 carries it); its ADR-0045 visibility flip, which re-saves each + * `_unpublished` app through `saveMetaItem`, is. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const PKG = 'com.acme.app'; +const SESSION_USER = 'u_author'; +const SPOOFED = 'u_someone_else'; + +/** Admitted by the write doors' capability gate. */ +const AUTHOR = { userId: SESSION_USER, isSystem: false, systemPermissions: ['manage_metadata', 'studio.access'] }; +/** A system context: admitted by `isSystem`, and no user id to record. */ +const SYSTEM = { isSystem: true, systemPermissions: [] as string[] }; + +/** + * The dispatcher over a REAL registry holding one installed, writable package + * and a recording protocol: each door's protocol call is a spy, so "what was + * recorded" is the request that spy received. + */ +function boot(caller: Record) { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + (registry as any).logLevel = 'silent'; + registry.installPackage({ id: PKG, name: PKG, namespace: 'app', version: '1.0.0', type: 'app' } as any, 'authored'); + const protocol = { + discardPackageDrafts: vi.fn(async (_r: any) => ({ success: true, discardedCount: 1, failedCount: 0, discarded: [], failed: [] })), + revertCommit: vi.fn(async (_r: any) => ({ success: true })), + rollbackToPackageCommit: vi.fn(async (_r: any) => ({ success: true, revertedCommits: [], failed: [] })), + reassignOrphanedMetadata: vi.fn(async (r: any) => ({ success: true, reassignedCount: 0, reassigned: [], targetPackageId: r.targetPackageId })), + duplicatePackage: vi.fn(async (r: any) => ({ success: true, copiedCount: 0, failedCount: 0, targetPackageId: r.targetPackageId, copied: [], failed: [] })), + }; + const services: Record = { objectql: { registry }, protocol }; + const get = (n: string) => services[n] ?? null; + const kernel: any = { context: { getService: get, trigger: vi.fn() }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + // The seam `dispatch()` resolves identity through (as the sibling dispatch-level pins do). + (dispatcher as any).timedResolveExecutionContext = async () => ({ ...caller }); + return { dispatcher, protocol }; +} + +type Protocol = ReturnType['protocol']; + +interface Door { + door: string; + path: string; + /** The door's own body, without any `actor`. */ + body: Record; + act: keyof Protocol; +} + +const DOORS: Door[] = [ + { door: 'POST /packages/:id/discard-drafts', path: `/packages/${PKG}/discard-drafts`, body: {}, act: 'discardPackageDrafts' }, + { door: 'POST /packages/:id/commits/:c/revert', path: `/packages/${PKG}/commits/c_1/revert`, body: {}, act: 'revertCommit' }, + { door: 'POST /packages/:id/rollback', path: `/packages/${PKG}/rollback`, body: { commitId: 'c_1' }, act: 'rollbackToPackageCommit' }, + { door: 'POST /packages/:id/adopt-orphans', path: `/packages/${PKG}/adopt-orphans`, body: {}, act: 'reassignOrphanedMetadata' }, + { door: 'POST /packages/:id/duplicate', path: `/packages/${PKG}/duplicate`, body: { targetPackageId: 'com.acme.copy' }, act: 'duplicatePackage' }, +]; + +async function post(caller: Record, door: Door, body: Record) { + const { dispatcher, protocol } = boot(caller); + const res = await dispatcher.dispatch('POST', door.path, body, {}, { request: { headers: {} } } as any); + const status = res.response?.status ?? (res.handled ? 200 : 0); + expect(status, `expected 200, got ${JSON.stringify(res.response)}`).toBe(200); + const spy = protocol[door.act]; + expect(spy).toHaveBeenCalledTimes(1); + return spy.mock.calls[0][0] as Record; +} + +describe.each(DOORS)('[#28414] $door records the session user, never a body actor', (door) => { + it('a body naming another user records the session user', async () => { + const request = await post(AUTHOR, door, { ...door.body, actor: SPOOFED }); + expect(request.actor).toBe(SESSION_USER); + }); + + it('a body naming no one records the session user too', async () => { + const request = await post(AUTHOR, door, { ...door.body }); + expect(request.actor).toBe(SESSION_USER); + }); + + it('a system context keeps the actor-less fallback, whatever its body names', async () => { + const plain = await post(SYSTEM, door, { ...door.body }); + expect(plain).not.toHaveProperty('actor'); + const named = await post(SYSTEM, door, { ...door.body, actor: SPOOFED }); + expect(named).not.toHaveProperty('actor'); + }); +}); + +describe('[#28414] the publish-drafts visibility flip re-saves each app as the session user', () => { + function makeDoor(executionContext: Record) { + const publishPackageDrafts = vi.fn().mockResolvedValue({ + success: true, publishedCount: 0, failedCount: 0, published: [], failed: [], + seedApplied: { success: true, inserted: 0 }, + }); + const getMetaItems = vi.fn().mockResolvedValue({ items: [{ name: 'hidden_app', label: 'Hidden', _unpublished: true }] }); + const saveMetaItem = vi.fn().mockResolvedValue({ success: true }); + const services: Record = { + protocol: { publishPackageDrafts, getMetaItems, saveMetaItem }, + objectql: { registry: { getAllPackages: () => [] } }, + }; + const kernel: any = { + getService: (name: string) => (name in services ? Promise.resolve(services[name]) : null), + context: { getService: () => null, trigger: vi.fn() }, + }; + const context = { request: {}, executionContext } as any; + return { dispatcher: new HttpDispatcher(kernel), saveMetaItem, context }; + } + + async function flip(executionContext: Record, body: Record) { + const { dispatcher, saveMetaItem, context } = makeDoor(executionContext); + const { response } = await dispatcher.handlePackages(`/${PKG}/publish-drafts`, 'POST', body, {}, context); + expect((response as any)?.status).toBe(200); + expect(((response as any)?.body?.data ?? (response as any)?.body)?.unhiddenApps).toEqual(['hidden_app']); + expect(saveMetaItem).toHaveBeenCalledTimes(1); + return saveMetaItem.mock.calls[0][0] as Record; + } + + it('a body naming another user re-saves the app as the session user', async () => { + const saved = await flip({ ...AUTHOR }, { actor: SPOOFED }); + expect(saved).toMatchObject({ type: 'app', name: 'hidden_app', packageId: PKG }); + expect(saved.actor).toBe(SESSION_USER); + }); + + it('a system context keeps the actor-less fallback, whatever its body names', async () => { + expect(await flip({ ...SYSTEM }, {})).not.toHaveProperty('actor'); + expect(await flip({ ...SYSTEM }, { actor: SPOOFED })).not.toHaveProperty('actor'); + }); +}); From aae06521ff073428d9e8725bc279d7338a2eeb04 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:08:00 +0000 Subject: [PATCH 3/5] test(runtime): the commit-revert routing pin expects the session actor Claude-Session: https://claude.ai/code/session_01QUs3BvyWc2j7Vh5KBnJtam Co-authored-by: Claude --- packages/runtime/src/http-dispatcher.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 2a827ff05c7..e813b1885ac 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2114,7 +2114,9 @@ describe('HttpDispatcher', () => { expect(result.handled).toBe(true); expect(result.response?.status).toBe(200); - expect(revertCommit).toHaveBeenCalledWith(expect.objectContaining({ commitId: 'cmt_1', actor: 'ai:claude' })); + // [#28414] A body `actor` is ignored: the revert commit records the + // signed-in caller (`packages-write-actor-session.test.ts` pins every door). + expect(revertCommit).toHaveBeenCalledWith(expect.objectContaining({ commitId: 'cmt_1', actor: 'u_pkg_admin' })); }); it('POST /packages/:id/rollback routes to protocol.rollbackToPackageCommit', async () => { From fea2382b5d43daa616519d9a4b3245248c3d1b1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:29:27 +0000 Subject: [PATCH 4/5] test(runtime): the publish-drafts app re-save pin expects the caller Claude-Session: https://claude.ai/code/session_01QUs3BvyWc2j7Vh5KBnJtam Co-authored-by: Claude --- ...ages-publish-drafts-request-conformance.test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/runtime/src/domains/packages-publish-drafts-request-conformance.test.ts b/packages/runtime/src/domains/packages-publish-drafts-request-conformance.test.ts index 22aeb03bc15..f37ed7cbcde 100644 --- a/packages/runtime/src/domains/packages-publish-drafts-request-conformance.test.ts +++ b/packages/runtime/src/domains/packages-publish-drafts-request-conformance.test.ts @@ -154,19 +154,17 @@ describe('publish-drafts request body conforms to PublishPackageDraftsRequestSch expect(bodyMembersSent(sent)).toEqual({}); }); - it('today the ADR-0045 app re-save after the publish still forwards a body `actor` (the one undeclared read left)', async () => { - // The publish itself records the caller (above); the visibility flip - // that re-saves each unpublished app afterwards still passes a body - // `actor` to `saveMetaItem`. Removing that read is the sibling-door - // card's (the six package doors' actor fix). When it lands this pin - // goes red: flip it to `USER`/absent and drop the sentence about it - // from the publish-drafts row note in `route-ledger.ts`. + it('the ADR-0045 app re-save after the publish records the caller too: a body `actor` reaches no write', async () => { + // [#28414] The visibility flip that re-saves each unpublished app + // afterwards used to pass a body `actor` to `saveMetaItem`, the one + // undeclared read left on this door. It now records the publisher, as + // the publish itself does (`resolvePackageWriteActor`). const { saveMetaItem } = await publishWith( { actor: 'someone_else' }, [{ name: 'crm', label: 'CRM', _unpublished: true }], ); expect(saveMetaItem).toHaveBeenCalledTimes(1); - expect((saveMetaItem.mock.calls[0]![0] as Record).actor).toBe('someone_else'); + expect((saveMetaItem.mock.calls[0]![0] as Record).actor).toBe(USER); }); }); From 4f64ab7c64284373fb9c8a5310e8c8abf805e8a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 02:52:52 +0000 Subject: [PATCH 5/5] test(runtime): name the shared actor helper the doors now consume Claude-Session: https://claude.ai/code/session_01QUs3BvyWc2j7Vh5KBnJtam Co-authored-by: Claude --- .../src/domains/packages-write-actor-session.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/domains/packages-write-actor-session.test.ts b/packages/runtime/src/domains/packages-write-actor-session.test.ts index 007f60b0fba..e1b7737a818 100644 --- a/packages/runtime/src/domains/packages-write-actor-session.test.ts +++ b/packages/runtime/src/domains/packages-write-actor-session.test.ts @@ -10,8 +10,9 @@ * `manage_metadata` who sent `{ actor: 'u_someone_else' }` had the protocol * stamp `u_someone_else` into `sys_metadata_history.recorded_by` and the * package commit. The single-item door already took the session identity - * (`RestServer.resolveMetaWriteActor`); these doors now share one helper, - * `packageWriteActor`, that reads the same identity. + * (`RestServer.resolveMetaWriteActor`); these doors now read the same + * identity through the one helper the publish-drafts door already used, + * `resolvePackageWriteActor`. * * Both halves per door: the session user is what the protocol receives when * the body names another user, and a caller with no user id (a system @@ -19,8 +20,8 @@ * records `null` (#4556) — whether or not its body names one. * * The publish-drafts door's own `publishPackageDrafts` call is not pinned - * here (#28406 carries it); its ADR-0045 visibility flip, which re-saves each - * `_unpublished` app through `saveMetaItem`, is. + * here (#28406 pinned it beside the door); its ADR-0045 visibility flip, + * which re-saves each `_unpublished` app through `saveMetaItem`, is. */ import { describe, it, expect, vi } from 'vitest';