From 2271f3e11c067fa83bfacd6a3697d8589d6e5112 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:38:39 +0800 Subject: [PATCH 1/2] chore: bump objectui to 2e7d7f0f7ee7 feat(evaluator): route CEL-dialect component/action predicates to the canonical engine (#2664) objectui@2e7d7f0f7ee76b838f580e8a36b74f1309b204fc --- .changeset/console-2e7d7f0f7ee7.md | 29 +++++++++++++++++++++++++++++ .objectui-sha | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 .changeset/console-2e7d7f0f7ee7.md diff --git a/.changeset/console-2e7d7f0f7ee7.md b/.changeset/console-2e7d7f0f7ee7.md new file mode 100644 index 0000000000..288cedd6ba --- /dev/null +++ b/.changeset/console-2e7d7f0f7ee7.md @@ -0,0 +1,29 @@ +--- +"@objectstack/console": patch +--- + +Console (objectui) refreshed to `2e7d7f0f7ee7`. Frontend changes in this range: + +- feat(evaluator): route CEL-dialect component/action predicates to the canonical engine (#2664) +- fix(grid): explain the import wizard's disabled Next and silent downgrade (#2640, #2639) (#2646) +- fix(form+detail): single-file children stay inline grids; drop non-spec `attachment` (#2654, #2655) (#2656) +- feat(access): localize curated capability labels client-side (#2600 B5 follow-up) (#2657) +- feat(access): localize capability picker group headers (#2600 B5, objectui side) (#2653) +- fix(access): Studio permission matrix — stop clipping the Bulk column at narrow widths (#2600 B3) (#2652) +- feat(access): Studio permission matrix — field-level bulk + filter for wide objects (#2600 B4) (#2651) +- feat(access): Studio Explain panel — package-scoped object dropdown instead of free-text api-name (#2600 B2) (#2650) +- feat(access): Studio permission matrix — collapse identity + zero-grant capabilities so the matrix hits the first screen (#2600 B1) (#2649) +- feat(plugin-list): 列表工具栏增加手动刷新按钮 (#2634) (#2645) +- fix(studio): approver Type dropdown drops deprecated `role`, membership-tier picker (#2643) +- fix(components): route internal html-page links through the SPA navigation handler (#2642) +- feat(discovery): trust only handlerReady/available services (ADR-0076 D12) (#2637) +- feat(types)!: adopt @objectstack/spec 15.1.1; drop value-erased spec/ui `…Schema` re-exports (#2589) +- feat(console): dev-seeded admin credentials hint on the login page (#2635) +- fix(auth): 注册页去掉重复的「or」分隔线(与 #2629 登录页修复对齐) (#2633) +- feat(app-shell/react): adapt to framework 15.1 — atomic publish rendering + honest discovery (#2630) +- fix(chatbot): plan approval flips the card to a Building… badge immediately (#2632) +- fix(app-shell,components): welcome CTA deep-links into the environment create dialog (#2631) +- fix(auth): login-page config race + sign-in watchdog — never strand SSO-only users on a password wall (#2629) +- feat(types): derive ListViewSchema from @objectstack/spec/ui (#2231) (#2622) + +objectui range: `077e45b4bc55...2e7d7f0f7ee7` diff --git a/.objectui-sha b/.objectui-sha index 1c543d511b..5ba96f2057 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -077e45b4bc5517bddeab8bb1ea35fcf49dccdd34 +2e7d7f0f7ee76b838f580e8a36b74f1309b204fc From 53ca97323581fd0769728fb07224bdf7e58ddb7c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:52:18 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(spec,objectql):=20unify=20developer-fa?= =?UTF-8?q?cing=20org=20identifier=20in=20hooks=20=E2=80=94=20organization?= =?UTF-8?q?Id=20is=20blessed=20(#3280)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The caller's active organization was surfaced to hook authors as `ctx.session.tenantId`, while the column (`organization_id`), RLS (`current_user.organizationId`), and seed rows already said `organization`. A hook author had to internalize `tenantId === organizationId` to move between surfaces. Additive, non-breaking: - `ctx.session.organizationId` added as the blessed name; `session.tenantId` kept as a deprecated alias with the identical value (both from `ExecutionContext.tenantId`, resolved from `session.activeOrganizationId`). - `ctx.user.organizationId` added to the ergonomic user shortcut; the engine now populates `ctx.user` at every hook event that carries a session, and it stays undefined for system/unauthenticated writes. Generic driver-layer tenancy (`ExecutionContext.tenantId`, `DriverOptions.tenantId`, `SqlDriver.applyTenantScope`, `TenancyConfig.tenantField`) is deliberately untouched — that layer's isolation column is configurable and can carry an environment id in database-per-tenant kernels. Docs now teach `organizationId` and distinguish the two isolation axes (org row-scoping vs environment/database-per-tenant). Co-Authored-By: Claude Opus 4.8 --- .../unify-org-identifier-hook-session.md | 11 +++++ content/docs/references/data/hook.mdx | 4 +- packages/objectql/src/engine.test.ts | 44 +++++++++++++++++++ packages/objectql/src/engine.ts | 33 ++++++++++++++ packages/spec/src/data/hook.test.ts | 35 +++++++++++++++ packages/spec/src/data/hook.zod.ts | 27 +++++++++++- .../objectstack-data/references/data-hooks.md | 38 +++++++++++++++- 7 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 .changeset/unify-org-identifier-hook-session.md diff --git a/.changeset/unify-org-identifier-hook-session.md b/.changeset/unify-org-identifier-hook-session.md new file mode 100644 index 0000000000..0ee6d0b7e0 --- /dev/null +++ b/.changeset/unify-org-identifier-hook-session.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": patch +"@objectstack/objectql": patch +--- + +**Unify the developer-facing org identifier in JS hooks — `organizationId` is now the blessed name; `session.tenantId` becomes a deprecated alias (#3280).** The caller's active organization was surfaced to hook authors as `ctx.session.tenantId`, while everything else on the developer surface — the `organization_id` column, `current_user.organizationId` in RLS/sharing, and seed rows — already said `organization`. A hook author had to internalize the hidden equation `tenantId === organizationId` to move between surfaces. This is additive and non-breaking: + +- **`ctx.session.organizationId`** is added as the blessed name; **`ctx.session.tenantId`** still carries the identical value but is marked `@deprecated` in its TSDoc. Both come from the same resolved `ExecutionContext.tenantId` (which the kernel derives from `session.activeOrganizationId`). +- **`ctx.user.organizationId`** is added to the ergonomic `user` shortcut, so a hook that needs "the current org to filter by" writes `ctx.user.organizationId` with zero relearning — matching `current_user.organizationId` (RLS) and the `organization_id` column. The engine now populates `ctx.user` (`{ id, email?, organizationId? }`) at every hook event that already carries a `session`; it stays `undefined` for system / unauthenticated writes. + +**No behavior change and no breaking rename.** The generic driver-layer tenancy abstraction (`ExecutionContext.tenantId`, `DriverOptions.tenantId`, `SqlDriver.applyTenantScope`, `TenancyConfig.tenantField`) is deliberately untouched — that layer's isolation column is configurable and legitimately carries an *environment* id in per-environment (database-per-tenant) kernels. Hook-authoring docs now teach `organizationId` and distinguish the two isolation axes: **org row-scoping** (`organization_id`, shared DB) vs **environment / database-per-tenant** (`service-tenant`, `driver-turso`). Community edition never populates an org, so `organizationId` is `undefined` there. diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index 57ebcfef0a..3de22d970c 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -37,11 +37,11 @@ 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; tenantId?: string; roles?: string[]; accessToken?: string; … }` | optional | Current session context | +| **session** | `{ userId?: string; organizationId?: string; tenantId?: string; roles?: string[]; … }` | optional | Current session context | | **transaction** | `any` | optional | Database transaction handle | | **ql** | `any` | ✅ | ObjectQL Engine Reference | | **api** | `any` | optional | Cross-object data access (ScopedContext) | -| **user** | `{ id?: string; name?: string; email?: string }` | optional | Current user info shortcut | +| **user** | `{ id?: string; name?: string; email?: string; organizationId?: string }` | optional | Current user info shortcut | --- diff --git a/packages/objectql/src/engine.test.ts b/packages/objectql/src/engine.test.ts index 3588da367d..a43ce8f9fc 100644 --- a/packages/objectql/src/engine.test.ts +++ b/packages/objectql/src/engine.test.ts @@ -483,6 +483,50 @@ describe('ObjectQL Engine', () => { }); }); + describe('organizationId exposed to hooks as the blessed org name (#3280)', () => { + beforeEach(async () => { + engine.registerDriver(mockDriver, true); + await engine.init(); + vi.mocked(SchemaRegistry.getObject).mockReturnValue({ name: 'task', fields: {} } as any); + }); + + it('populates session.organizationId + user.organizationId, both equal to the resolved org', async () => { + let session: any, user: any; + engine.registerHook('beforeInsert', async (ctx: any) => { session = ctx.session; user = ctx.user; }, { object: 'task' }); + + await engine.insert('task', { title: 'x' }, { context: { userId: 'u1', tenantId: 'org_1' } as any }); + + // Blessed name and the deprecated alias carry the identical value. + expect(session.organizationId).toBe('org_1'); + expect(session.tenantId).toBe('org_1'); + expect(session.organizationId).toBe(session.tenantId); + // `ctx.user` shortcut carries the same org for zero-relearning filtering. + expect(user).toMatchObject({ id: 'u1', organizationId: 'org_1' }); + }); + + it('unscoped (no org) call: session present, org fields undefined, user has no organizationId', async () => { + let session: any, user: any; + engine.registerHook('beforeInsert', async (ctx: any) => { session = ctx.session; user = ctx.user; }, { object: 'task' }); + + await engine.insert('task', { title: 'y' }, { context: { userId: 'u1' } as any }); + + expect(session).toBeDefined(); + expect(session.organizationId).toBeUndefined(); + expect(session.tenantId).toBeUndefined(); + expect(user).toMatchObject({ id: 'u1' }); + expect(user.organizationId).toBeUndefined(); + }); + + it('system / no acting user: user shortcut is undefined (org read via session)', async () => { + let userSeen: any = 'unset'; + engine.registerHook('beforeInsert', async (ctx: any) => { userSeen = ctx.user; }, { object: 'task' }); + + await engine.insert('task', { title: 'z' }, { context: { isSystem: true, tenantId: 'org_1' } as any }); + + expect(userSeen).toBeUndefined(); + }); + }); + describe('execution context via the trailing options arg (read methods)', () => { // Regression: reads took context inside the query while writes took it in // a trailing options arg — so `find(obj, q, { context })` silently dropped diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 3956cbae32..a7e1b5e444 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -737,6 +737,12 @@ export class ObjectQL implements IDataEngine { if (!execCtx) return undefined; return { 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). + organizationId: execCtx.tenantId, tenantId: execCtx.tenantId, positions: execCtx.positions, accessToken: execCtx.accessToken, @@ -772,6 +778,28 @@ export class ObjectQL implements IDataEngine { }; } + /** + * Build the `HookContext.user` shortcut — the ergonomic "current user" + * object surfaced to JS hooks. Carries `organizationId` (the blessed name + * for the caller's active org, identical to `session.organizationId` and + * `current_user.organizationId`) so a hook author who needs "the current org + * to filter by" writes `ctx.user.organizationId` with zero relearning (#3280). + * + * Returns undefined for system / unauthenticated writes (no acting user) — + * hooks that need an org regardless of a resolved user read + * `ctx.session.organizationId`, which is populated whenever a session is. + */ + private buildUser(execCtx?: ExecutionContext): HookContext['user'] { + if (!execCtx || execCtx.userId == null) return undefined; + return { + id: String(execCtx.userId), + ...(execCtx.email ? { email: execCtx.email } : {}), + // Always equals `session.organizationId` (both from `execCtx.tenantId`). + // Undefined on unscoped (platform/community) calls. + ...(execCtx.tenantId != null ? { organizationId: String(execCtx.tenantId) } : {}), + }; + } + /** * Build the DriverOptions blob passed to every IDataDriver call. * @@ -2162,6 +2190,7 @@ export class ObjectQL implements IDataEngine { event: 'beforeFind', input: { ast: opCtx.ast, options: opCtx.options }, session: this.buildSession(opCtx.context), + user: this.buildUser(opCtx.context), api: this.buildHookApi(opCtx.context), transaction: opCtx.context?.transaction, ql: this @@ -2244,6 +2273,7 @@ export class ObjectQL implements IDataEngine { event: 'beforeFind', input: { ast: opCtx.ast, options: opCtx.options }, session: this.buildSession(opCtx.context), + user: this.buildUser(opCtx.context), api: this.buildHookApi(opCtx.context), transaction: opCtx.context?.transaction, ql: this @@ -2330,6 +2360,7 @@ export class ObjectQL implements IDataEngine { event: 'beforeInsert', input: { data: row, options: opCtx.options }, session: this.buildSession(opCtx.context), + user: this.buildUser(opCtx.context), api: this.buildHookApi(opCtx.context), transaction: opCtx.context?.transaction, ql: this, @@ -2599,6 +2630,7 @@ export class ObjectQL implements IDataEngine { event: 'beforeUpdate', input: { id, data: opCtx.data, options: opCtx.options }, session: this.buildSession(opCtx.context), + user: this.buildUser(opCtx.context), api: this.buildHookApi(opCtx.context), transaction: opCtx.context?.transaction, ql: this @@ -2924,6 +2956,7 @@ export class ObjectQL implements IDataEngine { event: 'beforeDelete', input: { id, options: opCtx.options }, session: this.buildSession(opCtx.context), + user: this.buildUser(opCtx.context), api: this.buildHookApi(opCtx.context), transaction: opCtx.context?.transaction, ql: this diff --git a/packages/spec/src/data/hook.test.ts b/packages/spec/src/data/hook.test.ts index 5d5ec64207..fbfd8ac678 100644 --- a/packages/spec/src/data/hook.test.ts +++ b/packages/spec/src/data/hook.test.ts @@ -566,6 +566,41 @@ 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)', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeInsert', + input: {}, + session: { + userId: 'user_123', + organizationId: 'org_456', + tenantId: 'org_456', + }, + ql: {}, + }); + + expect(context.session?.organizationId).toBe('org_456'); + expect(context.session?.tenantId).toBe('org_456'); + }); + + it('should accept user.organizationId shortcut', () => { + const context = HookContextSchema.parse({ + object: 'account', + event: 'beforeInsert', + input: {}, + user: { + id: 'user_123', + email: 'dev@example.com', + organizationId: 'org_456', + }, + ql: {}, + }); + + expect(context.user?.organizationId).toBe('org_456'); + }); }); describe('Transaction Support', () => { diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index e5912624b5..4d097d4c32 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -208,11 +208,26 @@ export const HookContextSchema = lazySchema(() => z.object({ /** * Execution Session - * Contains authentication and tenancy information. + * Contains authentication and organization/tenancy information. */ session: z.object({ userId: z.string().optional(), - tenantId: 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. + */ + 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'), 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)'), @@ -253,6 +268,14 @@ export const HookContextSchema = lazySchema(() => z.object({ id: z.string().optional(), name: z.string().optional(), email: z.string().optional(), + /** + * Active organization ID of the acting user — the blessed name for + * "the current org to filter/scope by" inside a hook. Same value as + * `session.organizationId`, `current_user.organizationId` (RLS), and the + * `organization_id` column, so a hook author needs zero relearning to move + * between surfaces. Undefined for unscoped (platform/community) calls. + */ + organizationId: z.string().optional().describe('Active organization ID of the acting user (equals session.organizationId)'), }).optional().describe('Current user info shortcut'), })); diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index f69fc8efcd..35e3d3f4c1 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -255,7 +255,9 @@ interface HookContext { // Execution context session?: { userId?: string; - tenantId?: string; + organizationId?: string; // Active org — blessed name. Matches the + // `organization_id` column + `current_user.organizationId` (RLS) + tenantId?: string; // @deprecated alias of organizationId (identical value) roles?: string[]; accessToken?: string; }; @@ -266,15 +268,47 @@ interface HookContext { ql: IDataEngine; // ObjectQL engine instance api?: ScopedContext; // Cross-object CRUD API - // User info shortcut + // User info shortcut (undefined for system / unauthenticated writes) user?: { id?: string; name?: string; email?: string; + organizationId?: string; // Same value as session.organizationId }; } ``` +### Reading the current organization + +The value a hook usually wants when it needs "the current org to filter/scope +by" is the caller's **active organization** — the same value that lives in the +`organization_id` column, in `current_user.organizationId` inside RLS/sharing +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; +``` + +`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. + +> **Two isolation axes — don't conflate them.** `organization_id` is +> **org row-scoping**: many organizations share one database and every row +> carries its owning org (`current_user.organizationId` filters reads/writes; +> multi-org needs cloud + `@objectstack/organizations`). That is different from +> **environment / database-per-tenant** isolation (`service-tenant`, +> `driver-turso`), where "tenant" means an entire environment/database and the +> generic driver-layer `tenantId` knob can carry that environment id. The +> object-metadata `tenancy.*` knob configures the *mechanism* (isolation on/off +> + which column); the *value* you read and write is your `organization_id` +> column. Community edition never populates an org, so `organizationId` is +> `undefined` there. + ### `input` — Operation Parameters The structure of `ctx.input` varies by event: