diff --git a/.changeset/remove-session-tenantid-alias.md b/.changeset/remove-session-tenantid-alias.md new file mode 100644 index 0000000000..f5e9ffe79d --- /dev/null +++ b/.changeset/remove-session-tenantid-alias.md @@ -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. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9afabd1506..13b5f20012 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 from — off 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 diff --git a/content/docs/kernel/runtime-services/examples.mdx b/content/docs/kernel/runtime-services/examples.mdx index 0ef96f46ad..f12151a4a7 100644 --- a/content/docs/kernel/runtime-services/examples.mdx +++ b/content/docs/kernel/runtime-services/examples.mdx @@ -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, }); diff --git a/content/docs/kernel/runtime-services/sharing-service.mdx b/content/docs/kernel/runtime-services/sharing-service.mdx index 353696935a..8760007182 100644 --- a/content/docs/kernel/runtime-services/sharing-service.mdx +++ b/content/docs/kernel/runtime-services/sharing-service.mdx @@ -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'); diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index 3de22d970c..5afc130fd1 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -37,7 +37,7 @@ const result = HookContext.parse(data); | **input** | `Record` | ✅ | Mutable input parameters | | **result** | `any` | optional | Operation result (After hooks only) | | **previous** | `Record` | 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) | diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index 14b3215315..c01297737e 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -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' }); }); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 01f1eace47..e818e33c94 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -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 diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index e66abeb14f..be8d128652 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -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; } }; diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index b157e1317a..8b332a2966 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -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'], @@ -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 () => { diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index f51eb56518..e8a749d377 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -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 } : {}), }; } @@ -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: [] }; diff --git a/packages/spec/src/data/hook.test.ts b/packages/spec/src/data/hook.test.ts index fbfd8ac678..38449f2ac9 100644 --- a/packages/spec/src/data/hook.test.ts +++ b/packages/spec/src/data/hook.test.ts @@ -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'); }); @@ -567,9 +567,10 @@ 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', @@ -577,13 +578,14 @@ describe('HookContextSchema', () => { 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', () => { diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index 4d097d4c32..8c94d9f033 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -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)'), diff --git a/packages/triggers/trigger-record-change/src/record-change-trigger.ts b/packages/triggers/trigger-record-change/src/record-change-trigger.ts index 5201eb0f9b..03b7455264 100644 --- a/packages/triggers/trigger-record-change/src/record-change-trigger.ts +++ b/packages/triggers/trigger-record-change/src/record-change-trigger.ts @@ -225,7 +225,7 @@ 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, @@ -233,11 +233,14 @@ export class RecordChangeTrigger implements FlowTrigger { 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, diff --git a/scripts/check-org-identifier.mjs b/scripts/check-org-identifier.mjs index a504ac0c7d..007057a39f 100644 --- a/scripts/check-org-identifier.mjs +++ b/scripts/check-org-identifier.mjs @@ -8,38 +8,47 @@ // caller's active org across the JS authoring surface: a hook or action body // reads `ctx.user.organizationId` / `ctx.session.organizationId`, matching the // `organization_id` column and `current_user.organizationId` in RLS. The old -// `ctx.session.tenantId` is a DEPRECATED alias that still works but teaches the -// wrong name -- and TSDoc `@deprecated` only nudges an author whose editor -// surfaces it. Nothing stopped our own reference apps (examples/, apps/), which -// authors and AIs copy from, from re-introducing `session.tenantId`. +// `ctx.session.tenantId` was a deprecated alias; #3290 REMOVED it from the +// hook/action `ctx.session` surface entirely (v11 major), so any `session.tenantId` +// read in an authoring body now resolves to `undefined` and is simply a bug. // -// This is a hard-fail guard, not a ratchet: the authoring surfaces carry ZERO +// This is a hard-fail guard, not a ratchet: the scanned surfaces carry ZERO // occurrences today, so any match is a NEW one and fails. It is deliberately // NARROW: -// • Scope is author-facing reference code only (examples/, apps/). Internal -// framework packages legitimately read `session.tenantId` (the engine's own -// `buildSession`, the record-change trigger) -- that is the driver-layer -// alias, a non-goal of #3280, and must not be flagged. -// • skills/ and content/docs/ are EXCLUDED: they deliberately show the -// deprecated form when TEACHING the deprecation ("deprecated alias"). -// • The pattern matches only the `session.tenantId` token, never -// `execCtx.tenantId` / `opts.tenantId` / `DriverOptions.tenantId`, which are -// the generic driver-layer tenancy knob (explicitly out of scope). -// -// Escape hatch for a genuine driver-layer `session.tenantId` in an example: -// add `os-allow-tenant-id` in a comment on the same line. +// • Scope is author-facing reference code: examples/, apps/, AND packages/ +// (#3290). The framework's own hook/action surface no longer emits or reads +// `session.tenantId` (engine `buildSession`, the record-change trigger, and +// the ObjectQL audit-stamp plugin were migrated to `organizationId`), so +// packages/ is now held to the same bar as reference apps — an author or AI +// copying a package example body will not find the removed name. +// • The generic DRIVER-LAYER tenancy knob is untouched and never matched: the +// pattern anchors on the `session` receiver, so `execCtx.tenantId` / +// `opts.tenantId` / `DriverOptions.tenantId` (a configurable isolation +// column, legitimately an *environment* id in database-per-tenant kernels) +// do not trip it. For the rare genuine driver-layer `session.tenantId`, add +// an `os-allow-tenant-id` comment on the same line. +// • Test/spec files are EXCLUDED: they legitimately reference the removed +// token to assert its ABSENCE (`expect(session.tenantId).toBeUndefined()`), +// and are not reference bodies an author copies a hook from. +// • Comment lines are SKIPPED (JSDoc `*`, `//`, `/*`, and trailing `// …`): a +// migration note that NAMES the removed alias to explain its removal is +// documentation, not an executable read. Only code is checked. +// • skills/ and content/docs/ are EXCLUDED: prose there may still name the +// removed alias when documenting the migration. // // node scripts/check-org-identifier.mjs // -// Scope: tracked sources under examples/ and apps/ only (git ls-files). +// Scope: tracked sources under examples/, apps/, and packages/ (git ls-files). import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -const ROOTS = ['examples', 'apps']; +const ROOTS = ['examples', 'apps', 'packages']; const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.cts', '.mts']; const EXCLUDED = /(^|\/)(node_modules|dist|build|\.next|\.turbo)\//; +// Tests assert the alias is GONE, so they reference the token on purpose. +const TEST_FILE = /(\.(test|spec)\.[cm]?[jt]sx?$)|((^|\/)__tests__\/)/; // `ctx.session.tenantId`, `session?.tenantId`, `this.session . tenantId`, … — // the `session` receiver immediately before `.tenantId`. Anchored on the @@ -51,8 +60,8 @@ const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', }).trim(); -// Newline-delimited on purpose (not `-z`): tracked paths under examples/ and -// apps/ never contain a newline, and avoiding the NUL delimiter keeps this very +// Newline-delimited on purpose (not `-z`): tracked paths under these roots +// never contain a newline, and avoiding the NUL delimiter keeps this very // script free of any raw NUL byte (which would make it invisible to grep — the // exact #3127 failure mode this repo already guards with check:nul-bytes). const files = execFileSync('git', ['ls-files', '--', ...ROOTS], { @@ -63,7 +72,8 @@ const files = execFileSync('git', ['ls-files', '--', ...ROOTS], { .split('\n') .filter(Boolean) .filter((f) => EXTENSIONS.some((ext) => f.endsWith(ext))) - .filter((f) => !EXCLUDED.test(f)); + .filter((f) => !EXCLUDED.test(f)) + .filter((f) => !TEST_FILE.test(f)); const offenders = []; for (const file of files) { @@ -71,33 +81,40 @@ for (const file of files) { const lines = text.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; - if (!PATTERN.test(line)) continue; if (line.includes(ALLOW_MARKER)) continue; + // Skip comment lines — a migration note that names the removed alias is + // documentation, not an executable read (JSDoc `*`, line/block `//`,`/*`). + const trimmed = line.trimStart(); + if (trimmed.startsWith('*') || trimmed.startsWith('//') || trimmed.startsWith('/*')) continue; + // Drop any trailing line-comment so `foo(); // …session.tenantId…` is clean. + const code = line.replace(/\/\/.*$/, ''); + if (!PATTERN.test(code)) continue; offenders.push({ file, line: i + 1, text: line.trim() }); } } if (offenders.length === 0) { console.log( - `check-org-identifier: OK (${files.length} author-facing source file(s), no deprecated session.tenantId).`, + `check-org-identifier: OK (${files.length} author-facing source file(s), no removed session.tenantId alias).`, ); process.exit(0); } const plural = offenders.length === 1 ? 'occurrence' : 'occurrences'; console.error( - `check-org-identifier: ${offenders.length} deprecated \`session.tenantId\` ${plural} in author-facing code\n`, + `check-org-identifier: ${offenders.length} removed \`session.tenantId\` ${plural} in author-facing code\n`, ); for (const o of offenders) { console.error(` • ${o.file}:${o.line} ${o.text}`); } console.error(` -\`session.tenantId\` is a DEPRECATED alias (#3280). In a hook or action body read -the caller's active org under the blessed name instead: +\`session.tenantId\` was REMOVED from the hook/action ctx.session surface (#3290); +it no longer carries a value. In a hook or action body read the caller's active +org under the blessed name instead: const org = ctx.user?.organizationId ?? ctx.session?.organizationId; -It carries the identical value and matches the \`organization_id\` column and -\`current_user.organizationId\` in RLS. For a genuine driver-layer use, add an -\`${ALLOW_MARKER}\` comment on the line.`); +It matches the \`organization_id\` column and \`current_user.organizationId\` in +RLS. For a genuine driver-layer use (a configurable isolation column, not the +caller's org), add an \`${ALLOW_MARKER}\` comment on the line.`); process.exit(1); diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index 35e3d3f4c1..0c6b2cedde 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -288,11 +288,12 @@ predicates, and in seed rows. Read it as **`organizationId`**: ```typescript // ✅ Blessed — matches columns, RLS `current_user`, and seed data const org = ctx.user?.organizationId ?? ctx.session?.organizationId; - -// ⚠️ Deprecated alias — still works, carries the identical value -const org = ctx.session?.tenantId; ``` +> The former `ctx.session.tenantId` alias was removed in v11 (#3290) — read the +> org under `organizationId`. (The generic driver-layer `execCtx.tenantId` / +> `DriverOptions.tenantId` isolation knob is a separate axis and is unaffected.) + `ctx.user` is the ergonomic shortcut for an authenticated caller; it is `undefined` for system / unauthenticated writes, so read `ctx.session?.organizationId` when a hook must work regardless of whether a user resolved. diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index 50b35d7e18..5a460fce5e 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -1674,11 +1674,11 @@ 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; ``` +> The former `ctx.session.tenantId` alias was removed in v11 (#3290); read the +> caller's active org under `organizationId`. + 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