From 07a7ba828e317f89567f1615f8a962b398589e12 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 25 Jun 2026 19:57:01 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(auth):=20sys=5Fuser.ai=5Faccess=20?= =?UTF-8?q?=E2=86=92=20synthesize=20the=20ai=5Fseat=20capability=20(simple?= =?UTF-8?q?=20AI-seat=20model)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The env DB (Turso) is single-org and has only sys_user — so the AI seat is best modeled as a boolean on the user, not a permission set. Add `ai_access` as a better-auth additionalField (rides on the session user) and have resolveCtx synthesize the `ai_seat` capability when it is true. The existing per-agent gate (evaluateAgentAccess → requires ai_seat), catalog filter, and frontend are unchanged — they now admit a user via the field with NO permission-set grant. ADDITIVE: the permission-set path still works (a user can get ai_seat from either source), so there is no regression while the rest of the simplification (have the entitlement layer SET the field + retire the permission-set seed/junction/cap- middleware + repoint the management UI) lands. Built clean (plugin-auth + hono). Co-Authored-By: Claude Opus 4.8 --- packages/plugins/plugin-auth/src/auth-manager.ts | 9 +++++++++ packages/plugins/plugin-hono-server/src/hono-plugin.ts | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 30f056cfc4..ae6d10b6c8 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -324,6 +324,15 @@ export class AuthManager { // createAdapterFactory. user: { ...AUTH_USER_CONFIG, + // System-managed per-user capability flag(s). `ai_access` is the + // env-side AI-seat marker (single-org env DB has only sys_user, so the + // seat lives here as a boolean, not a permission set). better-auth + // includes additionalFields in the session user, so resolveCtx reads + // it with no extra query and synthesizes the `ai_seat` capability. + // `input:false` → not self-settable; set by the entitlement layer/admin. + additionalFields: { + ai_access: { type: 'boolean', required: false, defaultValue: false, input: false }, + }, }, account: { ...AUTH_ACCOUNT_CONFIG, diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 4d13aebb54..ab418d0de1 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -490,6 +490,16 @@ export class HonoServerPlugin implements Plugin { /* fall back to self-only */ } } + // Env-side AI-seat marker (ADR: simple model). The single-org + // env DB has no permission-set/org dimension for this — the seat + // is a boolean on sys_user (a better-auth additionalField, so it + // rides on the session user). When set, synthesize the `ai_seat` + // capability so the existing per-agent gate (evaluateAgentAccess + // → requires `ai_seat`) admits the user with no permission-set + // grant. Absent/false → no synthesis (gate denies as before). + if ((session.user as any)?.ai_access === true && !permissions.includes('ai_seat')) { + permissions.push('ai_seat'); + } return { userId, tenantId, From bbac28f974c63f618bb017ec078807674f3dc864 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 25 Jun 2026 20:38:58 +0800 Subject: [PATCH 2/2] fix(ai-seat): own ai_access via objectql SysUser def + guarded read (not a better-auth additionalField) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sys_user is a better-auth-MANAGED table and better-auth SELECTs explicit columns. Declaring ai_access as a better-auth additionalField (my first cut) would make getSession query a column that may not exist on every env yet → broken auth on deploy. Safe-by-construction instead: • drop the additionalField (better-auth stays oblivious to the column); • own ai_access on the objectql SysUser object def → provisioned by boot schema-sync, and editable as a normal field (the management UI is a checkbox); • resolveCtx reads it via a GUARDED system query (.catch → []), so a missing column / failed read can only NO-OP — never break auth. Built clean (platform-objects + plugin-auth + plugin-hono-server). Amends the prior additionalField approach on this branch. Co-Authored-By: Claude Opus 4.8 --- .../src/identity/sys-user.object.ts | 13 ++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 17 +++++----- .../plugin-hono-server/src/hono-plugin.ts | 32 +++++++++++++------ 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 8a603e822b..850d58ff26 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -395,6 +395,19 @@ export const SysUser = ObjectSchema.create({ description: 'When set, the ban auto-clears at this time.', }), + ai_access: Field.boolean({ + label: 'AI Access', + defaultValue: false, + group: 'Admin', + description: + 'Whether this user holds an AI seat — grants access to the in-UI AI ' + + 'agents (build / ask). The framework synthesizes the `ai_seat` ' + + 'capability from this flag (plugin-hono-server resolveCtx). Assignment ' + + 'is capped by the licensed / purchased seat count (enforced by ' + + '@objectstack/security-enterprise AiSeatPlugin). Owned by objectql ' + + '(better-auth is oblivious to this column).', + }), + // ── Profile ────────────────────────────────────────────────── image: Field.url({ label: 'Profile Image', diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index ae6d10b6c8..c0764915fd 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -324,15 +324,14 @@ export class AuthManager { // createAdapterFactory. user: { ...AUTH_USER_CONFIG, - // System-managed per-user capability flag(s). `ai_access` is the - // env-side AI-seat marker (single-org env DB has only sys_user, so the - // seat lives here as a boolean, not a permission set). better-auth - // includes additionalFields in the session user, so resolveCtx reads - // it with no extra query and synthesizes the `ai_seat` capability. - // `input:false` → not self-settable; set by the entitlement layer/admin. - additionalFields: { - ai_access: { type: 'boolean', required: false, defaultValue: false, input: false }, - }, + // NOTE: the env-side AI-seat marker `sys_user.ai_access` is deliberately + // NOT declared as a better-auth additionalField. sys_user is a + // better-auth-MANAGED table and better-auth SELECTs explicit columns, so + // declaring it here would make getSession query a column that may not + // exist on every env yet → broken auth. Instead the column is owned by + // the objectql `SysUser` object def (provisioned by boot schema-sync) + // and read by a GUARDED system query in resolveCtx (can only no-op, + // never break auth). better-auth stays oblivious to the extra column. }, account: { ...AUTH_ACCOUNT_CONFIG, diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index ab418d0de1..7b307c74e9 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -490,15 +490,29 @@ export class HonoServerPlugin implements Plugin { /* fall back to self-only */ } } - // Env-side AI-seat marker (ADR: simple model). The single-org - // env DB has no permission-set/org dimension for this — the seat - // is a boolean on sys_user (a better-auth additionalField, so it - // rides on the session user). When set, synthesize the `ai_seat` - // capability so the existing per-agent gate (evaluateAgentAccess - // → requires `ai_seat`) admits the user with no permission-set - // grant. Absent/false → no synthesis (gate denies as before). - if ((session.user as any)?.ai_access === true && !permissions.includes('ai_seat')) { - permissions.push('ai_seat'); + // Env-side AI-seat marker (simple model). The single-org env + // DB has no permission-set/org dimension for this — the seat is + // the boolean `sys_user.ai_access`. Read it with a GUARDED system + // query (NOT a better-auth additionalField: sys_user is + // better-auth-managed and better-auth SELECTs explicit columns, + // so an additionalField would make getSession query a possibly- + // missing column → broken auth; a guarded read can only no-op). + // When true, synthesize the `ai_seat` capability so the per-agent + // gate (evaluateAgentAccess → requires `ai_seat`) admits the user + // with no permission-set grant. Absent/false/missing-column → + // no synthesis (deny, as before). + if (!permissions.includes('ai_seat')) { + try { + const ql = getObjectQL(); + const sysCtx = { context: { isSystem: true } }; + const uRows = await ql?.find?.( + 'sys_user', + { where: { id: userId }, limit: 1, ...sysCtx } as any, + ).catch(() => []); + if ((uRows?.[0] as any)?.ai_access === true) permissions.push('ai_seat'); + } catch { + /* no ai_access column / query failed → no seat (safe) */ + } } return { userId,