From 9f137174b5894d3ecfafc047276c9264d8228f9d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:31:46 +0000 Subject: [PATCH] feat(plugin-hono-server): export the current-user endpoint registrar so a bare-adapter host can supply them (cloud#924) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/api/v1/auth/me/permissions`, `/auth/me/localization` and `/me/apps` are the platform's SOLE supply — `packages/rest` and `packages/runtime` register no `/me/*` route, the objectui console reads the first for its whole permission layer and the second for regional defaults, and `core/security/auth-gate.ts` allow-lists the last two as endpoints a gated user MUST still reach. #4073/#4079 freed them from `registerStandardEndpoints` but left the supply welded to `HonoServerPlugin`. A host that stands up a bare `HonoHttpServer` and registers it as `http.server` itself — cloud's default Vercel/serverless `bootKernel` branch, whose `OS_NODE_SERVE=1` sibling mounts the real plugin — got no provider at all, so the console's FLS/`apiOperations` had a server-side answer on one startup path and a 404 on the other (cloud#924). Registration is a function of a Hono app plus a service locator, not of owning the listening socket, so it moves to `./current-user-endpoints` and is exported: - `registerCurrentUserEndpoints({ rawApp, ctx, prefix? })` where `ctx` is any `{ getService, logger }` — a `PluginContext` satisfies it structurally. - Idempotent: returns `false` and registers nothing when all three paths are already served, so a host may pre-register on the raw app AND mount the plugin without the plugin shadowing it with dead duplicates. `every`, not `some`, so a host owning just one path still gets the other two. - `makeExecutionContextResolver` and the four permission-map shaping helpers move with it (same package-root exports, no renames) — they exist only to shape this endpoint's `objects` map. The plugin now delegates from the same `kernel:ready` hook, in the same order relative to the CRUD/discovery block, so `os serve` and every plugin-mounting host are byte-identical. Tests: 137 passed in plugin-hono-server (5 new — bare-app mount + answer, non-default prefix, idempotence, partial-host re-registration, and a host pre-register + plugin mount that must yield ONE registration owned by the host). client 200, http-conformance 46, runtime hono/route-parity/ready 16 — all unchanged. tsc + eslint clean; `check:authz-resolver` still passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016gEeLZ4oTeSXG6fKG1r3vd --- .../hono-current-user-endpoints-exported.md | 53 ++ .../src/current-user-endpoints.ts | 863 ++++++++++++++++++ .../src/effective-api-operations.test.ts | 2 +- .../src/fold-wildcard-superuser.test.ts | 2 +- .../src/hono-current-user-endpoints.test.ts | 109 +++ .../src/hono-discovery.test.ts | 6 +- .../plugin-hono-server/src/hono-plugin.ts | 798 +--------------- .../plugins/plugin-hono-server/src/index.ts | 1 + 8 files changed, 1064 insertions(+), 770 deletions(-) create mode 100644 .changeset/hono-current-user-endpoints-exported.md create mode 100644 packages/plugins/plugin-hono-server/src/current-user-endpoints.ts diff --git a/.changeset/hono-current-user-endpoints-exported.md b/.changeset/hono-current-user-endpoints-exported.md new file mode 100644 index 0000000000..cd3c22af2d --- /dev/null +++ b/.changeset/hono-current-user-endpoints-exported.md @@ -0,0 +1,53 @@ +--- +"@objectstack/plugin-hono-server": minor +--- + +feat(plugin-hono-server): export `registerCurrentUserEndpoints` so a host without the plugin can still supply them (cloud#924) + +`GET /api/v1/auth/me/permissions`, `/api/v1/auth/me/localization` and +`/api/v1/me/apps` are the platform's **sole** supply — neither +`@objectstack/rest` nor `@objectstack/runtime` registers any `/me/*` route, the +objectui console reads the first for its whole permission layer and the second +for regional defaults, and `core`'s auth gate allow-lists the last two as +endpoints a gated user MUST still reach. #4073/#4079 freed them from the +`registerStandardEndpoints` flag, but left the supply welded to +`HonoServerPlugin`: a host that stands up a bare `HonoHttpServer` and registers +it as `http.server` itself — rather than mounting the plugin — got no provider at +all, and the console's FLS / `apiOperations` had no server-side answer on that +startup path. + +Registration needs a Hono app and a service locator, not ownership of the +listening socket, so it is now a standalone module (`./current-user-endpoints`) +that both shapes call: + +```ts +import { registerCurrentUserEndpoints } from '@objectstack/plugin-hono-server'; + +const httpServer = new HonoHttpServer(); +kernel.registerService('http.server', httpServer); +registerCurrentUserEndpoints({ + rawApp: httpServer.getRawApp(), + // any { getService, logger } — a PluginContext satisfies it structurally + ctx: { getService: (n) => { try { return kernel.getService(n); } catch { return undefined; } } }, +}); +``` + +It is **idempotent**: it returns `false` and registers nothing when all three +paths are already served, so a host may both call it eagerly on the raw app AND +mount the plugin — the plugin's `kernel:ready` registration then no-ops instead +of shadowing the host's routes with dead duplicates. Registering early matters, +because Hono's only route precedence is first-registration-wins and plugin-auth +mounts a `/api/v1/auth/*` wildcard that `/auth/me/*` must outrank. + +**No behaviour change for existing hosts.** `os serve` and every host that mounts +`HonoServerPlugin` register the same three routes, in the same `kernel:ready` +position, with the same response shapes — the plugin now delegates to the shared +registrar instead of owning a private method. + +**Moved exports (same package, same names, no rename).** `foldWildcardSuperUser`, +`clampManagedObjectWrites`, `seedSuperUserRestrictedObjects`, +`annotateEffectiveApiOperations`, `ManagedSchemaLike` and `ApiExposureSchemaLike` +now live in `./current-user-endpoints` alongside the endpoint they shape. Importing +them from the package root (`@objectstack/plugin-hono-server`) is unchanged; only a +deep import of `.../dist/hono-plugin` would need updating, and the package exposes +no such subpath. diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts new file mode 100644 index 0000000000..fb32091a4a --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -0,0 +1,863 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Current-user endpoints — `/auth/me/permissions`, `/auth/me/localization` and + * `/me/apps` — plus the session → `ExecutionContext` resolver and the + * permission-map shaping they need. + * + * ## Why this is its own module (and its own export) + * + * These three are the platform's SOLE supply: `packages/rest` and + * `packages/runtime` register no `/me/*` route at all, the objectui console + * reads `/auth/me/permissions` for its whole permission layer and + * `/auth/me/localization` for regional defaults, and `core`'s auth gate + * allow-lists `/me/apps` + `/me/localization` as endpoints a gated user MUST + * still reach to bootstrap the remediation UI. #4079 lifted them out from under + * `registerStandardEndpoints` (which covers only DUPLICATE supply — raw `/data` + * CRUD and a discovery the dispatcher/REST own) so the flag could not take the + * console down with it. + * + * That split fixed the flag but left the supply still welded to + * {@link HonoServerPlugin}: a host that stands up a bare {@link HonoHttpServer} + * instead of mounting the plugin — which is exactly what cloud's Vercel / + * serverless entrypoints do, while their `OS_NODE_SERVE=1` sibling mounts the + * real plugin — got no provider at all, and the console's FLS / `apiOperations` + * had no server-side answer on that path (cloud#924). Registration is a + * function of a Hono app plus a service locator, not of owning the socket, so it + * lives here and both shapes call the same one. + * + * {@link registerCurrentUserEndpoints} is idempotent: a host may call it eagerly + * on its raw app AND mount the plugin, and the plugin's own `kernel:ready` + * registration then finds the routes present and skips rather than shadowing + * them with dead duplicates. Registration order is the only route precedence + * Hono has (first registration wins), and it is load-bearing here — plugin-auth + * (and cloud's `AuthProxyPlugin`) mount a `/api/v1/auth/*` wildcard that + * `/auth/me/*` must be registered ahead of to win the match without a wasted + * round-trip to an auth service that does not implement these paths. + */ + +import { IDataEngine, derivePosture } from '@objectstack/core'; +import { ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN_GRANTS } from '@objectstack/spec'; +import { + resolveEffectiveApiMethods, + effectiveOperationsArray, + type EnableLike, +} from '@objectstack/spec/data'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { Logger } from '@objectstack/spec/contracts'; +import { allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; + +/** API prefix these endpoints mount under unless the host overrides it. */ +export const DEFAULT_CURRENT_USER_PREFIX = '/api/v1'; + +/** + * The service locator + logger the current-user endpoints resolve their answer + * from. A `PluginContext` satisfies this structurally, so the plugin passes its + * own context unchanged; a host outside the plugin lifecycle (cloud's + * serverless entrypoints) passes a thin adapter over its kernel. + * + * `getService` may return `undefined` for an absent service — every service + * these endpoints read is optional, and each read has a defined degraded answer + * (see the handlers). A locator that THROWS instead is equally accepted: the + * reads are wrapped, exactly as they were when `PluginContext.getService` (which + * throws) was the only caller. + */ +export interface CurrentUserEndpointsContext { + getService(name: string): T | undefined; + logger?: Partial>; +} + +/** Options for {@link registerCurrentUserEndpoints}. */ +export interface RegisterCurrentUserEndpointsOptions { + /** + * The raw Hono app to register on. Routes go on the raw app (not through + * `IHttpServer`'s verb methods) because that is where the `/api/v1/auth/*` + * wildcards they must outrank are mounted. + */ + rawApp: any; + /** Service locator + logger — see {@link CurrentUserEndpointsContext}. */ + ctx: CurrentUserEndpointsContext; + /** API prefix. @default '/api/v1' */ + prefix?: string; +} + +/** The three route paths this module owns, under `prefix`. */ +export function currentUserRoutePaths(prefix: string = DEFAULT_CURRENT_USER_PREFIX): string[] { + return [ + `${prefix}/auth/me/permissions`, + `${prefix}/auth/me/localization`, + `${prefix}/me/apps`, + ]; +} + +/** + * Whether every path in `paths` is already served by a GET (or `all()`) route on + * this app — the idempotence predicate. + * + * `every`, not `some`: this function registers all three or none, so a partial + * state can only come from a host mounting its own route at one of these paths. + * Treating that as "already provided" would silently drop the other two, while + * re-registering all three leaves the host's earlier registration winning its + * own path (first registration wins) and supplies the rest. + */ +function allPathsMounted(rawApp: any, paths: readonly string[]): boolean { + const routes: Array<{ method?: string; path?: string }> = Array.isArray(rawApp?.routes) ? rawApp.routes : []; + return paths.every((p) => + routes.some((r) => { + if (r?.path !== p) return false; + const method = String(r?.method ?? '').toUpperCase(); + return method === 'GET' || method === 'ALL'; + }), + ); +} + +/** + * Fold the `'*'` wildcard super-user grant into every per-object entry of a + * `/me/permissions` `objects` map, mutating it in place. + * + * The endpoint merges each resolved permission set's explicit `objects` entries + * most-permissively per key, but treats `'*'` and named objects as independent + * keys — so a wildcard "Modify/View All Data" grant is never propagated into a + * per-object entry another set explicitly denied. That makes the client's + * per-object FLS STRICTER than the server's actual enforcement + * (`PermissionEvaluator.checkObjectPermission`, which returns allow as soon as + * ANY set grants — including via the `'*'` modifyAll/viewAll super-user bypass, + * with no deny-wins). The mismatch surfaces for a platform admin + * (`admin_full_access` `'*': {modifyAllRecords}`) who ALSO holds + * `organization_admin` (which denies writes on identity tables): the client + * would see `sys_user.allowEdit:false` and disable a form the server accepts + * (verified: `PATCH /data/sys_user {name}` → 200). ADR-0057 D10 makes the + * server the authoritative gate; the client must mirror it, never diverge. + * + * The super-user grant covers private/managed objects on the server, so folding + * it here is exactly as broad as real enforcement — never broader. + */ +export function foldWildcardSuperUser(objects: Record): void { + const wild = objects?.['*']; + if (!wild) return; + const superRead = wild.viewAllRecords === true || wild.modifyAllRecords === true; + const superWrite = wild.modifyAllRecords === true; + if (!superRead && !superWrite) return; + for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { + if (obj === '*' || !acc) continue; + if (superRead) acc.allowRead = true; + if (superWrite) { + acc.allowEdit = true; + acc.allowCreate = true; + acc.allowDelete = true; + } + } +} + +/** Minimal schema shape the managed-write clamp needs. */ +export interface ManagedSchemaLike { + managedBy?: string; + userActions?: { + create?: boolean; + // edit/delete accept the #2614 object form ({ enabled, visibleWhen, + // disabledWhen }); only the object-level `enabled` matters here — the + // per-record predicates are UI gating, not a permission grant. + edit?: boolean | { enabled?: boolean }; + delete?: boolean | { enabled?: boolean }; + } | null; +} + +/** True only when a userActions flag (bare boolean or object form) explicitly opts the write in. */ +function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null): boolean { + return v === true || (typeof v === 'object' && v !== null && v.enabled === true); +} + +/** + * Buckets whose user-context generic writes are guarded fail-closed at the + * engine: `better-auth` by plugin-auth's identity write guard (ADR-0092 D2), + * `engine-owned` / `system` / `append-only` by plugin-security's engine-owned + * write guard (ADR-0103). `config` / `platform` have no such guard — their + * permission-set result stands. + */ +const GUARDED_WRITE_BUCKETS: ReadonlySet = new Set(['better-auth', 'system', 'engine-owned', 'append-only']); + +/** + * Re-clamp a `/me/permissions` `objects` map by the SECOND server-side + * enforcement layer that permission sets don't model: the engine write guards. + * They fail-closed reject USER-CONTEXT insert/update/delete on every managed + * object whose resolved affordances forbid the verb — `better-auth` + * (ADR-0092 D2) and `system`/`append-only` (ADR-0103) — except where the object + * opted the write affordance in via `userActions.{create,edit,delete}` (e.g. + * sys_user opens `edit` for its profile fields; the RBAC link tables / prefs / + * messaging config open their CRUD). + * + * Without this clamp, {@link foldWildcardSuperUser} would report `allowEdit:true` + * for a platform admin on tables the guard actually blocks (sys_member, + * sys_automation_run, …) — a false-POSITIVE that mirrors, inverted, the + * false-negative the fold fixes. The real effective answer for a user-context + * caller is `permission-set grant ∩ guard policy`, and the guard policy for a + * guarded object is exactly its resolved CRUD affordance. `config`/`platform` + * objects are NOT clamped — no guard covers them, so their permission-set result + * stands (an admin CAN write them via the data API, and the hint must not + * under-report that). + */ +export function clampManagedObjectWrites( + objects: Record, + schemaOf: (objectName: string) => ManagedSchemaLike | undefined, +): void { + for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { + if (obj === '*' || !acc) continue; + const schema = schemaOf(obj); + if (!schema?.managedBy || !GUARDED_WRITE_BUCKETS.has(schema.managedBy)) continue; + const ua = schema.userActions ?? {}; + if (!isWriteOptedIn(ua.edit)) acc.allowEdit = false; + if (ua.create !== true) acc.allowCreate = false; + if (!isWriteOptedIn(ua.delete)) acc.allowDelete = false; + } +} + +/** The API-exposure-relevant slice of a registered object schema. */ +export interface ApiExposureSchemaLike { + name?: string; + enable?: EnableLike | null; +} + +/** + * [#3391] Seed false-initialized per-object entries for a MODIFY-ALL super-user, + * for every registered object whose `apiMethods` whitelist tightens exposure. + * + * A super-user's grant is usually the `'*'` wildcard, not explicit per-object + * entries — so restricting objects never appear in the merged `objects` map and + * would miss their `apiOperations` annotation. Seeding a `{allow*: false}` entry + * lets {@link foldWildcardSuperUser} pull it true (super-user reads/writes + * everything) and lets {@link annotateEffectiveApiOperations} attach the effective + * set. Runs BEFORE fold. + * + * Guarded to `modifyAllRecords` super-users ONLY: for a viewAll-only caller, + * materializing a `false` entry would flip the client's `check('edit')` from + * "undefined → default-allow" to "explicit false → deny" — a scope-exceeding + * behavior change. A modify-all caller is folded to `true` anyway, so seeding is + * harmless there. Unrestricted objects are skipped (they carry no annotation). + */ +export function seedSuperUserRestrictedObjects( + objects: Record, + allSchemas: readonly ApiExposureSchemaLike[], +): void { + if (objects?.['*']?.modifyAllRecords !== true) return; + for (const schema of allSchemas) { + const name = schema?.name; + if (!name || name === '*' || objects[name]) continue; + const eff = resolveEffectiveApiMethods(schema.enable ?? undefined); + if (eff.mode === 'unrestricted') continue; // only restricting objects + objects[name] = { allowCreate: false, allowRead: false, allowEdit: false, allowDelete: false }; + } +} + +/** + * [#3391] Annotate each per-object `/me/permissions` entry with the SERVER's + * effective API operation set (`apiOperations`), mutating the map in place. + * + * This is the single "effective" channel the frontend consumes — it renders the + * operations the server hands down here, never the raw `apiMethods` whitelist. + * Only objects whose whitelist actually tightens exposure are annotated (a + * `deny-all` object gets an empty array; an unrestricted object gets nothing, so + * the client keeps its default-allow behavior). Runs AFTER fold + clamp so the + * annotation sits alongside the final CRUD affordances. + */ +export function annotateEffectiveApiOperations( + objects: Record, + schemaOf: (objectName: string) => ApiExposureSchemaLike | undefined, +): void { + // [#3544] The `'*'` entry's export grant is the FALLBACK for objects that do + // not carry one of their own. The merge keeps `'*'` and named objects as + // independent keys, but the server evaluator does not: its + // `resolveObjectPermission` falls back to the wildcard whenever a set has no + // explicit entry for the object, so an admin set granting export wholesale + // via `'*': { allowExport: true }` really does grant it per-object. Reading + // the wildcard here keeps the button the client shows and the request the + // server accepts in agreement — the same class of client/server divergence + // `foldWildcardSuperUser` exists to close, on the export axis. + const wildExport = objects?.['*']?.allowExport; + for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { + if (obj === '*' || !acc) continue; + const schema = schemaOf(obj); + if (!schema) continue; // schema missing → no annotation (client falls back) + // [#3544] User-level export axis: `export` derives from `list ∧ this + // grant`. OPT-IN — only an explicit `true` (on the object entry, else + // inherited from `'*'`) allows export; unset and `false` both withhold + // it, and the super-user bits do NOT imply it. + const exportBit = acc.allowExport ?? wildExport; + const userExportAllowed = exportBit === true; + const eff = resolveEffectiveApiMethods(schema.enable ?? undefined, { userExportAllowed }); + // Annotate when the object tightens via `apiMethods`, OR when the export + // axis removes `export` from an otherwise-open object (so the client + // hides the Export button). An unrestricted object with export still + // allowed needs no annotation — the client keeps its default-allow path. + if (eff.mode === 'unrestricted' && userExportAllowed) continue; + acc.apiOperations = effectiveOperationsArray(eff); + } +} + +/** + * Build the session → `ExecutionContext` resolver the current-user endpoints — + * and the plugin's standalone `/data` CRUD surface — both need. + * + * Extracted from `registerDiscoveryAndCrudEndpoints` when the current-user + * endpoints stopped being gated on `registerStandardEndpoints` (#4073): they + * resolve the same principal the `/data` routes do, and one resolver is the + * only way the two groups can agree on who the caller is. + */ +export function makeExecutionContextResolver(ctx: CurrentUserEndpointsContext) { + const getObjectQL = () => ctx.getService('objectql'); + // Helper: resolve ExecutionContext from request headers (cookie session + // or API key). Mirrors the runtime's resolveExecutionContext but + // self-contained to avoid a cross-package dep. We DO query the + // `sys_user_permission_set` link tables because hardcoding a single + // permission set name (e.g. `member_default`) would silently ignore + // any explicit admin / role assignment — including the platform-admin + // promotion seeded by `bootstrapPlatformAdmin`. + const resolveCtx = async (c: any): Promise => { + try { + const authService: any = ctx.getService('auth'); + if (!authService) return undefined; + let api: any = authService.api; + if (!api && typeof authService.getApi === 'function') { + api = await authService.getApi(); + } + if (!api?.getSession) return undefined; + const session = await api.getSession({ headers: c.req.raw.headers }); + if (!session?.user?.id) return undefined; + const userId = session.user.id; + const tenantId = session.session?.activeOrganizationId ?? undefined; + const permissions: string[] = []; + const roles: string[] = []; + try { + const ql = getObjectQL(); + const sysCtx = { context: { isSystem: true } }; + // Roles via sys_member (org-scoped if active org). + const memberRows = await ql?.find?.( + 'sys_member', + { + where: tenantId + ? { user_id: userId, organization_id: tenantId } + : { user_id: userId }, + limit: 50, + ...sysCtx, + } as any, + ).catch(() => []); + for (const m of (memberRows ?? []) as any[]) { + if (typeof m.role === 'string') { + for (const r of m.role.split(',').map((s: string) => s.trim()).filter(Boolean)) { + if (!roles.includes(r)) roles.push(r); + } + } + } + // User-scoped permission sets — match BOTH (a) the active + // org's link rows and (b) the cross-tenant rows + // (organization_id IS NULL) so the platform-admin + // promotion seeded by `bootstrapPlatformAdmin` applies + // regardless of the user's active org. + const upsRows = await ql?.find?.( + 'sys_user_permission_set', + { where: { user_id: userId }, limit: 100, ...sysCtx } as any, + ).catch(() => []); + const psIds = new Set(); + for (const r of (upsRows ?? []) as any[]) { + const orgScope = r.organization_id ?? null; + if (!orgScope || (tenantId && orgScope === tenantId)) { + const pid = r.permission_set_id ?? r.permissionSetId; + if (pid) psIds.add(pid); + } + } + if (psIds.size > 0) { + const psRows = await ql?.find?.( + 'sys_permission_set', + { where: { id: { $in: Array.from(psIds) } }, limit: 500, ...sysCtx } as any, + ).catch(() => []); + for (const ps of (psRows ?? []) as any[]) { + if (ps.name && !permissions.includes(ps.name)) permissions.push(ps.name); + } + } + } catch { + /* fall through with whatever we resolved so far */ + } + // Resolve fellow-org user IDs so identity-table RLS (sys_user + // org-members policy) can scope @-mention pickers, owner + // lookups and reviewer selectors to the active organization. + // Mirrors the resolvers in `@objectstack/rest` and + // `@objectstack/runtime` so all three REST entry-points + // produce a consistent ExecutionContext shape. + let orgUserIds: string[] = [userId]; + if (tenantId) { + try { + const ql = getObjectQL(); + const sysCtx = { context: { isSystem: true } }; + const memberRows = await ql?.find?.( + 'sys_member', + { where: { organization_id: tenantId }, limit: 1000, ...sysCtx } as any, + ).catch(() => []); + const ids = new Set([userId]); + for (const m of (memberRows ?? []) as any[]) { + const uid = m.user_id ?? m.userId; + if (typeof uid === 'string' && uid.length > 0) ids.add(uid); + } + orgUserIds = Array.from(ids); + } catch { + /* fall back to self-only */ + } + } + // [ADR-0105 D2] The caller's org access set — the `group` + // posture's Layer 0 wall is `organization_id IN (...)`, so a + // context without it fails every read closed on this surface. + // Resolved from the user's OWN memberships (all organizations, + // not the active one). This standalone resolver duplicates the + // canonical `resolveAuthzContext` by design (see the posture + // note below); the duplication is tracked by + // `scripts/check-single-authz-resolver.mjs`. + let accessibleOrgIds: string[] = []; + try { + const ql = getObjectQL(); + const sysCtx = { context: { isSystem: true } }; + const myMemberships = await ql?.find?.( + 'sys_member', + { where: { user_id: userId }, limit: 200, ...sysCtx } as any, + ).catch(() => []); + const orgIds = new Set(); + for (const m of (myMemberships ?? []) as any[]) { + const oid = m.organization_id ?? m.organizationId; + if (typeof oid === 'string' && oid.length > 0) orgIds.add(oid); + } + accessibleOrgIds = Array.from(orgIds); + } catch { + /* no memberships resolvable → empty set → fails closed */ + } + // 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(() => []); + // Turso returns sqlite booleans as 1/0; memory driver as boolean. + const aiAccess = (uRows?.[0] as any)?.ai_access; + if (aiAccess === true || aiAccess === 1 || aiAccess === '1') permissions.push('ai_seat'); + } catch { + /* no ai_access column / query failed → no seat (safe) */ + } + } + // [#2408 / #3361] Open the per-request `Server-Timing` disclosure + // gate for an admin/service principal — the standalone-surface analog + // of the runtime dispatcher's `timedResolveExecutionContext`. This + // self-contained resolver derives no posture rung, so derive one HERE, + // for the gate decision ONLY, from the resolved permission-set grants, + // and hand it to the shared `isPerfDisclosurePrincipal` predicate. The + // rung is computed onto a THROW-AWAY object, never the returned + // context: `ctx.posture` is an enforcement input (Layer 0 tier + // adjudication, ADR-0099 D1) and only the authoritative resolver may + // set it. A no-op when perf-tuning is off (no ambient gate). + const disclosurePosture = derivePosture({ + isPlatformAdmin: permissions.includes(ADMIN_FULL_ACCESS), + isTenantAdmin: ORGANIZATION_ADMIN_GRANTS.some((n) => permissions.includes(n)), + }); + if (isPerfDisclosurePrincipal({ isSystem: false, posture: disclosurePosture } as ExecutionContext)) { + allowPerfDisclosure(); + } + return { + userId, + tenantId, + roles, + permissions, + isSystem: false, + org_user_ids: orgUserIds, + accessible_org_ids: accessibleOrgIds, + } as any; + } catch { + return undefined; + } + }; + return resolveCtx; +} + +/** + * Register the current-user endpoints — `/auth/me/permissions`, + * `/auth/me/localization` and `/me/apps` — on `rawApp`. + * + * When {@link HonoServerPlugin} drives this, it is UNCONDITIONAL, unlike the + * plugin's CRUD + discovery block (#4073). Those two groups used to ride on one + * `registerStandardEndpoints` flag, which conflated two opposite things. The flag + * covers DUPLICATE supply — raw `/data` CRUD that `@objectstack/rest` also serves + * (and, being registered first, really serves), plus a discovery that the + * dispatcher/REST own (#4018). These three are the opposite: nothing else in the + * platform mounts them. `packages/rest` and `packages/runtime` register no + * `/me/*` route at all, the console reads `/auth/me/permissions` for its whole + * permission layer and `/auth/me/localization` for regional defaults, and + * `core/security/auth-gate.ts` allow-lists `/me/apps` + `/me/localization` as + * endpoints a gated user MUST still reach to bootstrap the remediation UI. + * `os serve` gets them only because `registerStandardEndpoints` defaults to true + * (`cli/src/commands/serve.ts` passes just `{ port }`), so turning that flag off + * — or retiring the convenience surface it names — would have taken the console + * down with it. + * + * IDEMPOTENT: returns `false` and registers nothing when all three paths are + * already served. That is what lets a host call this eagerly on its own raw app + * AND mount the plugin — the plugin's `kernel:ready` registration then finds them + * present and skips, instead of shadowing the host's routes with dead + * duplicates (cloud#924). + */ +export function registerCurrentUserEndpoints( + options: RegisterCurrentUserEndpointsOptions, +): boolean { + const { rawApp, ctx, prefix = DEFAULT_CURRENT_USER_PREFIX } = options; + const paths = currentUserRoutePaths(prefix); + if (allPathsMounted(rawApp, paths)) { + ctx.logger?.debug?.('Current-user endpoints already registered — skipping', { prefix }); + return false; + } + const resolveCtx = makeExecutionContextResolver(ctx); + // Effective permissions for the current user — single aggregation + // endpoint that resolves session → roles → permission sets → merged + // field/object permissions. Frontend Field-Level Security (FLS) + // consumes this to gate form fields / list columns without having + // to replicate the server's role+permission-set resolution and + // most-permissive merge logic. + // + // Response shape (designed to mirror @object-ui/permissions + // expectations — see `PermissionSet` in @objectstack/spec): + // { + // userId, tenantId, roles, permissionSets, + // objects: Record, + // fields: Record<"object.field", { readable, editable }>, + // } + // + // Returns `{authenticated:false}` (200) when no session is + // present, so the frontend can distinguish anon from error. + rawApp.get(`${prefix}/auth/me/permissions`, async (c: any) => { + const execCtx = await resolveCtx(c); + if (!execCtx?.userId) { + return c.json({ authenticated: false }); + } + try { + const metadata: any = ctx.getService('metadata'); + const evaluator: any = ctx.getService('security.permissions'); + const bootstrap: any[] = (() => { + try { return ctx.getService('security.bootstrapPermissionSets') ?? []; } + catch { return []; } + })(); + const fallbackName: string | null = (() => { + try { return ctx.getService('security.fallbackPermissionSet') ?? 'member_default'; } + catch { return 'member_default'; } + })(); + // DB loader: surfaces user-defined permission sets + // (created via the admin UI as `sys_permission_set` + // rows) that aren't in metadata or bootstrap. + const ql: any = (() => { + try { return ctx.getService('objectql'); } catch { return null; } + })(); + const dbLoader = ql + ? async (names: string[]) => { + let rows: any; + try { + rows = await ql.find( + 'sys_permission_set', + { where: { name: { $in: names } }, limit: names.length }, + { context: { isSystem: true } }, + ); + } catch { + rows = []; + } + const list = Array.isArray(rows) ? rows : rows?.records ?? []; + return list.map((r: any) => ({ + name: r.name, + label: r.label, + objects: typeof r.object_permissions === 'string' + ? JSON.parse(r.object_permissions || '{}') + : r.object_permissions ?? {}, + fields: typeof r.field_permissions === 'string' + ? JSON.parse(r.field_permissions || '{}') + : r.field_permissions ?? {}, + // #2752 follow-through: DB-loaded sets used to drop + // their capability + tab columns, so a direct grant + // of e.g. `setup.access` never surfaced here. + systemPermissions: typeof r.system_permissions === 'string' + ? JSON.parse(r.system_permissions || '[]') + : r.system_permissions ?? [], + tabPermissions: typeof r.tab_permissions === 'string' + ? JSON.parse(r.tab_permissions || '{}') + : r.tab_permissions ?? {}, + })); + } + : undefined; + if (!evaluator || !metadata) { + // Auth resolved but security plugin isn't wired — emit + // an empty-but-authenticated body so the frontend can + // fail-open with full access (matches server behaviour + // when SecurityPlugin isn't registered). + return c.json({ + authenticated: true, + userId: execCtx.userId, + tenantId: execCtx.tenantId ?? null, + positions: execCtx.positions ?? [], + permissionSets: execCtx.permissions ?? [], + objects: {}, + fields: {}, + }); + } + // Resolve the same way SecurityPlugin middleware does: + // role names + explicit permission-set names, with a + // fallback to `member_default` when authenticated users + // resolve to zero permission sets (matches the + // post-resolution fallback in security-plugin.ts). + const requested = [ + ...(execCtx.positions ?? []), + ...(execCtx.permissions ?? []), + ]; + let resolved: any[] = await evaluator + .resolvePermissionSets(requested, metadata, bootstrap, dbLoader) + .catch(() => []); + if (resolved.length === 0 && fallbackName) { + resolved = await evaluator + .resolvePermissionSets([fallbackName], metadata, bootstrap, dbLoader) + .catch(() => []); + } + // Most-permissive merge of `objects` and `fields` across + // all resolved permission sets — same semantics as + // PermissionEvaluator.getFieldPermissions but for ALL + // objects in a single pass. + const objects: Record = {}; + const fields: Record = {}; + const systemPermissions = new Set(); + const tabRank: Record = { hidden: 0, default_off: 1, default_on: 2, visible: 3 }; + const tabPermissions: Record = {}; + for (const ps of resolved) { + if (ps?.objects) { + for (const [obj, perm] of Object.entries(ps.objects)) { + const acc = objects[obj] ?? {}; + for (const [k, v] of Object.entries(perm as any)) { + if (v === true) acc[k] = true; + else if (acc[k] === undefined) acc[k] = v; + } + objects[obj] = acc; + } + } + if (ps?.fields) { + for (const [key, perm] of Object.entries(ps.fields)) { + const acc = fields[key] ?? { readable: false, editable: false }; + const p = perm as any; + if (p.readable) acc.readable = true; + if (p.editable) acc.editable = true; + fields[key] = acc; + } + } + if (Array.isArray(ps?.systemPermissions)) { + for (const sp of ps.systemPermissions) { + if (typeof sp === 'string') systemPermissions.add(sp); + } + } + if (ps?.tabPermissions && typeof ps.tabPermissions === 'object') { + for (const [app, val] of Object.entries(ps.tabPermissions as Record)) { + if (typeof val !== 'string' || !(val in tabRank)) continue; + const cur = tabPermissions[app]; + if (!cur || tabRank[val] > tabRank[cur]) { + tabPermissions[app] = val as 'visible' | 'hidden' | 'default_on' | 'default_off'; + } + } + } + } + // Make the client's per-object FLS reflect the server's ACTUAL + // effective enforcement = permission-set grant ∩ identity write + // guard (ADR-0057 D10). (1) Fold the `'*'` super-user grant into + // every object so an admin's wildcard is not shadowed by another + // set's explicit deny; (2) re-clamp `better-auth` managed objects + // by their write affordance, since the guard (ADR-0092 D2) blocks + // user-context writes there except where the object opted in + // (sys_user → edit). Together these remove both the false-negative + // (admin sees sys_user editable) and the false-positive (admin does + // NOT see sys_member editable, matching the guard). + // [#3391] For a modify-all super-user, seed restricting objects + // absent from the merged map so fold pulls them true and annotate + // can attach their effective apiOperations. Guarded — a failure + // here must never drop the whole response. + try { + const allSchemas: ApiExposureSchemaLike[] = (() => { + try { return (ql as any)?.registry?.getAllObjects?.() ?? []; } + catch { return []; } + })(); + seedSuperUserRestrictedObjects(objects, allSchemas); + } catch (e: any) { + ctx.logger?.warn?.('[hono] effective apiOperations seed failed', { err: e?.message }); + } + foldWildcardSuperUser(objects); + clampManagedObjectWrites(objects, (name) => { + try { return ql?.getSchema?.(name) as ManagedSchemaLike | undefined; } + catch { return undefined; } + }); + // [#3391] Annotate the per-object effective API operation set — + // the single channel the frontend consumes for effective ops. + // Guarded: on failure we simply omit apiOperations and the client + // falls back to its default-allow behavior. + try { + annotateEffectiveApiOperations(objects, (name) => { + try { return ql?.getSchema?.(name) as ApiExposureSchemaLike | undefined; } + catch { return undefined; } + }); + } catch (e: any) { + ctx.logger?.warn?.('[hono] effective apiOperations annotate failed', { err: e?.message }); + } + return c.json({ + authenticated: true, + userId: execCtx.userId, + tenantId: execCtx.tenantId ?? null, + positions: execCtx.positions ?? [], + permissionSets: resolved.map((p: any) => p?.name).filter(Boolean), + objects, + fields, + systemPermissions: Array.from(systemPermissions), + tabPermissions, + }); + } catch (err: any) { + ctx.logger?.warn?.('[hono] /auth/me/permissions failed', { err: err?.message }); + return c.json({ authenticated: true, userId: execCtx.userId, objects: {}, fields: {} }); + } + }); + + // GET /me/localization — the resolved regional defaults (currency / + // locale / timezone) for the current request's tenant, exposed to EVERY + // authenticated user. The `localization` SETTINGS are gated to + // `setup.access`, but the resolved defaults are needed by every renderer + // to format currency/dates/numbers — so they ride on the request + // ExecutionContext (ADR-0053) and are surfaced here without that gate. + rawApp.get(`${prefix}/auth/me/localization`, async (c: any) => { + const execCtx = await resolveCtx(c); + if (!execCtx?.userId) { + return c.json({ authenticated: false }); + } + return c.json({ + authenticated: true, + currency: execCtx.currency ?? null, + locale: execCtx.locale ?? null, + timezone: execCtx.timezone ?? null, + }); + }); + + // GET /me/apps — list apps the current user is allowed to enter. + // Apps live in the ENGINE REGISTRY (runtime AppPlugin registerApp()), + // not the metadata service — reading `metadata.list('app')` returned + // [] for every principal (#2752), leaving tabPermissions and + // AppSchema.requiredPermissions with no enforced consumer. Source + // from `registry.getAllApps()` (the same authority the meta routes + // use, nav contributions merged), with the metadata service kept as + // an additive fallback for runtime-draft-published apps. Filters: + // 1. AppSchema.requiredPermissions ⊆ ctx.systemPermissions + // 2. ctx.tabPermissions[app.name] !== 'hidden' + // Anonymous users get an empty array. When SecurityPlugin is absent + // we fail-open and return every app (matches server behaviour). + rawApp.get(`${prefix}/me/apps`, async (c: any) => { + const execCtx = await resolveCtx(c); + if (!execCtx?.userId) return c.json({ apps: [] }); + try { + const byName = new Map(); + try { + const registry: any = (ctx.getService('objectql') as any)?._registry; + for (const app of registry?.getAllApps?.() ?? []) { + if (app?.name) byName.set(String(app.name), app); + } + } catch { /* registry unavailable — fall through to metadata */ } + try { + const metadata: any = ctx.getService('metadata'); + for (const app of ((await metadata?.list?.('app')) ?? []) as any[]) { + if (app?.name && !byName.has(String(app.name))) byName.set(String(app.name), app); + } + } catch { /* metadata service optional */ } + // Resolve the caller's effective capability/tab surface the + // same way /auth/me/permissions does — resolveCtx() carries + // neither systemPermissions nor tabPermissions, so filtering + // on execCtx fields silently gated EVERY requiredPermissions + // app away from everyone, including the platform admin. + const sysPerms = new Set(execCtx.systemPermissions ?? []); + const tabs: Record = { ...((execCtx as any).tabPermissions ?? {}) }; + let failOpen = true; + try { + const evaluator: any = ctx.getService('security.permissions'); + failOpen = !evaluator; + if (evaluator) { + const metadata: any = ctx.getService('metadata'); + const bootstrap: any[] = (() => { + try { return ctx.getService('security.bootstrapPermissionSets') ?? []; } + catch { return []; } + })(); + const fallbackName: string | null = (() => { + try { return ctx.getService('security.fallbackPermissionSet') ?? 'member_default'; } + catch { return 'member_default'; } + })(); + const requested = [ + ...((execCtx as any).positions ?? []), + ...((execCtx as any).permissions ?? []), + ]; + const qlSvc: any = (() => { try { return ctx.getService('objectql'); } catch { return null; } })(); + const dbLoader = qlSvc + ? async (names: string[]) => { + let rows: any; + try { + rows = await qlSvc.find( + 'sys_permission_set', + { where: { name: { $in: names } }, limit: names.length }, + { context: { isSystem: true } }, + ); + } catch { rows = []; } + const list = Array.isArray(rows) ? rows : rows?.records ?? []; + return list.map((r: any) => ({ + name: r.name, + systemPermissions: typeof r.system_permissions === 'string' + ? JSON.parse(r.system_permissions || '[]') + : r.system_permissions ?? [], + tabPermissions: typeof r.tab_permissions === 'string' + ? JSON.parse(r.tab_permissions || '{}') + : r.tab_permissions ?? {}, + })); + } + : undefined; + let resolved: any[] = await evaluator + .resolvePermissionSets(requested, metadata, bootstrap, dbLoader) + .catch(() => []); + if (resolved.length === 0 && fallbackName) { + resolved = await evaluator + .resolvePermissionSets([fallbackName], metadata, bootstrap, dbLoader) + .catch(() => []); + } + const tabRank: Record = { hidden: 0, default_off: 1, default_on: 2, visible: 3 }; + for (const ps of resolved) { + for (const sp of (Array.isArray(ps?.systemPermissions) ? ps.systemPermissions : [])) { + if (typeof sp === 'string') sysPerms.add(sp); + } + if (ps?.tabPermissions && typeof ps.tabPermissions === 'object') { + for (const [app, val] of Object.entries(ps.tabPermissions as Record)) { + if (typeof val !== 'string' || !(val in tabRank)) continue; + const cur = tabs[app]; + if (!cur || tabRank[val] > (tabRank[cur] ?? -1)) tabs[app] = val; + } + } + } + } + } catch { failOpen = true; } + const apps = [...byName.values()].filter((app: any) => { + if (tabs[app.name] === 'hidden') return false; + if (failOpen) return true; + const req: string[] = Array.isArray(app.requiredPermissions) ? app.requiredPermissions : []; + return req.every((p) => sysPerms.has(p)); + }); + return c.json({ apps }); + } catch (err: any) { + ctx.logger?.warn?.('[hono] /me/apps failed', { err: err?.message }); + return c.json({ apps: [] }); + } + }); + ctx.logger?.debug?.('Registered current-user endpoints', { prefix }); + return true; +} diff --git a/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts b/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts index 55ace31b4e..0ba9e553c8 100644 --- a/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts +++ b/packages/plugins/plugin-hono-server/src/effective-api-operations.test.ts @@ -5,7 +5,7 @@ import { annotateEffectiveApiOperations, seedSuperUserRestrictedObjects, type ApiExposureSchemaLike, -} from './hono-plugin.js'; +} from './current-user-endpoints.js'; /** * #3391 — the `/me/permissions` per-object map carries the server-resolved diff --git a/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts b/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts index 6a54967808..0b57b10b48 100644 --- a/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts +++ b/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts @@ -1,7 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { foldWildcardSuperUser, clampManagedObjectWrites, type ManagedSchemaLike } from './hono-plugin.js'; +import { foldWildcardSuperUser, clampManagedObjectWrites, type ManagedSchemaLike } from './current-user-endpoints.js'; /** * ADR-0057 D10 / ADR-0092 D5 — the `/me/permissions` per-object FLS map must diff --git a/packages/plugins/plugin-hono-server/src/hono-current-user-endpoints.test.ts b/packages/plugins/plugin-hono-server/src/hono-current-user-endpoints.test.ts index 7995ee9594..81d855593f 100644 --- a/packages/plugins/plugin-hono-server/src/hono-current-user-endpoints.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-current-user-endpoints.test.ts @@ -17,7 +17,9 @@ // endpoints are registered whatever it says. import { describe, it, expect, vi } from 'vitest'; +import { Hono } from 'hono'; import { HonoServerPlugin } from './hono-plugin'; +import { currentUserRoutePaths, registerCurrentUserEndpoints } from './current-user-endpoints'; const ME_ROUTES = [ '/api/v1/auth/me/permissions', @@ -116,3 +118,110 @@ describe('current-user endpoints are not gated by registerStandardEndpoints (#40 expect(firstMe).toBeLessThan(firstData); }); }); + +// cloud#924 — #4079 freed these three from the wrong flag, but left the SUPPLY +// welded to this plugin. A host that stands up a bare `HonoHttpServer` instead +// of mounting the plugin got no provider at all: that is cloud's default +// (Vercel/serverless) `bootKernel` branch, whose `OS_NODE_SERVE=1` sibling +// mounts the real plugin — so the console's whole permission layer had a +// server-side answer on one startup path and a 404 on the other. Registration +// needs a Hono app and a service locator, not ownership of the socket, so the +// registrar is exported and both shapes call it. + +/** A minimal locator: no services wired, so handlers take their anon branch. */ +function bareCtx() { + return { + logger: { debug() {}, warn() {} }, + getService: vi.fn(() => undefined), + }; +} + +describe('registerCurrentUserEndpoints is usable without the plugin (cloud#924)', () => { + it('mounts and answers all three on a bare Hono app', async () => { + const app = new Hono(); + + expect(registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx() })).toBe(true); + + for (const route of ME_ROUTES) expect(paths(app)).toContain(route); + // Answering is the point — before this, the serverless branch 404'd. + const permissions = await app.request('http://localhost/api/v1/auth/me/permissions'); + expect(permissions.status).toBe(200); + expect(await permissions.json()).toEqual({ authenticated: false }); + const localization = await app.request('http://localhost/api/v1/auth/me/localization'); + expect(localization.status).toBe(200); + expect(await localization.json()).toEqual({ authenticated: false }); + const apps = await app.request('http://localhost/api/v1/me/apps'); + expect(apps.status).toBe(200); + expect(await apps.json()).toEqual({ apps: [] }); + }); + + it('honours a non-default prefix', () => { + const app = new Hono(); + + registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx(), prefix: '/api/v2' }); + + expect(paths(app)).toEqual(expect.arrayContaining(currentUserRoutePaths('/api/v2'))); + expect(paths(app)).not.toContain('/api/v1/auth/me/permissions'); + }); + + it('is idempotent — a second call registers nothing', () => { + const app = new Hono(); + + expect(registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx() })).toBe(true); + expect(registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx() })).toBe(false); + + for (const route of ME_ROUTES) { + expect(paths(app).filter((p) => p === route)).toHaveLength(1); + } + }); + + it('re-registers the rest when a host owns only one of the three', () => { + // `every`, not `some`: treating one host-owned path as "already provided" + // would silently drop the other two. + const app = new Hono(); + app.get('/api/v1/me/apps', (c) => c.json({ apps: ['host-owned'] })); + + expect(registerCurrentUserEndpoints({ rawApp: app, ctx: bareCtx() })).toBe(true); + + for (const route of ME_ROUTES) expect(paths(app)).toContain(route); + }); +}); + +describe('a host that pre-registers AND mounts the plugin gets ONE registration', () => { + /** + * cloud's `bootKernel` reaches the raw app before `kernel.bootstrap()`, so a + * host call lands ahead of the plugin's `kernel:ready` hook. The host's + * registration must win (it is the one that can see the host's own service + * graph) and the plugin must not append dead duplicates behind it. + */ + it('the host wins and the plugin adds no duplicate', async () => { + const plugin = new HonoServerPlugin({ port: 0, cors: false }); + const rawApp = (plugin as any).server.getRawApp(); + const readyHooks: Array<() => unknown> = []; + const ctx: any = { + logger: { info() {}, debug() {}, warn() {}, error() {} }, + getKernel: () => ({ hasPlugin: () => false, getService: () => undefined }), + registerService: () => {}, + hook: (event: string, fn: () => unknown) => { + if (event === 'kernel:ready') readyHooks.push(fn); + }, + getService: vi.fn(() => undefined), + }; + + // Host pre-registers with a recognizable body, the way cloud's bare + // `HonoHttpServer` branch does before any plugin is used. + rawApp.get('/api/v1/auth/me/permissions', (c: any) => c.json({ from: 'host' })); + rawApp.get('/api/v1/auth/me/localization', (c: any) => c.json({ from: 'host' })); + rawApp.get('/api/v1/me/apps', (c: any) => c.json({ from: 'host' })); + + await plugin.init(ctx); + await plugin.start(ctx); + for (const fn of readyHooks) await fn(); + + for (const route of ME_ROUTES) { + expect(paths(rawApp).filter((p) => p === route), route).toHaveLength(1); + } + const res = await rawApp.request('http://localhost/api/v1/auth/me/permissions'); + expect(await res.json()).toEqual({ from: 'host' }); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/hono-discovery.test.ts b/packages/plugins/plugin-hono-server/src/hono-discovery.test.ts index 5a7a70012f..fd94d3e11a 100644 --- a/packages/plugins/plugin-hono-server/src/hono-discovery.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-discovery.test.ts @@ -14,6 +14,7 @@ import { describe, it, expect } from 'vitest'; import { HonoServerPlugin } from './hono-plugin'; +import { registerCurrentUserEndpoints } from './current-user-endpoints'; const REST_API_PLUGIN = 'com.objectstack.rest.api'; const RUNTIME_DISPATCHER_PLUGIN = 'com.objectstack.runtime.dispatcher'; @@ -40,9 +41,10 @@ function bootStandardEndpoints(installedPlugins: string[] = []) { // the CRUD + discovery surface only under `registerStandardEndpoints`. // Discovery is computed from what is really mounted, so a boot that skipped // the `/auth/me/*` helpers would under-report `routes.auth`. - (plugin as any).registerCurrentUserEndpoints(ctx); + const rawApp = (plugin as any).server.getRawApp(); + registerCurrentUserEndpoints({ rawApp, ctx }); (plugin as any).registerDiscoveryAndCrudEndpoints(ctx); - return (plugin as any).server.getRawApp(); + return rawApp; } async function discoveryRoutes(app: any): Promise> { diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 42f8be23f8..96115bca6c 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -3,19 +3,15 @@ import { Plugin, PluginContext, IDataEngine, shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, - derivePosture, } from '@objectstack/core'; import { RestServerConfig, type ApiRoutes, } from '@objectstack/spec/api'; -import { ADMIN_FULL_ACCESS, ORGANIZATION_ADMIN_GRANTS } from '@objectstack/spec'; import { - resolveEffectiveApiMethods, - effectiveOperationsArray, - type EnableLike, -} from '@objectstack/spec/data'; -import type { ExecutionContext } from '@objectstack/spec/kernel'; + makeExecutionContextResolver, + registerCurrentUserEndpoints, +} from './current-user-endpoints'; import { HonoHttpServer, HonoCorsOptions } from './adapter'; import { cors } from 'hono/cors'; import { serveStatic } from '@hono/node-server/serve-static'; @@ -117,55 +113,6 @@ export interface HonoPluginOptions { serverTiming?: boolean; } -/** - * Hono Server Plugin - * - * Provides HTTP server capabilities using Hono framework. - * Registers the IHttpServer service so other plugins can register routes. - * - * Route registration is handled by plugins: - * - `@objectstack/rest` → CRUD, metadata, discovery, UI, batch - * - `createDispatcherPlugin()` → auth, graphql, analytics, packages, etc. - */ - -/** - * Fold the `'*'` wildcard super-user grant into every per-object entry of a - * `/me/permissions` `objects` map, mutating it in place. - * - * The endpoint merges each resolved permission set's explicit `objects` entries - * most-permissively per key, but treats `'*'` and named objects as independent - * keys — so a wildcard "Modify/View All Data" grant is never propagated into a - * per-object entry another set explicitly denied. That makes the client's - * per-object FLS STRICTER than the server's actual enforcement - * (`PermissionEvaluator.checkObjectPermission`, which returns allow as soon as - * ANY set grants — including via the `'*'` modifyAll/viewAll super-user bypass, - * with no deny-wins). The mismatch surfaces for a platform admin - * (`admin_full_access` `'*': {modifyAllRecords}`) who ALSO holds - * `organization_admin` (which denies writes on identity tables): the client - * would see `sys_user.allowEdit:false` and disable a form the server accepts - * (verified: `PATCH /data/sys_user {name}` → 200). ADR-0057 D10 makes the - * server the authoritative gate; the client must mirror it, never diverge. - * - * The super-user grant covers private/managed objects on the server, so folding - * it here is exactly as broad as real enforcement — never broader. - */ -export function foldWildcardSuperUser(objects: Record): void { - const wild = objects?.['*']; - if (!wild) return; - const superRead = wild.viewAllRecords === true || wild.modifyAllRecords === true; - const superWrite = wild.modifyAllRecords === true; - if (!superRead && !superWrite) return; - for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { - if (obj === '*' || !acc) continue; - if (superRead) acc.allowRead = true; - if (superWrite) { - acc.allowEdit = true; - acc.allowCreate = true; - acc.allowDelete = true; - } - } -} - /** * How much per-request timing the caller opted into via `X-OS-Debug-Timing`: * - `off` — no header sent (or an unrecognized value). @@ -240,150 +187,6 @@ export function buildTimingDetail(timing: PerfTiming): string { return JSON.stringify(payload); } -/** Minimal schema shape the managed-write clamp needs. */ -export interface ManagedSchemaLike { - managedBy?: string; - userActions?: { - create?: boolean; - // edit/delete accept the #2614 object form ({ enabled, visibleWhen, - // disabledWhen }); only the object-level `enabled` matters here — the - // per-record predicates are UI gating, not a permission grant. - edit?: boolean | { enabled?: boolean }; - delete?: boolean | { enabled?: boolean }; - } | null; -} - -/** True only when a userActions flag (bare boolean or object form) explicitly opts the write in. */ -function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null): boolean { - return v === true || (typeof v === 'object' && v !== null && v.enabled === true); -} - -/** - * Buckets whose user-context generic writes are guarded fail-closed at the - * engine: `better-auth` by plugin-auth's identity write guard (ADR-0092 D2), - * `engine-owned` / `system` / `append-only` by plugin-security's engine-owned - * write guard (ADR-0103). `config` / `platform` have no such guard — their - * permission-set result stands. - */ -const GUARDED_WRITE_BUCKETS: ReadonlySet = new Set(['better-auth', 'system', 'engine-owned', 'append-only']); - -/** - * Re-clamp a `/me/permissions` `objects` map by the SECOND server-side - * enforcement layer that permission sets don't model: the engine write guards. - * They fail-closed reject USER-CONTEXT insert/update/delete on every managed - * object whose resolved affordances forbid the verb — `better-auth` - * (ADR-0092 D2) and `system`/`append-only` (ADR-0103) — except where the object - * opted the write affordance in via `userActions.{create,edit,delete}` (e.g. - * sys_user opens `edit` for its profile fields; the RBAC link tables / prefs / - * messaging config open their CRUD). - * - * Without this clamp, {@link foldWildcardSuperUser} would report `allowEdit:true` - * for a platform admin on tables the guard actually blocks (sys_member, - * sys_automation_run, …) — a false-POSITIVE that mirrors, inverted, the - * false-negative the fold fixes. The real effective answer for a user-context - * caller is `permission-set grant ∩ guard policy`, and the guard policy for a - * guarded object is exactly its resolved CRUD affordance. `config`/`platform` - * objects are NOT clamped — no guard covers them, so their permission-set result - * stands (an admin CAN write them via the data API, and the hint must not - * under-report that). - */ -export function clampManagedObjectWrites( - objects: Record, - schemaOf: (objectName: string) => ManagedSchemaLike | undefined, -): void { - for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { - if (obj === '*' || !acc) continue; - const schema = schemaOf(obj); - if (!schema?.managedBy || !GUARDED_WRITE_BUCKETS.has(schema.managedBy)) continue; - const ua = schema.userActions ?? {}; - if (!isWriteOptedIn(ua.edit)) acc.allowEdit = false; - if (ua.create !== true) acc.allowCreate = false; - if (!isWriteOptedIn(ua.delete)) acc.allowDelete = false; - } -} - -/** The API-exposure-relevant slice of a registered object schema. */ -export interface ApiExposureSchemaLike { - name?: string; - enable?: EnableLike | null; -} - -/** - * [#3391] Seed false-initialized per-object entries for a MODIFY-ALL super-user, - * for every registered object whose `apiMethods` whitelist tightens exposure. - * - * A super-user's grant is usually the `'*'` wildcard, not explicit per-object - * entries — so restricting objects never appear in the merged `objects` map and - * would miss their `apiOperations` annotation. Seeding a `{allow*: false}` entry - * lets {@link foldWildcardSuperUser} pull it true (super-user reads/writes - * everything) and lets {@link annotateEffectiveApiOperations} attach the effective - * set. Runs BEFORE fold. - * - * Guarded to `modifyAllRecords` super-users ONLY: for a viewAll-only caller, - * materializing a `false` entry would flip the client's `check('edit')` from - * "undefined → default-allow" to "explicit false → deny" — a scope-exceeding - * behavior change. A modify-all caller is folded to `true` anyway, so seeding is - * harmless there. Unrestricted objects are skipped (they carry no annotation). - */ -export function seedSuperUserRestrictedObjects( - objects: Record, - allSchemas: readonly ApiExposureSchemaLike[], -): void { - if (objects?.['*']?.modifyAllRecords !== true) return; - for (const schema of allSchemas) { - const name = schema?.name; - if (!name || name === '*' || objects[name]) continue; - const eff = resolveEffectiveApiMethods(schema.enable ?? undefined); - if (eff.mode === 'unrestricted') continue; // only restricting objects - objects[name] = { allowCreate: false, allowRead: false, allowEdit: false, allowDelete: false }; - } -} - -/** - * [#3391] Annotate each per-object `/me/permissions` entry with the SERVER's - * effective API operation set (`apiOperations`), mutating the map in place. - * - * This is the single "effective" channel the frontend consumes — it renders the - * operations the server hands down here, never the raw `apiMethods` whitelist. - * Only objects whose whitelist actually tightens exposure are annotated (a - * `deny-all` object gets an empty array; an unrestricted object gets nothing, so - * the client keeps its default-allow behavior). Runs AFTER fold + clamp so the - * annotation sits alongside the final CRUD affordances. - */ -export function annotateEffectiveApiOperations( - objects: Record, - schemaOf: (objectName: string) => ApiExposureSchemaLike | undefined, -): void { - // [#3544] The `'*'` entry's export grant is the FALLBACK for objects that do - // not carry one of their own. The merge keeps `'*'` and named objects as - // independent keys, but the server evaluator does not: its - // `resolveObjectPermission` falls back to the wildcard whenever a set has no - // explicit entry for the object, so an admin set granting export wholesale - // via `'*': { allowExport: true }` really does grant it per-object. Reading - // the wildcard here keeps the button the client shows and the request the - // server accepts in agreement — the same class of client/server divergence - // `foldWildcardSuperUser` exists to close, on the export axis. - const wildExport = objects?.['*']?.allowExport; - for (const [obj, acc] of Object.entries(objects) as Array<[string, any]>) { - if (obj === '*' || !acc) continue; - const schema = schemaOf(obj); - if (!schema) continue; // schema missing → no annotation (client falls back) - // [#3544] User-level export axis: `export` derives from `list ∧ this - // grant`. OPT-IN — only an explicit `true` (on the object entry, else - // inherited from `'*'`) allows export; unset and `false` both withhold - // it, and the super-user bits do NOT imply it. - const exportBit = acc.allowExport ?? wildExport; - const userExportAllowed = exportBit === true; - const eff = resolveEffectiveApiMethods(schema.enable ?? undefined, { userExportAllowed }); - // Annotate when the object tightens via `apiMethods`, OR when the export - // axis removes `export` from an otherwise-open object (so the client - // hides the Export button). An unrestricted object with export still - // allowed needs no annotation — the client keeps its default-allow path. - if (eff.mode === 'unrestricted' && userExportAllowed) continue; - acc.apiOperations = effectiveOperationsArray(eff); - } -} - /** * The two plugins that own a REAL, computed `/discovery` (ADR-0076 D11 / OQ#9). * `@objectstack/rest` serves `metadata-protocol`'s registry-driven `getDiscovery()`; @@ -422,6 +225,21 @@ const DISCOVERY_ROUTE_SEGMENTS: Partial> = { ui: 'ui', }; +/** + * Hono Server Plugin + * + * Provides HTTP server capabilities using Hono framework. + * Registers the IHttpServer service so other plugins can register routes. + * + * Route registration is handled by plugins: + * - `@objectstack/rest` → CRUD, metadata, discovery, UI, batch + * - `createDispatcherPlugin()` → auth, graphql, analytics, packages, etc. + * + * The current-user endpoints (`/auth/me/permissions`, `/auth/me/localization`, + * `/me/apps`) are this plugin's own, and are the platform's only supply — see + * `./current-user-endpoints`, which exports the registrar so a host serving a + * bare {@link HonoHttpServer} instead of this plugin can supply them too. + */ export class HonoServerPlugin implements Plugin { name = 'com.objectstack.server.hono'; type = 'server'; @@ -775,15 +593,22 @@ export class HonoServerPlugin implements Plugin { // other plugins' route registrations. // // The current-user endpoints go first and are NOT gated (#4073): this - // plugin is their only provider on any host, so they must not depend on - // a flag whose stated job is the optional CRUD/discovery convenience - // surface. Registering them ahead of that block also keeps their - // position in the `kernel:ready` order exactly where it was, which + // plugin is their only provider on any host that mounts it, so they must + // not depend on a flag whose stated job is the optional CRUD/discovery + // convenience surface. Registering them ahead of that block also keeps + // their position in the `kernel:ready` order exactly where it was, which // matters: plugin-auth mounts a TERMINAL `rawApp.all('/api/v1/auth/*')` // from its own `kernel:ready` hook, and `/auth/me/*` only wins the match // by being registered first. + // + // The registrar lives in `./current-user-endpoints` and is exported, so a + // host serving a bare `HonoHttpServer` instead of this plugin (cloud's + // Vercel/serverless entrypoints) supplies the same three endpoints from + // the same code (cloud#924). It is idempotent: a host that pre-registers + // them on the raw app AND mounts this plugin gets one registration, the + // host's — this call then no-ops rather than shadowing it. ctx.hook('kernel:ready', async () => { - this.registerCurrentUserEndpoints(ctx); + registerCurrentUserEndpoints({ rawApp: this.server.getRawApp(), ctx }); }); if (this.options.registerStandardEndpoints) { @@ -967,8 +792,8 @@ export class HonoServerPlugin implements Plugin { const getObjectQL = () => ctx.getService('objectql'); // Session → ExecutionContext. Shared with the always-registered - // current-user endpoints below, which resolve the same principal. - const resolveCtx = this.makeExecutionContextResolver(ctx); + // current-user endpoints, which resolve the same principal. + const resolveCtx = makeExecutionContextResolver(ctx); // Create rawApp.post(`${prefix}/data/:object`, async (c: any) => { @@ -1037,565 +862,6 @@ export class HonoServerPlugin implements Plugin { ctx.logger.debug('Registered standard CRUD data endpoints', { prefix }); } - /** - * Build the session → `ExecutionContext` resolver both route groups need. - * - * Extracted from `registerDiscoveryAndCrudEndpoints` when the current-user - * endpoints stopped being gated on `registerStandardEndpoints` (#4073): they - * resolve the same principal the `/data` routes do, and one resolver is the - * only way the two groups can agree on who the caller is. - */ - private makeExecutionContextResolver(ctx: PluginContext) { - const getObjectQL = () => ctx.getService('objectql'); - // Helper: resolve ExecutionContext from request headers (cookie session - // or API key). Mirrors the runtime's resolveExecutionContext but - // self-contained to avoid a cross-package dep. We DO query the - // `sys_user_permission_set` link tables because hardcoding a single - // permission set name (e.g. `member_default`) would silently ignore - // any explicit admin / role assignment — including the platform-admin - // promotion seeded by `bootstrapPlatformAdmin`. - const resolveCtx = async (c: any): Promise => { - try { - const authService: any = ctx.getService('auth'); - if (!authService) return undefined; - let api: any = authService.api; - if (!api && typeof authService.getApi === 'function') { - api = await authService.getApi(); - } - if (!api?.getSession) return undefined; - const session = await api.getSession({ headers: c.req.raw.headers }); - if (!session?.user?.id) return undefined; - const userId = session.user.id; - const tenantId = session.session?.activeOrganizationId ?? undefined; - const permissions: string[] = []; - const roles: string[] = []; - try { - const ql = getObjectQL(); - const sysCtx = { context: { isSystem: true } }; - // Roles via sys_member (org-scoped if active org). - const memberRows = await ql?.find?.( - 'sys_member', - { - where: tenantId - ? { user_id: userId, organization_id: tenantId } - : { user_id: userId }, - limit: 50, - ...sysCtx, - } as any, - ).catch(() => []); - for (const m of (memberRows ?? []) as any[]) { - if (typeof m.role === 'string') { - for (const r of m.role.split(',').map((s: string) => s.trim()).filter(Boolean)) { - if (!roles.includes(r)) roles.push(r); - } - } - } - // User-scoped permission sets — match BOTH (a) the active - // org's link rows and (b) the cross-tenant rows - // (organization_id IS NULL) so the platform-admin - // promotion seeded by `bootstrapPlatformAdmin` applies - // regardless of the user's active org. - const upsRows = await ql?.find?.( - 'sys_user_permission_set', - { where: { user_id: userId }, limit: 100, ...sysCtx } as any, - ).catch(() => []); - const psIds = new Set(); - for (const r of (upsRows ?? []) as any[]) { - const orgScope = r.organization_id ?? null; - if (!orgScope || (tenantId && orgScope === tenantId)) { - const pid = r.permission_set_id ?? r.permissionSetId; - if (pid) psIds.add(pid); - } - } - if (psIds.size > 0) { - const psRows = await ql?.find?.( - 'sys_permission_set', - { where: { id: { $in: Array.from(psIds) } }, limit: 500, ...sysCtx } as any, - ).catch(() => []); - for (const ps of (psRows ?? []) as any[]) { - if (ps.name && !permissions.includes(ps.name)) permissions.push(ps.name); - } - } - } catch { - /* fall through with whatever we resolved so far */ - } - // Resolve fellow-org user IDs so identity-table RLS (sys_user - // org-members policy) can scope @-mention pickers, owner - // lookups and reviewer selectors to the active organization. - // Mirrors the resolvers in `@objectstack/rest` and - // `@objectstack/runtime` so all three REST entry-points - // produce a consistent ExecutionContext shape. - let orgUserIds: string[] = [userId]; - if (tenantId) { - try { - const ql = getObjectQL(); - const sysCtx = { context: { isSystem: true } }; - const memberRows = await ql?.find?.( - 'sys_member', - { where: { organization_id: tenantId }, limit: 1000, ...sysCtx } as any, - ).catch(() => []); - const ids = new Set([userId]); - for (const m of (memberRows ?? []) as any[]) { - const uid = m.user_id ?? m.userId; - if (typeof uid === 'string' && uid.length > 0) ids.add(uid); - } - orgUserIds = Array.from(ids); - } catch { - /* fall back to self-only */ - } - } - // [ADR-0105 D2] The caller's org access set — the `group` - // posture's Layer 0 wall is `organization_id IN (...)`, so a - // context without it fails every read closed on this surface. - // Resolved from the user's OWN memberships (all organizations, - // not the active one). This standalone resolver duplicates the - // canonical `resolveAuthzContext` by design (see the posture - // note below); the duplication is tracked by - // `scripts/check-single-authz-resolver.mjs`. - let accessibleOrgIds: string[] = []; - try { - const ql = getObjectQL(); - const sysCtx = { context: { isSystem: true } }; - const myMemberships = await ql?.find?.( - 'sys_member', - { where: { user_id: userId }, limit: 200, ...sysCtx } as any, - ).catch(() => []); - const orgIds = new Set(); - for (const m of (myMemberships ?? []) as any[]) { - const oid = m.organization_id ?? m.organizationId; - if (typeof oid === 'string' && oid.length > 0) orgIds.add(oid); - } - accessibleOrgIds = Array.from(orgIds); - } catch { - /* no memberships resolvable → empty set → fails closed */ - } - // 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(() => []); - // Turso returns sqlite booleans as 1/0; memory driver as boolean. - const aiAccess = (uRows?.[0] as any)?.ai_access; - if (aiAccess === true || aiAccess === 1 || aiAccess === '1') permissions.push('ai_seat'); - } catch { - /* no ai_access column / query failed → no seat (safe) */ - } - } - // [#2408 / #3361] Open the per-request `Server-Timing` disclosure - // gate for an admin/service principal — the standalone-surface analog - // of the runtime dispatcher's `timedResolveExecutionContext`. This - // self-contained resolver derives no posture rung, so derive one HERE, - // for the gate decision ONLY, from the resolved permission-set grants, - // and hand it to the shared `isPerfDisclosurePrincipal` predicate. The - // rung is computed onto a THROW-AWAY object, never the returned - // context: `ctx.posture` is an enforcement input (Layer 0 tier - // adjudication, ADR-0099 D1) and only the authoritative resolver may - // set it. A no-op when perf-tuning is off (no ambient gate). - const disclosurePosture = derivePosture({ - isPlatformAdmin: permissions.includes(ADMIN_FULL_ACCESS), - isTenantAdmin: ORGANIZATION_ADMIN_GRANTS.some((n) => permissions.includes(n)), - }); - if (isPerfDisclosurePrincipal({ isSystem: false, posture: disclosurePosture } as ExecutionContext)) { - allowPerfDisclosure(); - } - return { - userId, - tenantId, - roles, - permissions, - isSystem: false, - org_user_ids: orgUserIds, - accessible_org_ids: accessibleOrgIds, - } as any; - } catch { - return undefined; - } - }; - return resolveCtx; - } - - /** - * Current-user endpoints — `/auth/me/permissions`, `/auth/me/localization` - * and `/me/apps`. Registered UNCONDITIONALLY, unlike the CRUD + discovery - * block above (#4073). - * - * They used to ride on `registerStandardEndpoints`, which conflated two - * unrelated things. That flag covers DUPLICATE supply — raw `/data` CRUD - * that `@objectstack/rest` also serves (and, being registered first, really - * serves), plus a discovery that the dispatcher/REST own (#4018). These - * three are the opposite: nothing else in the platform mounts them. - * `packages/rest` and `packages/runtime` register no `/me/*` route at all, - * the console reads `/auth/me/permissions` for its whole permission layer - * and `/auth/me/localization` for regional defaults, and - * `core/security/auth-gate.ts` allow-lists `/me/apps` + `/me/localization` - * as endpoints a gated user MUST still reach to bootstrap the remediation - * UI. `os serve` gets them only because `registerStandardEndpoints` - * defaults to true (`cli/src/commands/serve.ts` passes just `{ port }`), so - * turning that flag off — or retiring the convenience surface it names — - * would have taken the console down with it. - * - * Splitting them out is what makes that flag mean what it says, and is the - * precondition for retiring the duplicate half. - */ - private registerCurrentUserEndpoints(ctx: PluginContext) { - const rawApp = this.server.getRawApp(); - const prefix = '/api/v1'; - const resolveCtx = this.makeExecutionContextResolver(ctx); - - // Effective permissions for the current user — single aggregation - // endpoint that resolves session → roles → permission sets → merged - // field/object permissions. Frontend Field-Level Security (FLS) - // consumes this to gate form fields / list columns without having - // to replicate the server's role+permission-set resolution and - // most-permissive merge logic. - // - // Response shape (designed to mirror @object-ui/permissions - // expectations — see `PermissionSet` in @objectstack/spec): - // { - // userId, tenantId, roles, permissionSets, - // objects: Record, - // fields: Record<"object.field", { readable, editable }>, - // } - // - // Returns `{authenticated:false}` (200) when no session is - // present, so the frontend can distinguish anon from error. - rawApp.get(`${prefix}/auth/me/permissions`, async (c: any) => { - const execCtx = await resolveCtx(c); - if (!execCtx?.userId) { - return c.json({ authenticated: false }); - } - try { - const metadata: any = ctx.getService('metadata'); - const evaluator: any = ctx.getService('security.permissions'); - const bootstrap: any[] = (() => { - try { return ctx.getService('security.bootstrapPermissionSets') ?? []; } - catch { return []; } - })(); - const fallbackName: string | null = (() => { - try { return ctx.getService('security.fallbackPermissionSet') ?? 'member_default'; } - catch { return 'member_default'; } - })(); - // DB loader: surfaces user-defined permission sets - // (created via the admin UI as `sys_permission_set` - // rows) that aren't in metadata or bootstrap. - const ql: any = (() => { - try { return ctx.getService('objectql'); } catch { return null; } - })(); - const dbLoader = ql - ? async (names: string[]) => { - let rows: any; - try { - rows = await ql.find( - 'sys_permission_set', - { where: { name: { $in: names } }, limit: names.length }, - { context: { isSystem: true } }, - ); - } catch { - rows = []; - } - const list = Array.isArray(rows) ? rows : rows?.records ?? []; - return list.map((r: any) => ({ - name: r.name, - label: r.label, - objects: typeof r.object_permissions === 'string' - ? JSON.parse(r.object_permissions || '{}') - : r.object_permissions ?? {}, - fields: typeof r.field_permissions === 'string' - ? JSON.parse(r.field_permissions || '{}') - : r.field_permissions ?? {}, - // #2752 follow-through: DB-loaded sets used to drop - // their capability + tab columns, so a direct grant - // of e.g. `setup.access` never surfaced here. - systemPermissions: typeof r.system_permissions === 'string' - ? JSON.parse(r.system_permissions || '[]') - : r.system_permissions ?? [], - tabPermissions: typeof r.tab_permissions === 'string' - ? JSON.parse(r.tab_permissions || '{}') - : r.tab_permissions ?? {}, - })); - } - : undefined; - if (!evaluator || !metadata) { - // Auth resolved but security plugin isn't wired — emit - // an empty-but-authenticated body so the frontend can - // fail-open with full access (matches server behaviour - // when SecurityPlugin isn't registered). - return c.json({ - authenticated: true, - userId: execCtx.userId, - tenantId: execCtx.tenantId ?? null, - positions: execCtx.positions ?? [], - permissionSets: execCtx.permissions ?? [], - objects: {}, - fields: {}, - }); - } - // Resolve the same way SecurityPlugin middleware does: - // role names + explicit permission-set names, with a - // fallback to `member_default` when authenticated users - // resolve to zero permission sets (matches the - // post-resolution fallback in security-plugin.ts). - const requested = [ - ...(execCtx.positions ?? []), - ...(execCtx.permissions ?? []), - ]; - let resolved: any[] = await evaluator - .resolvePermissionSets(requested, metadata, bootstrap, dbLoader) - .catch(() => []); - if (resolved.length === 0 && fallbackName) { - resolved = await evaluator - .resolvePermissionSets([fallbackName], metadata, bootstrap, dbLoader) - .catch(() => []); - } - // Most-permissive merge of `objects` and `fields` across - // all resolved permission sets — same semantics as - // PermissionEvaluator.getFieldPermissions but for ALL - // objects in a single pass. - const objects: Record = {}; - const fields: Record = {}; - const systemPermissions = new Set(); - const tabRank: Record = { hidden: 0, default_off: 1, default_on: 2, visible: 3 }; - const tabPermissions: Record = {}; - for (const ps of resolved) { - if (ps?.objects) { - for (const [obj, perm] of Object.entries(ps.objects)) { - const acc = objects[obj] ?? {}; - for (const [k, v] of Object.entries(perm as any)) { - if (v === true) acc[k] = true; - else if (acc[k] === undefined) acc[k] = v; - } - objects[obj] = acc; - } - } - if (ps?.fields) { - for (const [key, perm] of Object.entries(ps.fields)) { - const acc = fields[key] ?? { readable: false, editable: false }; - const p = perm as any; - if (p.readable) acc.readable = true; - if (p.editable) acc.editable = true; - fields[key] = acc; - } - } - if (Array.isArray(ps?.systemPermissions)) { - for (const sp of ps.systemPermissions) { - if (typeof sp === 'string') systemPermissions.add(sp); - } - } - if (ps?.tabPermissions && typeof ps.tabPermissions === 'object') { - for (const [app, val] of Object.entries(ps.tabPermissions as Record)) { - if (typeof val !== 'string' || !(val in tabRank)) continue; - const cur = tabPermissions[app]; - if (!cur || tabRank[val] > tabRank[cur]) { - tabPermissions[app] = val as 'visible' | 'hidden' | 'default_on' | 'default_off'; - } - } - } - } - // Make the client's per-object FLS reflect the server's ACTUAL - // effective enforcement = permission-set grant ∩ identity write - // guard (ADR-0057 D10). (1) Fold the `'*'` super-user grant into - // every object so an admin's wildcard is not shadowed by another - // set's explicit deny; (2) re-clamp `better-auth` managed objects - // by their write affordance, since the guard (ADR-0092 D2) blocks - // user-context writes there except where the object opted in - // (sys_user → edit). Together these remove both the false-negative - // (admin sees sys_user editable) and the false-positive (admin does - // NOT see sys_member editable, matching the guard). - // [#3391] For a modify-all super-user, seed restricting objects - // absent from the merged map so fold pulls them true and annotate - // can attach their effective apiOperations. Guarded — a failure - // here must never drop the whole response. - try { - const allSchemas: ApiExposureSchemaLike[] = (() => { - try { return (ql as any)?.registry?.getAllObjects?.() ?? []; } - catch { return []; } - })(); - seedSuperUserRestrictedObjects(objects, allSchemas); - } catch (e: any) { - ctx.logger.warn('[hono] effective apiOperations seed failed', { err: e?.message }); - } - foldWildcardSuperUser(objects); - clampManagedObjectWrites(objects, (name) => { - try { return ql?.getSchema?.(name) as ManagedSchemaLike | undefined; } - catch { return undefined; } - }); - // [#3391] Annotate the per-object effective API operation set — - // the single channel the frontend consumes for effective ops. - // Guarded: on failure we simply omit apiOperations and the client - // falls back to its default-allow behavior. - try { - annotateEffectiveApiOperations(objects, (name) => { - try { return ql?.getSchema?.(name) as ApiExposureSchemaLike | undefined; } - catch { return undefined; } - }); - } catch (e: any) { - ctx.logger.warn('[hono] effective apiOperations annotate failed', { err: e?.message }); - } - return c.json({ - authenticated: true, - userId: execCtx.userId, - tenantId: execCtx.tenantId ?? null, - positions: execCtx.positions ?? [], - permissionSets: resolved.map((p: any) => p?.name).filter(Boolean), - objects, - fields, - systemPermissions: Array.from(systemPermissions), - tabPermissions, - }); - } catch (err: any) { - ctx.logger.warn('[hono] /auth/me/permissions failed', { err: err?.message }); - return c.json({ authenticated: true, userId: execCtx.userId, objects: {}, fields: {} }); - } - }); - - // GET /me/localization — the resolved regional defaults (currency / - // locale / timezone) for the current request's tenant, exposed to EVERY - // authenticated user. The `localization` SETTINGS are gated to - // `setup.access`, but the resolved defaults are needed by every renderer - // to format currency/dates/numbers — so they ride on the request - // ExecutionContext (ADR-0053) and are surfaced here without that gate. - rawApp.get(`${prefix}/auth/me/localization`, async (c: any) => { - const execCtx = await resolveCtx(c); - if (!execCtx?.userId) { - return c.json({ authenticated: false }); - } - return c.json({ - authenticated: true, - currency: execCtx.currency ?? null, - locale: execCtx.locale ?? null, - timezone: execCtx.timezone ?? null, - }); - }); - - // GET /me/apps — list apps the current user is allowed to enter. - // Apps live in the ENGINE REGISTRY (runtime AppPlugin registerApp()), - // not the metadata service — reading `metadata.list('app')` returned - // [] for every principal (#2752), leaving tabPermissions and - // AppSchema.requiredPermissions with no enforced consumer. Source - // from `registry.getAllApps()` (the same authority the meta routes - // use, nav contributions merged), with the metadata service kept as - // an additive fallback for runtime-draft-published apps. Filters: - // 1. AppSchema.requiredPermissions ⊆ ctx.systemPermissions - // 2. ctx.tabPermissions[app.name] !== 'hidden' - // Anonymous users get an empty array. When SecurityPlugin is absent - // we fail-open and return every app (matches server behaviour). - rawApp.get(`${prefix}/me/apps`, async (c: any) => { - const execCtx = await resolveCtx(c); - if (!execCtx?.userId) return c.json({ apps: [] }); - try { - const byName = new Map(); - try { - const registry: any = (ctx.getService('objectql') as any)?._registry; - for (const app of registry?.getAllApps?.() ?? []) { - if (app?.name) byName.set(String(app.name), app); - } - } catch { /* registry unavailable — fall through to metadata */ } - try { - const metadata: any = ctx.getService('metadata'); - for (const app of ((await metadata?.list?.('app')) ?? []) as any[]) { - if (app?.name && !byName.has(String(app.name))) byName.set(String(app.name), app); - } - } catch { /* metadata service optional */ } - // Resolve the caller's effective capability/tab surface the - // same way /auth/me/permissions does — resolveCtx() carries - // neither systemPermissions nor tabPermissions, so filtering - // on execCtx fields silently gated EVERY requiredPermissions - // app away from everyone, including the platform admin. - const sysPerms = new Set(execCtx.systemPermissions ?? []); - const tabs: Record = { ...((execCtx as any).tabPermissions ?? {}) }; - let failOpen = true; - try { - const evaluator: any = ctx.getService('security.permissions'); - failOpen = !evaluator; - if (evaluator) { - const metadata: any = ctx.getService('metadata'); - const bootstrap: any[] = (() => { - try { return ctx.getService('security.bootstrapPermissionSets') ?? []; } - catch { return []; } - })(); - const fallbackName: string | null = (() => { - try { return ctx.getService('security.fallbackPermissionSet') ?? 'member_default'; } - catch { return 'member_default'; } - })(); - const requested = [ - ...((execCtx as any).positions ?? []), - ...((execCtx as any).permissions ?? []), - ]; - const qlSvc: any = (() => { try { return ctx.getService('objectql'); } catch { return null; } })(); - const dbLoader = qlSvc - ? async (names: string[]) => { - let rows: any; - try { - rows = await qlSvc.find( - 'sys_permission_set', - { where: { name: { $in: names } }, limit: names.length }, - { context: { isSystem: true } }, - ); - } catch { rows = []; } - const list = Array.isArray(rows) ? rows : rows?.records ?? []; - return list.map((r: any) => ({ - name: r.name, - systemPermissions: typeof r.system_permissions === 'string' - ? JSON.parse(r.system_permissions || '[]') - : r.system_permissions ?? [], - tabPermissions: typeof r.tab_permissions === 'string' - ? JSON.parse(r.tab_permissions || '{}') - : r.tab_permissions ?? {}, - })); - } - : undefined; - let resolved: any[] = await evaluator - .resolvePermissionSets(requested, metadata, bootstrap, dbLoader) - .catch(() => []); - if (resolved.length === 0 && fallbackName) { - resolved = await evaluator - .resolvePermissionSets([fallbackName], metadata, bootstrap, dbLoader) - .catch(() => []); - } - const tabRank: Record = { hidden: 0, default_off: 1, default_on: 2, visible: 3 }; - for (const ps of resolved) { - for (const sp of (Array.isArray(ps?.systemPermissions) ? ps.systemPermissions : [])) { - if (typeof sp === 'string') sysPerms.add(sp); - } - if (ps?.tabPermissions && typeof ps.tabPermissions === 'object') { - for (const [app, val] of Object.entries(ps.tabPermissions as Record)) { - if (typeof val !== 'string' || !(val in tabRank)) continue; - const cur = tabs[app]; - if (!cur || tabRank[val] > (tabRank[cur] ?? -1)) tabs[app] = val; - } - } - } - } - } catch { failOpen = true; } - const apps = [...byName.values()].filter((app: any) => { - if (tabs[app.name] === 'hidden') return false; - if (failOpen) return true; - const req: string[] = Array.isArray(app.requiredPermissions) ? app.requiredPermissions : []; - return req.every((p) => sysPerms.has(p)); - }); - return c.json({ apps }); - } catch (err: any) { - ctx.logger.warn('[hono] /me/apps failed', { err: err?.message }); - return c.json({ apps: [] }); - } - }); - ctx.logger.debug('Registered current-user endpoints', { prefix }); - } - /** * Destroy phase - Stop server */ diff --git a/packages/plugins/plugin-hono-server/src/index.ts b/packages/plugins/plugin-hono-server/src/index.ts index 776404ee78..3909807a84 100644 --- a/packages/plugins/plugin-hono-server/src/index.ts +++ b/packages/plugins/plugin-hono-server/src/index.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. export * from './hono-plugin'; +export * from './current-user-endpoints'; export * from './adapter'; export * from './pattern-matcher'; export * from './route-pattern';