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
29 changes: 29 additions & 0 deletions .changeset/console-2e7d7f0f7ee7.md
Original file line number Diff line number Diff line change
@@ -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`
11 changes: 11 additions & 0 deletions .changeset/unify-org-identifier-hook-session.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
077e45b4bc5517bddeab8bb1ea35fcf49dccdd34
2e7d7f0f7ee76b838f580e8a36b74f1309b204fc
4 changes: 2 additions & 2 deletions content/docs/references/data/hook.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,11 @@ 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; 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 |


---
Expand Down
44 changes: 44 additions & 0 deletions packages/objectql/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions packages/spec/src/data/hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
27 changes: 25 additions & 2 deletions packages/spec/src/data/hook.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)'),
Expand Down Expand Up @@ -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'),
}));

Expand Down
38 changes: 36 additions & 2 deletions skills/objectstack-data/references/data-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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:
Expand Down