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
31 changes: 31 additions & 0 deletions .changeset/remove-session-tenantid-alias.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@objectstack/spec": major
"@objectstack/objectql": major
"@objectstack/runtime": major
"@objectstack/trigger-record-change": patch
---

**BREAKING: remove the deprecated `ctx.session.tenantId` / `ctx.user.tenantId` alias from the hook & action authoring surface — converge on `organizationId` (#3290).**

#3280 made `organizationId` the blessed developer-facing name for the caller's active org across the JS authoring surface and kept `tenantId` as a `@deprecated` alias carrying the identical value. That alias is now **removed** from the hook `ctx.session`, the action-body `ctx.session`, and the action-body `ctx.user`. Read the caller's active org under the single blessed name:

```diff
- const org = ctx.session.tenantId; // hook or action body
+ const org = ctx.user?.organizationId ?? ctx.session?.organizationId;
```

**FROM → TO migration** (in any `*.hook.ts` / `*.action.ts` body):

- `ctx.session.tenantId` → `ctx.session.organizationId`
- `ctx.user.tenantId` (action body) → `ctx.user.organizationId`

The value is unchanged — `organizationId` is the same active-org id, matching the `organization_id` column and `current_user.organizationId` in RLS/sharing. `ctx.user` is `undefined` for system / unauthenticated writes, so read `ctx.session?.organizationId` when a hook or action must work regardless of a resolved user.

What changed internally:

- **`@objectstack/spec`** — `HookContextSchema.session` drops the `tenantId` field (only `organizationId` remains). A stray `tenantId` on a constructed session is now stripped by the schema.
- **`@objectstack/objectql`** — the engine's `buildSession()` no longer emits `session.tenantId`; the audit-stamp plugin sources the `tenant_id` column from `session.organizationId`.
- **`@objectstack/runtime`** — `buildActionSession()` and the REST action `ctx.user` no longer emit `tenantId`.
- **`@objectstack/trigger-record-change`** — reads `session.organizationId` (was `session.tenantId`) when forwarding the writer's org to a `runAs:'user'` flow; behavior is identical.

**Explicit non-goal (unchanged):** the generic **driver-layer** tenancy abstraction is *not* touched — `ExecutionContext.tenantId`, `DriverOptions.tenantId`, `SqlDriver.applyTenantScope` / `TenancyConfig.tenantField`, and `ExecutionLog.tenantId`. That isolation column is configurable and legitimately carries an *environment* id in database-per-tenant kernels; it is a distinct axis from the developer-facing org. The build-time `check:org-identifier` guard now also covers `packages/**` to keep reference bodies off the removed name.
14 changes: 7 additions & 7 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,13 @@ jobs:
- name: Reserved-word ("role") docs ratchet
run: pnpm check:role-word

# #3280 org-identifier guard: `organizationId` is the blessed developer-facing
# name for the caller's active org in hook/action bodies; `session.tenantId`
# is a deprecated alias. Keeps our own reference apps (examples/, apps/) —
# which authors and AIs copy fromoff the deprecated name. Hard-fail
# (authoring surfaces carry zero occurrences today); skills/ and docs/ are
# excluded since they teach the deprecated form, and driver-layer
# `execCtx.tenantId` is never matched.
# #3280/#3290 org-identifier guard: `organizationId` is the blessed
# developer-facing name for the caller's active org in hook/action bodies;
# the `session.tenantId` alias was REMOVED in v11 (#3290). Keeps our own
# reference code (examples/, apps/, AND packages/)which authors and AIs
# copy from — off the removed name. Hard-fail (surfaces carry zero
# occurrences today); tests, comments, skills/ and docs/ are excluded, and
# driver-layer `execCtx.tenantId` is never matched.
- name: Org-identifier authoring guard
run: pnpm check:org-identifier

Expand Down
4 changes: 3 additions & 1 deletion content/docs/kernel/runtime-services/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ export async function run(ctx: any) {
export async function beforeUpdate(ctx: any) {
const ok = await ctx.services?.sharing?.canEdit('contract', ctx.input.id, {
userId: ctx.session?.userId,
tenantId: ctx.session?.tenantId,
// Read the caller's org under `organizationId` (the `session.tenantId` alias
// was removed in v11, #3290); it feeds the sharing context's `tenantId`.
tenantId: ctx.session?.organizationId,
positions: ctx.session?.positions,
});

Expand Down
4 changes: 3 additions & 1 deletion content/docs/kernel/runtime-services/sharing-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ services.sharing.listShares(object: string, recordId: string, context: SharingEx
```ts
const allowed = await services.sharing.canEdit('contract', ctx.input.id, {
userId: ctx.session?.userId,
tenantId: ctx.session?.tenantId,
// The hook exposes the caller's org as `organizationId` (the `session.tenantId`
// alias was removed in v11, #3290); it feeds the sharing context's `tenantId`.
tenantId: ctx.session?.organizationId,
positions: ctx.session?.positions,
});
if (!allowed) throw new Error('PERMISSION_DENIED');
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/hook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const result = HookContext.parse(data);
| **input** | `Record<string, any>` | ✅ | Mutable input parameters |
| **result** | `any` | optional | Operation result (After hooks only) |
| **previous** | `Record<string, any>` | optional | Record state before operation |
| **session** | `{ userId?: string; organizationId?: string; tenantId?: string; roles?: string[]; … }` | optional | Current session context |
| **session** | `{ userId?: string; organizationId?: string; roles?: string[]; accessToken?: string; … }` | optional | Current session context |
| **transaction** | `any` | optional | Database transaction handle |
| **ql** | `any` | ✅ | ObjectQL Engine Reference |
| **api** | `any` | optional | Cross-object data access (ScopedContext) |
Expand Down
6 changes: 3 additions & 3 deletions packages/objectql/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,10 +496,10 @@ describe('ObjectQL Engine', () => {

await engine.insert('task', { title: 'x' }, { context: { userId: 'u1', tenantId: 'org_1' } as any });

// Blessed name and the deprecated alias carry the identical value.
// Blessed name carries the org; the deprecated `tenantId` alias was
// removed in v11 (#3290) and must no longer be emitted.
expect(session.organizationId).toBe('org_1');
expect(session.tenantId).toBe('org_1');
expect(session.organizationId).toBe(session.tenantId);
expect(session.tenantId).toBeUndefined();
// `ctx.user` shortcut carries the same org for zero-relearning filtering.
expect(user).toMatchObject({ id: 'u1', organizationId: 'org_1' });
});
Expand Down
8 changes: 4 additions & 4 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,11 +745,11 @@ export class ObjectQL implements IDataEngine {
userId: execCtx.userId,
// `organizationId` is the blessed developer-facing name for the caller's
// active org (matches the `organization_id` column, `current_user`
// RLS shape, and seed rows). `tenantId` is kept as a deprecated alias
// carrying the IDENTICAL value — both come from `execCtx.tenantId`, which
// the kernel resolves from `session.activeOrganizationId` (#3280).
// RLS shape, and seed rows). It comes from `execCtx.tenantId`, which the
// kernel resolves from `session.activeOrganizationId`. The deprecated
// `session.tenantId` alias (#3280) was removed here in v11 (#3290) — the
// driver-layer `execCtx.tenantId` knob is a separate axis and stays.
organizationId: execCtx.tenantId,
tenantId: execCtx.tenantId,
positions: execCtx.positions,
accessToken: execCtx.accessToken,
// Propagate system-elevated flag so hooks can distinguish engine
Expand Down
7 changes: 5 additions & 2 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,8 +731,11 @@ export class ObjectQLPlugin implements Plugin {
record.updated_by = session.userId;
}
}
if (isInsert && session?.tenantId && hasField(objectName, 'tenant_id')) {
record.tenant_id = record.tenant_id ?? session.tenantId;
// Stamp the driver-layer `tenant_id` column from the caller's active org.
// The hook session exposes it as `organizationId` (the `session.tenantId`
// alias was removed in v11, #3290); the column name is a separate axis.
if (isInsert && session?.organizationId && hasField(objectName, 'tenant_id')) {
record.tenant_id = record.tenant_id ?? session.organizationId;
}
};

Expand Down
10 changes: 5 additions & 5 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2451,12 +2451,13 @@ 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.
// #3280 made `organizationId` the blessed name; the `tenantId` alias was
// removed in v11 (#3290) and must no longer be emitted on ctx.user.
expect(user.organizationId).toBe('org_acme');
expect(user.tenantId).toBe('org_acme');
expect(user.tenantId).toBeUndefined();
});

it('exposes ctx.session.organizationId (blessed) + tenantId (deprecated alias) to the action body (#3280)', async () => {
it('exposes ctx.session.organizationId and no longer emits the removed tenantId alias to the action body (#3290)', async () => {
const { dispatcher, executeAction, ctx } = captureCtx({
userId: 'user_42',
positions: ['sales_rep'],
Expand All @@ -2467,8 +2468,7 @@ describe('HttpDispatcher — action body ctx.user identity (#2701)', () => {
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);
expect(session.tenantId).toBeUndefined();
});

it('falls back to a `system` principal only when the request is anonymous', async () => {
Expand Down
18 changes: 8 additions & 10 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1033,17 +1033,17 @@ export class HttpDispatcher {
*
* `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.
* (RLS). The deprecated `session.tenantId` alias (#3280) was removed in v11
* (#3290); the driver-layer `ExecutionContext.tenantId` it is sourced from is
* a distinct, configurable axis and stays. 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) }
: {}),
...(ec.tenantId != null ? { organizationId: String(ec.tenantId) } : {}),
...(Array.isArray(ec.positions) && ec.positions.length ? { roles: ec.positions } : {}),
};
}
Expand Down Expand Up @@ -3932,11 +3932,9 @@ export class HttpDispatcher {
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).
// caller's active org (matches columns + `current_user.organizationId`).
// The deprecated `tenantId` alias (#3280) was removed in v11 (#3290).
organizationId: ec.tenantId,
tenantId: ec.tenantId,
}
: { id: 'system', name: 'system', roles: [], positions: [], permissions: [] };

Expand Down
16 changes: 9 additions & 7 deletions packages/spec/src/data/hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,14 +541,14 @@ describe('HookContextSchema', () => {
input: {},
session: {
userId: 'user_123',
tenantId: 'tenant_456',
organizationId: 'org_456',
roles: ['user', 'admin'],
},
ql: {},
});

expect(context.session?.userId).toBe('user_123');
expect(context.session?.tenantId).toBe('tenant_456');
expect(context.session?.organizationId).toBe('org_456');
expect(context.session?.roles).toContain('admin');
});

Expand All @@ -567,23 +567,25 @@ describe('HookContextSchema', () => {
expect(context.session?.accessToken).toBe('token_abc123');
});

// #3280 — `organizationId` is the blessed developer-facing name; `tenantId`
// stays as a deprecated alias carrying the identical value.
it('should accept session.organizationId (blessed) alongside tenantId (deprecated alias)', () => {
// #3280 made `organizationId` the blessed developer-facing name; the
// `tenantId` alias was removed from this surface in v11 (#3290). A stray
// `tenantId` key is now stripped by the schema rather than surfaced.
it('exposes session.organizationId and no longer carries the removed tenantId alias (#3290)', () => {
const context = HookContextSchema.parse({
object: 'account',
event: 'beforeInsert',
input: {},
session: {
userId: 'user_123',
organizationId: 'org_456',
// No longer part of the schema — Zod strips this unknown key.
tenantId: 'org_456',
},
} as any,
ql: {},
});

expect(context.session?.organizationId).toBe('org_456');
expect(context.session?.tenantId).toBe('org_456');
expect((context.session as any)?.tenantId).toBeUndefined();
});

it('should accept user.organizationId shortcut', () => {
Expand Down
25 changes: 11 additions & 14 deletions packages/spec/src/data/hook.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,21 +213,18 @@ export const HookContextSchema = lazySchema(() => z.object({
session: z.object({
userId: z.string().optional(),
/**
* Active organization ID — the blessed, developer-facing name for the
* caller's current org. Same value everywhere a developer reads the org:
* the `organization_id` column, `current_user.organizationId` (RLS/sharing),
* and seed rows. Prefer this in hooks; it always equals `tenantId`.
* `null`/`undefined` on unscoped (platform/community) calls.
* Active organization ID — the developer-facing name for the caller's
* current org. Same value everywhere a developer reads the org: the
* `organization_id` column, `current_user.organizationId` (RLS/sharing),
* and seed rows. `null`/`undefined` on unscoped (platform/community) calls.
*
* The former `session.tenantId` alias (#3280) was removed in the v11 major
* (#3290): read the org under this single blessed name. The generic
* driver-layer isolation knob (`ExecutionContext.tenantId`,
* `DriverOptions.tenantId`) is a distinct, configurable axis and is
* deliberately untouched.
*/
organizationId: z.string().optional().describe('Active organization ID (blessed name; equals tenantId)'),
/**
* @deprecated Prefer `organizationId` — the two carry the identical value
* (the caller's active org). `tenantId` remains for back-compat; it names
* the generic driver-layer isolation column, whereas everything else on the
* developer surface (columns, RLS `current_user`, seed) says `organization`.
* New docs/examples teach only `organizationId`.
*/
tenantId: z.string().optional().describe('Deprecated alias of organizationId'),
organizationId: z.string().optional().describe('Active organization ID (blessed developer-facing name)'),
roles: z.array(z.string()).optional(),
accessToken: z.string().optional(),
isSystem: z.boolean().optional().describe('True when the call was made with an elevated system context (engine self-writes)'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,19 +225,22 @@ export class RecordChangeTrigger implements FlowTrigger {
{ ...(inputDoc ?? {}), ...after }
: inputDoc ?? (previous && typeof previous === 'object' ? previous : {});

const session = (ctx.session ?? {}) as { userId?: string; tenantId?: string; positions?: string[] };
const session = (ctx.session ?? {}) as { userId?: string; organizationId?: string; positions?: string[] };

return {
record,
previous,
object: binding.object ?? ctx.object,
event: binding.event,
userId: session.userId,
// Forward the writer's roles/tenant so a `runAs:'user'` flow enforces
// Forward the writer's roles/org so a `runAs:'user'` flow enforces
// RLS exactly as the user who made the change, not a member fallback
// (#1888). The engine elevates only for `runAs:'system'`.
// (#1888). The engine elevates only for `runAs:'system'`. The hook
// session exposes the active org as `organizationId` (the deprecated
// `session.tenantId` alias was removed in v11, #3290); it feeds the
// automation context's driver-layer `tenantId` field unchanged.
...(Array.isArray(session.positions) && session.positions.length ? { positions: session.positions } : {}),
...(session.tenantId ? { tenantId: session.tenantId } : {}),
...(session.organizationId ? { tenantId: session.organizationId } : {}),
// Expose the record as params too, so flows with named `isInput`
// variables matching record fields get them seeded.
params: record,
Expand Down
Loading
Loading