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/action-body-org-identifier.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -2450,16 +2451,35 @@ 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);
const user = actionUser(executeAction);
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 () => {
Expand Down
39 changes: 38 additions & 1 deletion packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): Promise<{ id: string }> {
Expand Down Expand Up @@ -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 ──
Expand Down Expand Up @@ -1176,6 +1206,7 @@ export class HttpDispatcher {
const actionContext: any = {
record,
user,
session: this.buildActionSession(ec),
engine: this.buildActionEngineFacade(ql),
params: { ...params, recordId, objectName },
};
Expand Down Expand Up @@ -3900,13 +3931,19 @@ 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: [] };

const actionContext: any = {
record,
user: userFromAuth,
session: this.buildActionSession(ec),
engine: engineFacade,
params: { ...reqParams, recordId, objectName },
};
Expand Down
25 changes: 25 additions & 0 deletions skills/objectstack-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down