Skip to content

Commit b39c65d

Browse files
os-zhuangclaude
andauthored
feat(runtime): expose organizationId to action-body ctx (user + session) — follow-up to #3280 (#3288)
Hooks now teach `ctx.user.organizationId` / `ctx.session.organizationId` as the blessed name for the caller's active org (#3280). Action bodies — the sibling authoring surface sharing the same sandbox runner — were left behind: the REST dispatch path exposed only `ctx.user.tenantId` (deprecated) and no `ctx.session`, and the MCP `run_action` path exposed neither. Both action-dispatch sites (`handleActions`, MCP `runAction`) now populate: - `ctx.user.organizationId` — blessed name (matches the organization_id column and current_user.organizationId in RLS); `ctx.user.tenantId` kept as a deprecated alias on the REST path. - `ctx.session` (`{ userId, organizationId, tenantId, roles? }`) via a shared `buildActionSession(ec)` helper, mirroring the hook session shape; undefined for a context-less / self-invoked call. Action bodies execute trusted (engine/api facade bypasses RLS/FLS), so a body that scopes by org must read it from ctx — now under the same name a hook author uses. objectstack-ui skill documents the action-body ctx + org read. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 606a4c4 commit b39c65d

4 files changed

Lines changed: 95 additions & 1 deletion

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
**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.
6+
7+
Both action-dispatch sites (`handleActions`, MCP `runAction`) now populate:
8+
9+
- **`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.
10+
- **`ctx.session`** (`{ userId, organizationId, tenantId, roles? }`) — mirrors the hook `ctx.session` shape, `undefined` for a context-less / self-invoked call.
11+
12+
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.

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2434,6 +2434,7 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => {
24342434
};
24352435

24362436
const actionUser = (executeAction: any) => executeAction.mock.calls[0]?.[2]?.user;
2437+
const actionSession = (executeAction: any) => executeAction.mock.calls[0]?.[2]?.session;
24372438

24382439
it('forwards the session user id + business roles to the action body (not `system`)', async () => {
24392440
const { dispatcher, executeAction, ctx } = captureCtx({
@@ -2450,16 +2451,35 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => {
24502451
expect(user.positions).toEqual(['sales_rep', 'org_member']);
24512452
expect(user.permissions).toEqual(['convert_lead']);
24522453
expect(user.email).toBe('rep@acme.test');
2454+
// #3280 — `organizationId` is the blessed name; `tenantId` is the alias.
2455+
expect(user.organizationId).toBe('org_acme');
24532456
expect(user.tenantId).toBe('org_acme');
24542457
});
24552458

2459+
it('exposes ctx.session.organizationId (blessed) + tenantId (deprecated alias) to the action body (#3280)', async () => {
2460+
const { dispatcher, executeAction, ctx } = captureCtx({
2461+
userId: 'user_42',
2462+
positions: ['sales_rep'],
2463+
tenantId: 'org_acme',
2464+
});
2465+
await dispatcher.handleActions('/lead/convert', 'POST', {}, ctx);
2466+
const session = actionSession(executeAction);
2467+
expect(session).toBeDefined();
2468+
expect(session.userId).toBe('user_42');
2469+
expect(session.organizationId).toBe('org_acme');
2470+
expect(session.tenantId).toBe('org_acme');
2471+
expect(session.organizationId).toBe(session.tenantId);
2472+
});
2473+
24562474
it('falls back to a `system` principal only when the request is anonymous', async () => {
24572475
const { dispatcher, executeAction, ctx } = captureCtx(undefined);
24582476
await dispatcher.handleActions('/lead/convert', 'POST', {}, ctx);
24592477
const user = actionUser(executeAction);
24602478
expect(user.id).toBe('system');
24612479
expect(user.roles).toEqual([]);
24622480
expect(user.positions).toEqual([]);
2481+
// No resolved caller → no session (parity with the hook surface).
2482+
expect(actionSession(executeAction)).toBeUndefined();
24632483
});
24642484

24652485
it('sources identity from executionContext, ignoring a stray `_context.user` (regression guard)', async () => {

packages/runtime/src/http-dispatcher.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,28 @@ export class HttpDispatcher {
10261026
* audit-logged. Longer-term direction: an action-level `runAs: 'user'|'system'`
10271027
* mirroring flows (ADR-0049) — tracked in #2849.
10281028
*/
1029+
/**
1030+
* Build the action-body `ctx.session` from the request ExecutionContext,
1031+
* mirroring the hook `ctx.session` shape (#3280) so an action author reads
1032+
* the caller's active org under the SAME blessed name as a hook author.
1033+
*
1034+
* `organizationId` is the blessed name for the caller's active org — the
1035+
* same value as the `organization_id` column and `current_user.organizationId`
1036+
* (RLS). `tenantId` is a deprecated alias carrying the identical value.
1037+
* Returns undefined for a genuinely context-less / self-invoked call so a
1038+
* body can distinguish "no session" the same way hooks do.
1039+
*/
1040+
private buildActionSession(ec: any): any | undefined {
1041+
if (!ec || (ec.userId == null && ec.tenantId == null)) return undefined;
1042+
return {
1043+
...(ec.userId != null ? { userId: String(ec.userId) } : {}),
1044+
...(ec.tenantId != null
1045+
? { organizationId: String(ec.tenantId), tenantId: String(ec.tenantId) }
1046+
: {}),
1047+
...(Array.isArray(ec.positions) && ec.positions.length ? { roles: ec.positions } : {}),
1048+
};
1049+
}
1050+
10291051
private buildActionEngineFacade(ql: any): any {
10301052
return {
10311053
async insert(object: string, data: Record<string, unknown>): Promise<{ id: string }> {
@@ -1130,7 +1152,15 @@ export class HttpDispatcher {
11301152
if (record && (record as any).id == null && recordId) (record as any).id = recordId;
11311153

11321154
const user = ec?.userId
1133-
? { id: ec.userId, name: ec.userName ?? ec.userDisplayName ?? ec.userId }
1155+
? {
1156+
id: ec.userId,
1157+
name: ec.userName ?? ec.userDisplayName ?? ec.userId,
1158+
// `organizationId` is the blessed name for the caller's active
1159+
// org (matches columns + `current_user.organizationId`); the
1160+
// action body executes TRUSTED (RLS-bypassing), so a body that
1161+
// wants to scope by org must read it here (#3280).
1162+
...(ec.tenantId != null ? { organizationId: String(ec.tenantId) } : {}),
1163+
}
11341164
: { id: 'system', name: 'system' };
11351165

11361166
// ── flow dispatch ──
@@ -1176,6 +1206,7 @@ export class HttpDispatcher {
11761206
const actionContext: any = {
11771207
record,
11781208
user,
1209+
session: this.buildActionSession(ec),
11791210
engine: this.buildActionEngineFacade(ql),
11801211
params: { ...params, recordId, objectName },
11811212
};
@@ -3900,13 +3931,19 @@ export class HttpDispatcher {
39003931
roles: Array.isArray(ec.positions) ? ec.positions : [],
39013932
positions: Array.isArray(ec.positions) ? ec.positions : [],
39023933
permissions: Array.isArray(ec.permissions) ? ec.permissions : [],
3934+
// `organizationId` is the blessed developer-facing name for the
3935+
// caller's active org (matches columns + `current_user.organizationId`);
3936+
// `tenantId` is kept as a deprecated alias with the identical
3937+
// value (#3280).
3938+
organizationId: ec.tenantId,
39033939
tenantId: ec.tenantId,
39043940
}
39053941
: { id: 'system', name: 'system', roles: [], positions: [], permissions: [] };
39063942

39073943
const actionContext: any = {
39083944
record,
39093945
user: userFromAuth,
3946+
session: this.buildActionSession(ec),
39103947
engine: engineFacade,
39113948
params: { ...reqParams, recordId, objectName },
39123949
};

skills/objectstack-ui/SKILL.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,6 +1661,31 @@ export const AddToCampaignAction = defineAction({
16611661
});
16621662
```
16631663

1664+
#### Action body context (`ctx`)
1665+
1666+
A server-side action `body` (and a registered function `handler`) receives a
1667+
`ctx` with `input` (the modal params), `record` (the target row, when a
1668+
`recordId` is in scope), `api` (scoped cross-object CRUD), and the caller
1669+
identity. Read the caller's active organization under the **blessed**
1670+
`organizationId` name — the same value as the `organization_id` column and
1671+
`current_user.organizationId` in RLS, so it matches hooks and seed data with
1672+
zero relearning:
1673+
1674+
```typescript
1675+
// ✅ Blessed — identical to the hook surface (ctx.user / ctx.session)
1676+
const org = ctx.user?.organizationId ?? ctx.session?.organizationId;
1677+
1678+
// ⚠️ Deprecated alias — still works, carries the identical value
1679+
const org = ctx.session?.tenantId;
1680+
```
1681+
1682+
Action bodies execute **trusted** (the `ctx.engine` / `ctx.api` facade bypasses
1683+
RLS/FLS), so a body that must scope by org reads it from `ctx` explicitly.
1684+
`ctx.user` is `undefined` for a context-less / self-invoked call; read
1685+
`ctx.session?.organizationId` when the action must work regardless. (Same two
1686+
isolation axes as hooks — `organization_id` row-scoping vs environment /
1687+
database-per-tenant; see the objectstack-data hooks reference.)
1688+
16641689
### Opening in a New Tab (`openIn` / `opensInNewTab` / `newTabUrl`)
16651690

16661691
There are **two** mechanisms here. Pick by whether the URL is static or computed:

0 commit comments

Comments
 (0)