diff --git a/.changeset/action-body-org-identifier.md b/.changeset/action-body-org-identifier.md new file mode 100644 index 0000000000..757e0861e5 --- /dev/null +++ b/.changeset/action-body-org-identifier.md @@ -0,0 +1,12 @@ +--- +"@objectstack/runtime": patch +--- + +**Extend the blessed `organizationId` org name to the action-body surface (follow-up to #3280).** Hooks now teach `ctx.user.organizationId` / `ctx.session.organizationId` as the blessed name for the caller's active org; action bodies — the sibling authoring surface that shares the same sandbox runner — were left behind: the REST dispatch path exposed only `ctx.user.tenantId` (the deprecated name) and no `ctx.session` at all, and the MCP `run_action` path exposed neither. + +Both action-dispatch sites (`handleActions`, MCP `runAction`) now populate: + +- **`ctx.user.organizationId`** — the blessed name (matches the `organization_id` column and `current_user.organizationId` in RLS); `ctx.user.tenantId` is kept as a deprecated alias with the identical value on the REST path. +- **`ctx.session`** (`{ userId, organizationId, tenantId, roles? }`) — mirrors the hook `ctx.session` shape, `undefined` for a context-less / self-invoked call. + +Action bodies execute trusted (the `ctx.engine` / `ctx.api` facade bypasses RLS/FLS), so a body that must scope by org has to read it from `ctx` — now under the same name a hook author uses. Additive and behavior-preserving; the objectstack-ui skill documents the action-body `ctx` and the `organizationId` read. diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index b169c7bb89..b157e1317a 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -2434,6 +2434,7 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => { }; const actionUser = (executeAction: any) => executeAction.mock.calls[0]?.[2]?.user; + const actionSession = (executeAction: any) => executeAction.mock.calls[0]?.[2]?.session; it('forwards the session user id + business roles to the action body (not `system`)', async () => { const { dispatcher, executeAction, ctx } = captureCtx({ @@ -2450,9 +2451,26 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => { expect(user.positions).toEqual(['sales_rep', 'org_member']); expect(user.permissions).toEqual(['convert_lead']); expect(user.email).toBe('rep@acme.test'); + // #3280 — `organizationId` is the blessed name; `tenantId` is the alias. + expect(user.organizationId).toBe('org_acme'); expect(user.tenantId).toBe('org_acme'); }); + it('exposes ctx.session.organizationId (blessed) + tenantId (deprecated alias) to the action body (#3280)', async () => { + const { dispatcher, executeAction, ctx } = captureCtx({ + userId: 'user_42', + positions: ['sales_rep'], + tenantId: 'org_acme', + }); + await dispatcher.handleActions('/lead/convert', 'POST', {}, ctx); + const session = actionSession(executeAction); + expect(session).toBeDefined(); + expect(session.userId).toBe('user_42'); + expect(session.organizationId).toBe('org_acme'); + expect(session.tenantId).toBe('org_acme'); + expect(session.organizationId).toBe(session.tenantId); + }); + it('falls back to a `system` principal only when the request is anonymous', async () => { const { dispatcher, executeAction, ctx } = captureCtx(undefined); await dispatcher.handleActions('/lead/convert', 'POST', {}, ctx); @@ -2460,6 +2478,8 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => { expect(user.id).toBe('system'); expect(user.roles).toEqual([]); expect(user.positions).toEqual([]); + // No resolved caller → no session (parity with the hook surface). + expect(actionSession(executeAction)).toBeUndefined(); }); it('sources identity from executionContext, ignoring a stray `_context.user` (regression guard)', async () => { diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index fc7360e186..f51eb56518 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -1026,6 +1026,28 @@ export class HttpDispatcher { * audit-logged. Longer-term direction: an action-level `runAs: 'user'|'system'` * mirroring flows (ADR-0049) — tracked in #2849. */ + /** + * Build the action-body `ctx.session` from the request ExecutionContext, + * mirroring the hook `ctx.session` shape (#3280) so an action author reads + * the caller's active org under the SAME blessed name as a hook author. + * + * `organizationId` is the blessed name for the caller's active org — the + * same value as the `organization_id` column and `current_user.organizationId` + * (RLS). `tenantId` is a deprecated alias carrying the identical value. + * Returns undefined for a genuinely context-less / self-invoked call so a + * body can distinguish "no session" the same way hooks do. + */ + private buildActionSession(ec: any): any | undefined { + if (!ec || (ec.userId == null && ec.tenantId == null)) return undefined; + return { + ...(ec.userId != null ? { userId: String(ec.userId) } : {}), + ...(ec.tenantId != null + ? { organizationId: String(ec.tenantId), tenantId: String(ec.tenantId) } + : {}), + ...(Array.isArray(ec.positions) && ec.positions.length ? { roles: ec.positions } : {}), + }; + } + private buildActionEngineFacade(ql: any): any { return { async insert(object: string, data: Record): Promise<{ id: string }> { @@ -1130,7 +1152,15 @@ export class HttpDispatcher { if (record && (record as any).id == null && recordId) (record as any).id = recordId; const user = ec?.userId - ? { id: ec.userId, name: ec.userName ?? ec.userDisplayName ?? ec.userId } + ? { + id: ec.userId, + name: ec.userName ?? ec.userDisplayName ?? ec.userId, + // `organizationId` is the blessed name for the caller's active + // org (matches columns + `current_user.organizationId`); the + // action body executes TRUSTED (RLS-bypassing), so a body that + // wants to scope by org must read it here (#3280). + ...(ec.tenantId != null ? { organizationId: String(ec.tenantId) } : {}), + } : { id: 'system', name: 'system' }; // ── flow dispatch ── @@ -1176,6 +1206,7 @@ export class HttpDispatcher { const actionContext: any = { record, user, + session: this.buildActionSession(ec), engine: this.buildActionEngineFacade(ql), params: { ...params, recordId, objectName }, }; @@ -3900,6 +3931,11 @@ export class HttpDispatcher { roles: Array.isArray(ec.positions) ? ec.positions : [], positions: Array.isArray(ec.positions) ? ec.positions : [], permissions: Array.isArray(ec.permissions) ? ec.permissions : [], + // `organizationId` is the blessed developer-facing name for the + // caller's active org (matches columns + `current_user.organizationId`); + // `tenantId` is kept as a deprecated alias with the identical + // value (#3280). + organizationId: ec.tenantId, tenantId: ec.tenantId, } : { id: 'system', name: 'system', roles: [], positions: [], permissions: [] }; @@ -3907,6 +3943,7 @@ export class HttpDispatcher { const actionContext: any = { record, user: userFromAuth, + session: this.buildActionSession(ec), engine: engineFacade, params: { ...reqParams, recordId, objectName }, }; diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index 0e8d81e6b0..50b35d7e18 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -1661,6 +1661,31 @@ export const AddToCampaignAction = defineAction({ }); ``` +#### Action body context (`ctx`) + +A server-side action `body` (and a registered function `handler`) receives a +`ctx` with `input` (the modal params), `record` (the target row, when a +`recordId` is in scope), `api` (scoped cross-object CRUD), and the caller +identity. Read the caller's active organization under the **blessed** +`organizationId` name — the same value as the `organization_id` column and +`current_user.organizationId` in RLS, so it matches hooks and seed data with +zero relearning: + +```typescript +// ✅ Blessed — identical to the hook surface (ctx.user / ctx.session) +const org = ctx.user?.organizationId ?? ctx.session?.organizationId; + +// ⚠️ Deprecated alias — still works, carries the identical value +const org = ctx.session?.tenantId; +``` + +Action bodies execute **trusted** (the `ctx.engine` / `ctx.api` facade bypasses +RLS/FLS), so a body that must scope by org reads it from `ctx` explicitly. +`ctx.user` is `undefined` for a context-less / self-invoked call; read +`ctx.session?.organizationId` when the action must work regardless. (Same two +isolation axes as hooks — `organization_id` row-scoping vs environment / +database-per-tenant; see the objectstack-data hooks reference.) + ### Opening in a New Tab (`openIn` / `opensInNewTab` / `newTabUrl`) There are **two** mechanisms here. Pick by whether the URL is static or computed: