From 348b11ba7d5c22a0fdc14b2f34803612d2491c87 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:00:09 +0000 Subject: [PATCH 1/2] wip(plugin-hono-server): pins and readers for the /auth/me door on the one composition, before the door moves The door itself is unchanged in this commit; the next one converts it. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- .changeset/23217-s5-auth-me-door.md | 12 + .../security/authz-store-unavailable.test.ts | 17 +- ...endpoints.request-context-door.pin.test.ts | 261 ++++++++++++++++ ...ser-endpoints.resolver-wirings.pin.test.ts | 292 ++++++++++++++++++ ...uest-execution-context.conformance.test.ts | 166 +++++++++- scripts/check-single-authz-resolver.mjs | 1 + 6 files changed, 738 insertions(+), 11 deletions(-) create mode 100644 .changeset/23217-s5-auth-me-door.md create mode 100644 packages/plugins/plugin-hono-server/src/current-user-endpoints.request-context-door.pin.test.ts create mode 100644 packages/plugins/plugin-hono-server/src/current-user-endpoints.resolver-wirings.pin.test.ts diff --git a/.changeset/23217-s5-auth-me-door.md b/.changeset/23217-s5-auth-me-door.md new file mode 100644 index 00000000000..aaf58d6b9e1 --- /dev/null +++ b/.changeset/23217-s5-auth-me-door.md @@ -0,0 +1,12 @@ +--- +'@objectstack/plugin-hono-server': patch +--- + +The `/auth/me` endpoints resolve their caller through `@objectstack/core`'s `resolveRequestExecutionContext`, the one request-to-`ExecutionContext` composition + +Clause-②: no + +- `makeExecutionContextResolver`, which `GET /auth/me/permissions`, `GET /auth/me/localization` and `GET /me/apps` call, resolves the caller by calling `resolveRequestExecutionContext` with this door's differences as named parameters: `anonymous: 'fail-closed'`, `authGate: 'withhold'`, `credentials: 'session-only'`, `sessionApi: 'required'`, `session: 'request'`, no tenancy posture, `localization: 'withhold'`, no request locale, `accessToken: 'withhold'`, and no OAuth provenance or admission channel. The per-request service reads, the environment-kernel resolution and the fail-closed `catch` stay at the door. Every platform door that resolves a request's caller now calls the one composition. +- One answer changes. When the auth service's session read carries `session.userId` but no `user.id` (no user object, a user without an id, or `user.id: null`), these endpoints answered as signed out; they now answer as signed in as that user, which is how the runtime dispatcher, REST, the settings routes and the storage download already read it. No session producer yields that shape: in this repository better-auth's session read returns no session when the session's user is missing and plugin-auth passes its read through, and cloud's control plane uses the same plugin-auth read. +- Every other answer is unchanged: no credential, an unrecognised cookie, a failing session read, an API key alone and a host with no auth service get each route's signed-out body; a session the authentication policy holds is still served on these allow-listed routes, with no `authGate` on its context; an unreadable permission store is still the host's `503 SERVICE_UNAVAILABLE`. +- Nothing to do for a consumer: no export, route, configuration key or response shape changed. diff --git a/packages/core/src/security/authz-store-unavailable.test.ts b/packages/core/src/security/authz-store-unavailable.test.ts index cbbaa4be237..817064db4b4 100644 --- a/packages/core/src/security/authz-store-unavailable.test.ts +++ b/packages/core/src/security/authz-store-unavailable.test.ts @@ -183,14 +183,14 @@ const TRANSPORT_LEDGER: Record = { // letter A). It catches nothing around the resolver: every door that calls // it — the runtime dispatcher's `resolve-execution-context.ts` first, REST's // `rest-server.ts` second, the settings door's `settings-service-plugin.ts` - // third and the storage download door's `storage-service-plugin.ts` fourth, - // each of the last three through a fail-closed `catch` that re-raises the - // outage — receives the outage and answers it at its own net. + // third, the storage download door's `storage-service-plugin.ts` fourth and + // the `/auth/me` door's `current-user-endpoints.ts` fifth (ruling record + // 6257816853), each of the last four through a fail-closed `catch` that + // re-raises the outage — receives the outage and answers it at its own net. 'packages/core/src/security/request-execution-context.ts': 'propagates', 'packages/mcp/src/plugin.ts': 'propagates', 'packages/services/service-datasource/src/admin-routes.ts': 'guarded', 'packages/plugins/plugin-sharing/src/sharing-plugin.ts': 'guarded', - 'packages/plugins/plugin-hono-server/src/current-user-endpoints.ts': 'guarded', 'packages/cloud-connection/src/marketplace-install-local-plugin.ts': 'guarded', 'packages/cloud-connection/src/cloud-connection-plugin.ts': 'guarded', // The print door: its caller-resolution `catch` relays the outage with its @@ -297,8 +297,15 @@ describe('[#13279] every transport that authorizes through resolveAuthzContext', it('CONTROL: the scanner finds transports at all, and finds THIS repo', () => { // Without this, a broken walk would return [] and the set-equality audit // below would be comparing two empty sets and passing. + // + // [#23217 slice 5] The floor is the ledger's size, 7 rows in 6 packages + // since the last door moved onto the one composition. Set equality below + // already fails a walk that loses any listed file; the floor, with the + // known member asserted next, refuses the one case set equality cannot + // see: a broken walk and a gutted ledger together. Every door now calls + // the composition, so no further conversion moves this number. const found = discoverTransports(); - expect(found.length).toBeGreaterThanOrEqual(8); + expect(found.length).toBeGreaterThanOrEqual(7); // A transport this repo is known to hold. [#23217 slice 2] It named // `rest-server.ts` until that door moved onto the one composition, which // is the call site REST's answers now come from. diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.request-context-door.pin.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.request-context-door.pin.test.ts new file mode 100644 index 00000000000..15cd4f3b774 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.request-context-door.pin.test.ts @@ -0,0 +1,261 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ruling record 6227928430, letter A item 2; ruling record 6257816853, slice + * 5 letter A] The `/auth/me` door, proven on the one request-to- + * `ExecutionContext` composition by this pin plus its existing ones. + * + * The door is `makeExecutionContextResolver`, which all three current-user + * routes call before they answer. It is driven here through the routes + * themselves, `GET /auth/me/permissions`, `GET /auth/me/localization` and + * `GET /me/apps`, on the real `HonoServerPlugin` booted through `init()`, + * `start()` and its `kernel:ready` hooks, with no seam captured. Each row reads + * what the routes answered and what reached the security service: the + * context `/auth/me/permissions` and `/me/apps` hand to + * `resolvePermissionSetsForContext` for the caller's sets. + * + * - ADR-0096: a request that resolves no principal is answered by each + * route's own signed-out body, and the security service is never asked: + * no credential, a cookie the auth service does not recognise, a session + * read that throws, an API key alone (this door never opened an API-key + * door), and a host with no auth service. + * - ADR-0069: these routes are allow-listed for a gated user (they + * bootstrap the remediation UI), so a session the authentication policy + * holds is served, and the context the door hands on carries no + * `authGate`. + * - An authorization store that cannot be read is the host's `503`, never a + * signed-out body. + * - The composition's reading (ruling record 6257816853 item 1): a session + * read that carries `session.userId` and no `user.id` is served as that + * user, as at every other door. + * + * The CONTROL row proves the door admits somebody and withholds what it + * declares: an ungated session is served, and its context carries no session + * bearer, locale or time zone, although the session carries a bearer, the + * request a language and the host a settings service. + * + * Every row but the ruled reading's was green on the door's hand-written + * composition; all of them are green on the shared one. + */ + +import { describe, expect, it } from 'vitest'; +import { AUTHZ_STORE_UNAVAILABLE_CODE, AUTHZ_STORE_UNAVAILABLE_STATUS, hashApiKey } from '@objectstack/core'; +import { HonoServerPlugin } from './hono-plugin'; + +const ORG = 'org_alpha'; +const COOKIE = 'better-auth.session_token=tok_auth_me_door.c2ln'; +const HELD_COOKIE = 'better-auth.session_token=tok_auth_me_door_held.c2ln'; +const SESSION_USER_ID_COOKIE = 'better-auth.session_token=tok_auth_me_door_session_user_id.c2ln'; +const STRANGER_COOKIE = 'better-auth.session_token=tok_auth_me_door_unknown.c2ln'; +const KEY = 'osk_auth_me_door_key'; +const FUTURE = '2999-01-01T00:00:00Z'; +const PASSWORD_EXPIRED = { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }; + +const PERMISSIONS = '/api/v1/auth/me/permissions'; +const LOCALIZATION = '/api/v1/auth/me/localization'; +const APPS = '/api/v1/me/apps'; + +/** The one app the engine's registry holds: a signed-in caller is shown it, a signed-out one never is. */ +const APP = { name: 'door_app', label: 'Door App' }; + +interface Host { + /** The auth service: one with a session read, or none at all. */ + auth: 'session' | 'none'; + /** The auth service's session read throws for every request. */ + sessionReadThrows?: true; + /** The permission store's membership read fails. */ + storeDown?: true; +} + +/** + * One deployment: an engine answering the grant tables and holding one app, + * a metadata service, a settings service with a time zone, a security service + * that records every context it is asked about, and the host's auth service. + * A gate is active throughout, so a held session is held by policy. + */ +function services(host: Host, asked: unknown[]): Record { + const tables: Record>> = { + sys_api_key: [{ id: 'k_live', key: hashApiKey(KEY), revoked: false, user_id: 'u_key', active_organization_id: ORG, expires_at: FUTURE }], + sys_member: [ + { user_id: 'u_key', organization_id: ORG, role: 'member' }, + { user_id: 'u_ses', organization_id: ORG, role: 'member' }, + { user_id: 'u_held', organization_id: ORG, role: 'member' }, + ], + }; + const matches = (row: Record, where: Record | undefined) => + Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fixture: unsupported combinator ${k}`); + if (v !== null && typeof v === 'object') { + const list = (v as { $in?: unknown[] }).$in; + if (!Array.isArray(list)) throw new Error(`fixture: unsupported operator on ${k}`); + return list.includes(row[k] ?? null); + } + return (v ?? null) === (row[k] ?? null); + }); + const objectql = { + async find(object: string, q: { where?: Record; limit?: number } = {}) { + if (host.storeDown && object === 'sys_member') throw new Error('connection refused'); + const rows = (tables[object] ?? []).filter((row) => matches(row, q.where)); + return typeof q.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + registry: { getAllApps: () => [APP], getAllObjects: () => [] }, + getSchema: () => undefined, + }; + + const registered: Record = { + objectql, + metadata: { list: async () => [] }, + settings: { + async get(namespace: string, key: string) { + return { value: `${namespace}.${key}` === 'localization.timezone' ? 'Europe/Paris' : undefined }; + }, + }, + security: { + async resolvePermissionSetsForContext(context: unknown) { + asked.push(context); + return []; + }, + }, + }; + if (host.auth === 'session') { + const sessions: Record = { + [COOKIE]: { user: { id: 'u_ses' }, session: { id: 'sess_ses', userId: 'u_ses', token: 'bearer_ses', activeOrganizationId: ORG } }, + [HELD_COOKIE]: { + user: { id: 'u_held', authGate: PASSWORD_EXPIRED }, + session: { id: 'sess_held', userId: 'u_held', token: 'bearer_held', activeOrganizationId: ORG }, + }, + [SESSION_USER_ID_COOKIE]: { session: { id: 'sess_uid', userId: 'u_ses', token: 'bearer_uid', activeOrganizationId: ORG } }, + }; + registered.auth = { + api: { + async getSession(input: { headers: Headers }) { + if (host.sessionReadThrows) throw new Error('session backend unavailable'); + return sessions[input.headers.get('cookie') ?? ''] ?? null; + }, + }, + isAuthGateActive: () => true, + resolveUserAuthGates: async () => [], + }; + } + return registered; +} + +/** Boot the real plugin on `host` and answer the three routes for one request. */ +async function boot(host: Host, headers: Record) { + const asked: unknown[] = []; + const registered: Record = services(host, asked); + const readyHooks: Array<() => Promise | void> = []; + const quiet = { debug() {}, info() {}, warn() {}, error() {}, fatal() {}, child() { return quiet; } }; + const ctx: any = { + logger: quiet, + registerService: (name: string, value: unknown) => { registered[name] = value; }, + // The kernel's locator: an absent slot THROWS. + getService: (name: string): T => { + if (!(name in registered)) throw new Error(`[Kernel] Service '${name}' not found`); + return registered[name] as T; + }, + getKernel: () => ({ hasPlugin: () => false }), + hook: (event: string, fn: () => Promise | void) => { if (event === 'kernel:ready') readyHooks.push(fn); }, + }; + const plugin = new HonoServerPlugin({ port: 0, cors: false }); + await plugin.init(ctx); + await plugin.start(ctx); + for (const fn of readyHooks) await fn(); + const app = (plugin as any).server.getRawApp(); + + const answers: Record = {}; + for (const route of [PERMISSIONS, LOCALIZATION, APPS]) { + const res: Response = await app.request(`http://localhost${route}`, { headers }); + answers[route] = { status: res.status, body: await res.json() }; + } + return { answers, asked: asked as Array> }; +} + +const SIGNED_OUT = { + [PERMISSIONS]: { authenticated: false }, + [LOCALIZATION]: { authenticated: false }, + [APPS]: { apps: [] }, +}; + +const ROWS: Array<{ + name: string; + host: Host; + headers: Record; + /** Each route's own signed-out body, and the security service never asked. */ + signedOut?: true; + /** The host's outage answer on every route, and the security service never asked. */ + outage?: true; + /** Who the security service was asked about, by both routes that ask it. */ + principal?: string; +}> = [ + { name: 'ADR-0096: no credential is signed out on every route', host: { auth: 'session' }, headers: {}, signedOut: true }, + { + name: 'ADR-0096: a cookie the auth service does not recognise is signed out on every route', + host: { auth: 'session' }, headers: { cookie: STRANGER_COOKIE }, signedOut: true, + }, + { + name: 'ADR-0096: a session read that throws resolved no caller, so it is signed out and not an outage', + host: { auth: 'session', sessionReadThrows: true }, headers: { cookie: COOKIE }, signedOut: true, + }, + { + name: 'ADR-0096: an API key alone is signed out, because this door never opened an API-key door', + host: { auth: 'session' }, headers: { 'x-api-key': KEY }, signedOut: true, + }, + { + name: 'ADR-0096: a host with no auth service signs nobody in', + host: { auth: 'none' }, headers: { cookie: COOKIE }, signedOut: true, + }, + { + name: 'ADR-0069: a held session is served on these allow-listed routes, and its context carries no authGate', + host: { auth: 'session' }, headers: { cookie: HELD_COOKIE }, principal: 'u_held', + }, + { + name: "a permission store that cannot be read is the host's 503 on every route, never a signed-out body", + host: { auth: 'session', storeDown: true }, headers: { cookie: COOKIE }, outage: true, + }, + { + name: 'the ruled reading: a session read carrying session.userId and no user.id is served as that user', + host: { auth: 'session' }, headers: { cookie: SESSION_USER_ID_COOKIE }, principal: 'u_ses', + }, + { + name: 'CONTROL: an ungated session is served, and its context carries no session bearer, locale or time zone', + host: { auth: 'session' }, headers: { cookie: COOKIE, 'accept-language': 'zh-CN' }, principal: 'u_ses', + }, +]; + +describe('[ruling 6227928430 item 2, ruling 6257816853] the /auth/me door: the ADR-0096 principal-less refusal and the ADR-0069 allow-list on its three routes', () => { + it.each(ROWS)('$name', async (row) => { + const { answers, asked } = await boot(row.host, row.headers); + + if (row.signedOut) { + for (const [route, body] of Object.entries(SIGNED_OUT)) { + expect(answers[route], route).toEqual({ status: 200, body }); + } + expect(asked).toHaveLength(0); + return; + } + if (row.outage) { + for (const route of [PERMISSIONS, LOCALIZATION, APPS]) { + expect(answers[route]!.status, route).toBe(AUTHZ_STORE_UNAVAILABLE_STATUS); + expect(answers[route]!.body?.error?.code, route).toBe(AUTHZ_STORE_UNAVAILABLE_CODE); + } + expect(asked).toHaveLength(0); + return; + } + + expect(answers[PERMISSIONS]).toMatchObject({ status: 200, body: { authenticated: true, userId: row.principal, tenantId: ORG } }); + expect(answers[LOCALIZATION]).toMatchObject({ status: 200, body: { authenticated: true } }); + expect(answers[APPS]).toEqual({ status: 200, body: { apps: [APP] } }); + // `/auth/me/permissions` and `/me/apps` each ask once, about this caller. + expect(asked).toHaveLength(2); + for (const context of asked) { + expect(context).toMatchObject({ userId: row.principal, tenantId: ORG, principalKind: 'human', isSystem: false }); + // Withheld by this door: the ADR-0069 posture, the session bearer and + // the localization the endpoints resolve for themselves. + expect(context.authGate).toBeUndefined(); + expect(context.accessToken).toBeUndefined(); + expect(context.locale).toBeUndefined(); + expect(context.timezone).toBeUndefined(); + } + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.resolver-wirings.pin.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.resolver-wirings.pin.test.ts new file mode 100644 index 00000000000..e96055a84e4 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.resolver-wirings.pin.test.ts @@ -0,0 +1,292 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ruling record 6257816853, slice 5 letter A] Who the `/auth/me` door names, + * on every wiring of the services it reads. + * + * The door is `makeExecutionContextResolver`, the resolver all three + * current-user routes call and the named export cloud's serverless host + * composes directly (cloud#924). It is driven here through that export, over + * 18 wirings of the auth service, its session read, the data engine and the + * services beside them. Each row pins the door's answer: no context, the + * signed-in caller with the organization it resolves in, or the + * authorization-store outage the host answers as its `503`. + * + * Before ruling record 6257816853 the door composed its own resolution, and a + * measurement of it against `resolveRequestExecutionContext` under this door's + * parameters read the same answer on 15 of these wirings. The other 3 are the + * three session reads that carry `session.userId` and no `user.id`: the + * hand-written door dropped them as signed out, and the composition reads + * `user.id ?? session.userId`. Under the ruling the door calls the composition + * and adopts its reading, so those three now answer signed in as that user, + * and the other 15 keep the answers they had. + * + * Each row also asks the composition, under the parameters this door declares + * ({@link DOOR_PARAMETERS}), and requires the same answer. The one difference + * is the door's own `catch`: the composition propagates every rejection, and + * the door answers each one but the authorization-store outage as no context + * (an auth service whose `getApi()` rejects is the wiring that shows it). + */ + +import { describe, expect, it } from 'vitest'; +import { + hashApiKey, + isAuthzStoreUnavailableError, + resolveRequestExecutionContext, + type RequestExecutionContextInput, +} from '@objectstack/core'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { makeExecutionContextResolver } from './current-user-endpoints'; + +const ORG = 'org_alpha'; +const OTHER_ORG = 'org_other'; +const KEY = 'osk_resolver_wirings_key'; +const FUTURE = '2999-01-01T00:00:00Z'; +const PASSWORD_EXPIRED = { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }; + +/** The parameters this door declares to the composition. */ +const DOOR_PARAMETERS: Omit = { + tenancy: undefined, + anonymous: 'fail-closed', + authGate: 'withhold', + credentials: 'session-only', + sessionApi: 'required', + session: 'request', + localization: 'withhold', + requestLocale: undefined, + accessToken: 'withhold', + oauth: undefined, + onAdmission: undefined, +}; + +type Row = Record; + +/** + * An engine answering the grant tables. `u1` is a member of {@link ORG} only, + * and the key belongs to `u_key`, a member too, so a door that admitted the + * key would name somebody. `failMembers`: the permission store's membership + * read fails (the outage). + */ +function engine(opts: { failMembers?: boolean } = {}) { + const tables: Record = { + sys_api_key: [{ id: 'k1', key: hashApiKey(KEY), revoked: false, user_id: 'u_key', active_organization_id: ORG, expires_at: FUTURE }], + sys_user: [{ id: 'u1', email: 'u1@example.com' }, { id: 'u_key', email: 'key@example.com' }], + sys_member: [ + { user_id: 'u1', organization_id: ORG, role: 'admin' }, + { user_id: 'u_key', organization_id: ORG, role: 'member' }, + ], + sys_user_position: [], + sys_user_permission_set: [], + sys_position: [], + sys_position_permission_set: [], + sys_permission_set: [], + }; + const matches = (row: Row, where: Row | undefined) => Object.entries(where ?? {}).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fixture: unsupported combinator ${k}`); + if (v !== null && typeof v === 'object') { + const list = (v as { $in?: unknown[] }).$in; + if (!Array.isArray(list)) throw new Error(`fixture: unsupported operator on ${k}`); + return list.includes(row[k] ?? null); + } + return (row[k] ?? null) === (v ?? null); + }); + return { + async find(object: string, q: { where?: Row; limit?: number } = {}) { + if (opts.failMembers && object === 'sys_member') throw new Error('connection refused'); + const rows = (tables[object] ?? []).filter((row) => matches(row, q.where)); + return typeof q.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + registry: { getAllApps: () => [], getAllObjects: () => [] }, + getSchema: () => undefined, + }; +} + +/** What the door answers: no context, the caller it names, or the outage. */ +type Answer = 'none' | 'outage' | { userId: string; tenantId: string }; + +interface Wiring { + name: string; + /** How the auth service exposes its session read, if there is one. */ + auth: 'none' | 'no-session-api' | 'api' | 'getApi' | 'getApi-rejects'; + /** What the session read answers (or throws), for every request. */ + session?: () => unknown; + headers?: Record; + failMembers?: true; + /** Services registered beside the auth service; the door reads neither. */ + tenancy?: unknown; + settings?: unknown; + answer: Answer; +} + +const read = (value: unknown) => () => value; +const signedIn = (tenantId: string = ORG): Answer => ({ userId: 'u1', tenantId }); +const SESSION = { user: { id: 'u1', email: 'u1@example.com' }, session: { id: 'sess_1', userId: 'u1', token: 'bearer_1', activeOrganizationId: ORG } }; + +const WIRINGS: Wiring[] = [ + { name: 'no auth service', auth: 'none', answer: 'none' }, + { name: 'an auth service with neither `api` nor `getApi`', auth: 'no-session-api', answer: 'none' }, + { name: "an auth service whose `getApi()` rejects (the door's catch answers it)", auth: 'getApi-rejects', answer: 'none' }, + { name: 'the session read answers null', auth: 'api', session: read(null), answer: 'none' }, + { name: 'the session read answers undefined', auth: 'api', session: read(undefined), answer: 'none' }, + { name: 'the session read throws', auth: 'api', session: () => { throw new Error('session backend unavailable'); }, answer: 'none' }, + { name: 'a full session through `api`', auth: 'api', session: read(SESSION), answer: signedIn() }, + { name: 'a full session through `getApi()`', auth: 'getApi', session: read(SESSION), answer: signedIn() }, + { + name: 'a session the authentication policy holds (ADR-0069): served, and no posture is carried', + auth: 'api', + session: read({ user: { id: 'u1', authGate: PASSWORD_EXPIRED }, session: { userId: 'u1', activeOrganizationId: ORG } }), + answer: signedIn(), + }, + { + name: 'Accept-Language beside a settings service: no locale or time zone is resolved', + auth: 'api', + headers: { 'accept-language': 'zh-CN' }, + settings: { get: async () => ({ value: 'Europe/Paris' }) }, + session: read({ user: { id: 'u1' }, session: { userId: 'u1', activeOrganizationId: ORG } }), + answer: signedIn(), + }, + { + name: "a walled tenancy service: no posture is read, so the session's claimed organization stands", + auth: 'api', + tenancy: { posture: 'isolated' }, + session: read({ user: { id: 'u1' }, session: { userId: 'u1', activeOrganizationId: OTHER_ORG } }), + answer: signedIn(OTHER_ORG), + }, + { name: 'an API key alone: this door opens no API-key door', auth: 'api', headers: { 'x-api-key': KEY }, session: read(null), answer: 'none' }, + { + name: 'an API key beside a session: the session names the caller', + auth: 'api', + headers: { 'x-api-key': KEY }, + session: read({ user: { id: 'u1' }, session: { userId: 'u1', activeOrganizationId: ORG } }), + answer: signedIn(), + }, + { + name: 'a permission store that cannot be read is the outage', + auth: 'api', + failMembers: true, + session: read({ user: { id: 'u1' }, session: { userId: 'u1', activeOrganizationId: ORG } }), + answer: 'outage', + }, + { + name: 'an empty `user.id` beside `session.userId` names nobody', + auth: 'api', + session: read({ user: { id: '' }, session: { userId: 'u1', activeOrganizationId: ORG } }), + answer: 'none', + }, + // The composition's reading (ruling record 6257816853 item 1): a session + // read that carries `session.userId` and no `user.id` names that user. + { + name: 'the ruled reading: `session.userId` with no user names that user', + auth: 'api', + session: read({ session: { userId: 'u1', activeOrganizationId: ORG } }), + answer: signedIn(), + }, + { + name: 'the ruled reading: `session.userId` beside a user with no id names that user', + auth: 'api', + session: read({ user: { email: 'u1@example.com' }, session: { userId: 'u1', activeOrganizationId: ORG } }), + answer: signedIn(), + }, + { + name: 'the ruled reading: `session.userId` beside `user.id: null` names that user', + auth: 'api', + session: read({ user: { id: null }, session: { userId: 'u1', activeOrganizationId: ORG } }), + answer: signedIn(), + }, +]; + +function authService(w: Wiring): unknown { + const api = { getSession: async () => w.session?.() }; + // A gate is active, and no posture holds a user by account: neither moves + // a row, since this door carries no gate. + const beside = { isAuthGateActive: () => true, resolveUserAuthGates: async () => [] }; + switch (w.auth) { + case 'none': return undefined; + case 'no-session-api': return beside; + case 'api': return { api, ...beside }; + case 'getApi': return { getApi: async () => api, ...beside }; + case 'getApi-rejects': return { getApi: async () => { throw new Error('getApi down'); }, ...beside }; + } +} + +/** A call settled into a reading of its own, so a rejection is a reading too. */ +interface Settled { + threw: boolean; + context?: ExecutionContext; + rejected?: unknown; +} + +async function settle(call: () => Promise): Promise { + try { + return { threw: false, context: await call() }; + } catch (err) { + return { threw: true, rejected: err }; + } +} + +describe('[ruling 6257816853] the /auth/me door names the caller on every wiring of the services it reads', () => { + it('declares every wiring once', () => { + expect(WIRINGS).toHaveLength(18); + expect(new Set(WIRINGS.map((w) => w.name)).size).toBe(WIRINGS.length); + }); + + it.each(WIRINGS)('$name', async (w) => { + const ql = engine({ failMembers: w.failMembers }); + const auth = authService(w); + const services: Record = { objectql: ql }; + if (auth !== undefined) services.auth = auth; + if (w.tenancy !== undefined) services.tenancy = w.tenancy; + if (w.settings !== undefined) services.settings = w.settings; + const headers = new Headers(w.headers ?? {}); + + const door = await settle(() => + makeExecutionContextResolver({ getService: (name: string) => services[name] as T | undefined })({ req: { raw: { headers } } }), + ); + const composition = await settle(() => resolveRequestExecutionContext({ + headers, + ql, + authService: auth as RequestExecutionContextInput['authService'], + ...DOOR_PARAMETERS, + })); + + if (w.answer === 'outage') { + expect(isAuthzStoreUnavailableError(door.rejected)).toBe(true); + expect(isAuthzStoreUnavailableError(composition.rejected)).toBe(true); + return; + } + expect(door.threw).toBe(false); + if (w.answer === 'none') { + expect(door.context).toBeUndefined(); + // The composition answers no context too, or rejects with something + // other than the outage, which the door's `catch` answers as none. + if (composition.threw) { + expect(isAuthzStoreUnavailableError(composition.rejected)).toBe(false); + } else { + expect(composition.context).toBeUndefined(); + } + return; + } + expect(door.context).toMatchObject({ userId: w.answer.userId, tenantId: w.answer.tenantId, principalKind: 'human', isSystem: false }); + // Withheld by this door: the ADR-0069 posture, the session bearer, and + // the localization the endpoints resolve for themselves. + expect(door.context?.authGate).toBeUndefined(); + expect(door.context?.accessToken).toBeUndefined(); + expect(door.context?.locale).toBeUndefined(); + expect(door.context?.timezone).toBeUndefined(); + expect(door.context).toEqual(composition.context); + }); + + it("CONTROL: the wiring the door's catch answers is a rejection of the composition", async () => { + // Without this, the `getApi()` row could pass on a composition that + // swallowed the rejection itself, and the door's catch would go unread. + const w = WIRINGS.find((row) => row.auth === 'getApi-rejects')!; + const composition = await settle(() => resolveRequestExecutionContext({ + headers: new Headers(), + ql: engine(), + authService: authService(w) as RequestExecutionContextInput['authService'], + ...DOOR_PARAMETERS, + })); + expect(composition.threw).toBe(true); + expect((composition.rejected as Error | undefined)?.message).toBe('getApi down'); + }); +}); diff --git a/packages/runtime/src/security/request-execution-context.conformance.test.ts b/packages/runtime/src/security/request-execution-context.conformance.test.ts index cc8033c13b0..9c522cc2250 100644 --- a/packages/runtime/src/security/request-execution-context.conformance.test.ts +++ b/packages/runtime/src/security/request-execution-context.conformance.test.ts @@ -23,7 +23,8 @@ * Who a request resolves to is the request's {@link Request.principal} at every * door, except where a door's declared parameters authenticate nobody for it * ({@link expectedPrincipal}: a door that requires a session API, on a host - * with no auth service). Across rows, every door that resolves a principal + * with no auth service; a door that reads only the session, for a request that + * carries no session). Across rows, every door that resolves a principal * resolves the SAME one ({@link IDENTITY}): the named parameters decide what a * door withholds or adds around the identity, and whether it admits at all, * never who the caller is. @@ -59,12 +60,19 @@ * | the same session while no gate is active | `authGate` (`'resolved'` against `'reread-when-active'`) | * | an API key | `credentials` | * | an ex-member's organization-stamped key under a walled posture | `tenancy` | + * | a session read naming its user by `session.userId` alone | none: it holds every door to the one reading of who a session names (ruling record 6257816853 item 1), which a door re-reading the session its own way would break | * | an API key on a host with no auth service | `sessionApi` | * * The storage door binds no authorizer on a host with no auth service, so its * `sessionApi` is told apart in its CONTROL instead, on a host whose auth * service exposes no session read. * + * The `/auth/me` door reads only the session and no tenancy posture, so no + * request above tells its `tenancy` apart (the ex-member's key never reaches + * a session-only door): its CONTROL adds a session claiming an organization + * its user is not a member of, under the walled posture. Its `sessionApi` is + * not observable at all, and its CONTROL says why. + * * The runtime door's `/mcp` face (an OAuth access token) is not a row: its * verification and scope interpretation are the door's own, ahead of the call, * and its pins are `resolve-execution-context.oauth-organization.test.ts` and @@ -84,6 +92,7 @@ import { resolveRequestExecutionContext, type RequestExecutionContextInput } fro import { RestServer } from '@objectstack/rest'; import { SettingsServicePlugin, type SettingsContext } from '@objectstack/service-settings'; import { StorageServicePlugin } from '@objectstack/service-storage'; +import { HonoHttpServer, registerCurrentUserEndpoints } from '@objectstack/plugin-hono-server'; import { hashApiKey } from './api-key.js'; import { resolveExecutionContext } from './resolve-execution-context.js'; @@ -110,6 +119,11 @@ vi.mock('@objectstack/core', async (importOriginal) => { const ORG = 'org_alpha'; const COOKIE = 'better-auth.session_token=tok3n.c2ln'; +/** A session whose read names its user by `session.userId` alone, with no user object. */ +const SESSION_USER_ID_COOKIE = 'better-auth.session_token=tok3n_session_user_id.c2ln'; +/** A session claiming an organization its user is not a member of. */ +const NON_MEMBER_COOKIE = 'better-auth.session_token=tok3n_non_member.c2ln'; +const NON_MEMBER_ORG = 'org_beta'; const KEY = 'osk_conformance_key'; const EX_MEMBER_KEY = 'osk_conformance_ex_member'; const FUTURE = '2999-01-01T00:00:00Z'; @@ -189,7 +203,17 @@ function world(opts: { auth: boolean; gateActive?: boolean; sessionApi?: boolean ...(opts.sessionApi === false ? {} : { api: { async getSession(input: { headers: Headers }) { - if (input.headers.get('cookie') !== COOKIE) return undefined; + const cookie = input.headers.get('cookie'); + if (cookie === SESSION_USER_ID_COOKIE) { + return { session: { id: 'sess_2', userId: 'u_ses', token: 'sess_bearer_2', activeOrganizationId: ORG } }; + } + if (cookie === NON_MEMBER_COOKIE) { + return { + user: { id: 'u_ses', email: 'ses@example.com' }, + session: { id: 'sess_3', userId: 'u_ses', token: 'sess_bearer_3', activeOrganizationId: NON_MEMBER_ORG }, + }; + } + if (cookie !== COOKIE) return undefined; return { user: { id: 'u_ses', email: 'ses@example.com', authGate: { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' } }, session: { id: 'sess_1', userId: 'u_ses', token: 'sess_bearer', activeOrganizationId: ORG }, @@ -235,6 +259,8 @@ const REQUESTS: Request[] = [ }, { name: 'an API key', headers: { 'x-api-key': KEY }, auth: true, principal: 'u_key' }, { name: "an ex-member's organization-stamped key under the walled posture", headers: { 'x-api-key': EX_MEMBER_KEY }, auth: true, principal: undefined }, + { name: 'a session read naming its user by session.userId alone', headers: { cookie: SESSION_USER_ID_COOKIE }, auth: true, principal: 'u_ses' }, + // Last, as the CONTROLs below read it. { name: 'an API key on a host with no auth service', headers: { 'x-api-key': KEY }, auth: false, principal: 'u_key' }, ]; @@ -269,10 +295,17 @@ const principalOf = (answer: unknown): string | undefined => (answer as { userId /** * Who `request` resolves to at a door declaring `parameters`: its principal, * unless the door requires a session API and the host has no auth service, in - * which case the door authenticates nobody, the API-key door included. + * which case the door authenticates nobody, the API-key door included; or + * unless the door reads only the session and the request carries none, in + * which case an API key alone admits nobody there. */ -function expectedPrincipal(request: Request, parameters: Pick): string | undefined { - return !request.auth && parameters.sessionApi === 'required' ? undefined : request.principal; +function expectedPrincipal( + request: Request, + parameters: Pick, +): string | undefined { + if (!request.auth && parameters.sessionApi === 'required') return undefined; + if (parameters.credentials === 'session-only' && request.headers.cookie === undefined) return undefined; + return request.principal; } /** @@ -526,7 +559,65 @@ const STORAGE_ROW: DoorRow = { }, }; -const DOORS: ReadonlyArray> = [RUNTIME_ROW, REST_ROW, SETTINGS_ROW, STORAGE_ROW]; +/** The current-user routes' prefix, as the endpoints mount by default. */ +const AUTH_ME_PERMISSIONS = '/api/v1/auth/me/permissions'; + +/** + * The `/auth/me` door, `makeExecutionContextResolver` + * (`@objectstack/plugin-hono-server`), through its public entry: the three + * current-user routes registered on a Hono app over the world's services, a + * metadata service and a security service, and one request to + * `GET /auth/me/permissions`. The route hands the context the door resolved + * to the security service (`resolvePermissionSetsForContext`) for the + * caller's sets, and answers its own signed-out body without asking it for a + * request the door resolved nobody for, so the drive reads the context there. + */ +async function authMePermissions(w: World, headers: Record): Promise<{ + status: number; + body: { authenticated?: boolean; userId?: string } | undefined; + handedOn: ExecutionContext | undefined; +}> { + const asked: ExecutionContext[] = []; + const services: Record = { + ...w.services, + objectql: w.ql, + metadata: { list: async () => [] }, + security: { + async resolvePermissionSetsForContext(context: ExecutionContext) { + asked.push(context); + return []; + }, + }, + }; + const server = new HonoHttpServer(0); + registerCurrentUserEndpoints({ + rawApp: server.getRawApp(), + ctx: { logger: { debug() {}, warn() {} }, getService: (name: string) => services[name] as T | undefined }, + }); + const res: Response = await server.getRawApp().request(`http://localhost${AUTH_ME_PERMISSIONS}`, { headers }); + expect(asked.length, 'the route asks the security service at most once').toBeLessThanOrEqual(1); + return { status: res.status, body: (await res.json()) as { authenticated?: boolean; userId?: string }, handedOn: asked[0] }; +} + +const HONO_ROW: DoorRow = { + door: 'packages/plugins/plugin-hono-server/src/current-user-endpoints.ts', + drive: async (w, headers) => (await authMePermissions(w, headers)).handedOn, + parameters: () => ({ + tenancy: undefined, + anonymous: 'fail-closed', + authGate: 'withhold', + credentials: 'session-only', + sessionApi: 'required', + session: 'request', + localization: 'withhold', + requestLocale: undefined, + accessToken: 'withhold', + oauth: undefined, + onAdmission: undefined, + }), +}; + +const DOORS: ReadonlyArray> = [RUNTIME_ROW, REST_ROW, SETTINGS_ROW, STORAGE_ROW, HONO_ROW]; /** The facts that say WHO the caller is; equal at every door that resolves one. */ const IDENTITY = ['userId', 'tenantId', 'email', 'positions', 'permissions', 'systemPermissions', 'posture', 'org_user_ids', 'accessible_org_ids', 'principalKind'] as const; @@ -741,4 +832,67 @@ describe('[ruling 6227928430 item 4] one request, the function and every convert expect(await storageDownload(world({ auth: false }), REQUESTS[REQUESTS.length - 1]!.headers)) .toMatchObject({ status: 200, handedOn: undefined }); }); + + it('CONTROL: the requests tell the /auth/me door\'s named parameters apart', async () => { + const authMe = HONO_ROW; + const viaFunction = (w: World, headers: Headers, flip: Partial) => resolveRequestExecutionContext({ + headers, + ql: w.ql, + authService: w.services.auth as RequestExecutionContextInput['authService'], + ...authMe.parameters(w, headers), + ...flip, + }); + const w = world({ auth: true }); + // `anonymous: 'fail-closed'`: the route answers its own signed-out body + // and hands nothing on, and the guest entry would have handed on a guest + // context. + expect(await authMePermissions(w, REQUESTS[0]!.headers)).toEqual({ status: 200, body: { authenticated: false }, handedOn: undefined }); + expect((await viaFunction(w, new Headers(), { anonymous: 'guest' }))?.principalKind).toBe('guest'); + // `authGate`, `accessToken`, `localization` and `requestLocale` withheld: + // the gated session with a language preference is served (these routes + // are allow-listed for a gated user), and its context carries no posture, + // bearer, locale or time zone. Flipped, each is carried. + const session = await authMePermissions(w, REQUESTS[1]!.headers); + expect(session.status).toBe(200); + expect(session.body).toMatchObject({ authenticated: true, userId: 'u_ses' }); + expect(session.handedOn).toMatchObject({ userId: 'u_ses', principalKind: 'human', tenantId: ORG }); + expect(session.handedOn?.authGate).toBeUndefined(); + expect(session.handedOn?.accessToken).toBeUndefined(); + expect(session.handedOn?.locale).toBeUndefined(); + expect(session.handedOn?.timezone).toBeUndefined(); + const headers = new Headers(REQUESTS[1]!.headers); + const flipped = await viaFunction(w, headers, { + authGate: 'resolved', + accessToken: 'carry', + localization: { settings: () => w.services.settings }, + requestLocale: preferredLocaleFromHeader(headers.get('accept-language')), + }); + expect(flipped).toMatchObject({ + authGate: { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }, + accessToken: 'sess_bearer', + locale: 'zh-CN', + timezone: 'Europe/Paris', + }); + // `credentials: 'session-only'`: an API key alone admits nobody here, and + // a door reading keys would admit its owner. + expect(await authMePermissions(w, REQUESTS[3]!.headers)).toEqual({ status: 200, body: { authenticated: false }, handedOn: undefined }); + expect((await viaFunction(w, new Headers(REQUESTS[3]!.headers), { credentials: 'api-key-and-session' }))?.userId).toBe('u_key'); + // `tenancy: undefined`: a session claiming an organization its user is not + // a member of keeps its claim here, and a door reading the walled posture + // drops the claim and keeps the caller. + const claim = await authMePermissions(w, { cookie: NON_MEMBER_COOKIE }); + expect(claim.handedOn).toMatchObject({ userId: 'u_ses', tenantId: NON_MEMBER_ORG }); + const walled = await viaFunction(w, new Headers({ cookie: NON_MEMBER_COOKIE }), { tenancy: () => w.services.tenancy as never }); + expect(walled?.userId).toBe('u_ses'); + expect(walled?.tenantId).toBeUndefined(); + // `sessionApi: 'required'` is declared, and no request can tell it apart + // at a session-only door: without a session read this door reads no + // credential at all, so `'optional'` admits nobody either. The key on a + // host with no auth service is the request that tells it apart at a door + // that reads keys. + const noAuth = world({ auth: false }); + const lastKey = REQUESTS[REQUESTS.length - 1]!.headers; + expect((await authMePermissions(noAuth, lastKey)).handedOn).toBeUndefined(); + expect(await viaFunction(noAuth, new Headers(lastKey), { sessionApi: 'optional' })).toBeUndefined(); + }); }); diff --git a/scripts/check-single-authz-resolver.mjs b/scripts/check-single-authz-resolver.mjs index a642320e97f..762b5f8c2ed 100644 --- a/scripts/check-single-authz-resolver.mjs +++ b/scripts/check-single-authz-resolver.mjs @@ -222,6 +222,7 @@ const DELEGATORS = new Map([ ['packages/runtime/src/security/resolve-execution-context.ts', 'resolveRequestExecutionContext'], ['packages/services/service-settings/src/settings-service-plugin.ts', 'resolveRequestExecutionContext'], ['packages/services/service-storage/src/storage-service-plugin.ts', 'resolveRequestExecutionContext'], + ['packages/plugins/plugin-hono-server/src/current-user-endpoints.ts', 'resolveRequestExecutionContext'], ['packages/core/src/security/request-execution-context.ts', 'resolveAuthzContext'], ]); From 86cd71673728db16935cbccda41f2d962812d5e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:04:47 +0000 Subject: [PATCH 2/2] refactor(plugin-hono-server): the /auth/me door calls the one request-to-ExecutionContext composition, resolveRequestExecutionContext Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude --- .../src/current-user-endpoints.ts | 255 ++++++++---------- 1 file changed, 113 insertions(+), 142 deletions(-) diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index c0e104d2cae..e7cc167d51a 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -39,28 +39,24 @@ */ import { - apiKeyLifecyclePolicySourceOf, - assembleExecutionContext, buildEffectiveObjectPermissions, IDataEngine, - resolveAuthzContext, resolveLocalizationContext, + resolveRequestExecutionContext, rethrowAuthzStoreUnavailable, - userAuthGateSourceOf, type ApiExposureSchemaLike, type ManagedSchemaLike, + type RequestAuthService, } from '@objectstack/core'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { preferredLocaleFromHeader } from '@objectstack/spec/system'; import type { - IAuthService, IMetadataService, IObjectQLEngine, ISecurityService, Logger, } from '@objectstack/spec/contracts'; import { allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; -import { inProcessSessionReadInput } from '@objectstack/types'; /** API prefix these endpoints mount under unless the host overrides it. */ export const DEFAULT_CURRENT_USER_PREFIX = '/api/v1'; @@ -433,15 +429,32 @@ function permissionSetResolver( * names never reached `resolvePermissionSets` either, independently of the * position tables. * - * So the session lookup — the genuinely transport-specific part — stays here and - * ALL grant aggregation delegates to the canonical resolver. `sys_user_position` - * (null org = global, active-org match, ADR-0091 validity windows), the implicit - * `everyone` position (ADR-0090 D5), `sys_position_permission_set`, - * `mapMembershipRole` normalization, the platform-admin derivation and the - * `ai_seat` synthesis arrive with it instead of being re-implemented — one copy - * fewer to drift. [#24061] It is reached through `resolveAuthzContext`, whose - * request-scoped grants memo lets the session read's own resolution serve this - * face, with the session read supplied here and no API-key door opened. + * So ALL grant aggregation delegates to the canonical resolver. + * `sys_user_position` (null org = global, active-org match, ADR-0091 validity + * windows), the implicit `everyone` position (ADR-0090 D5), + * `sys_position_permission_set`, `mapMembershipRole` normalization, the + * platform-admin derivation and the `ai_seat` synthesis arrive with it instead + * of being re-implemented — one copy fewer to drift. [#24061] It runs inside + * `resolveAuthzContext`'s request-scoped grants memo (`request-grants-memo.ts`): + * the session read's `customSession` hook (plugin-auth) resolves the same + * principal's grants, and the memo serves this face that resolution instead of + * issuing every grant read twice per request. The memo closes when the call + * settles and serves only while no write has started or landed on the engine, + * so a revocation inside the request is still read fresh. No API-key door is + * opened. + * + * ## The one request-to-context composition (ruling records 6227928430 and 6257816853) + * + * The steps around that resolver — reading the session, the anonymous entry, + * the ADR-0069 gate and the envelope assembly — are not composed here. This + * face calls `resolveRequestExecutionContext` (`@objectstack/core`), the one + * composition every platform door calls, and states its differences as that + * function's named parameters (see the call below). It reads who a session + * names the way every door does: a session read that carries `session.userId` + * and no `user.id` names that user. What stays here are this door's facts: + * the per-request service reads, the kernel the request is resolved against + * ({@link resolveRequestContext}, which picks the locator this resolver is + * built over), and the fail-closed `catch`. */ export function makeExecutionContextResolver( ctx: CurrentUserEndpointsContext, @@ -452,7 +465,7 @@ export function makeExecutionContextResolver( * kernel's does), and an engine-less stack must still resolve the SESSION — * a multi-tenant environment kernel carrying `auth` but no `objectql` * answers `{authenticated:true}` with empty grants, never a fake anonymous - * (cloud#927). `resolveAuthzContext` accepts `undefined` and yields an + * (cloud#927). The composition accepts `undefined` and yields an * empty-but-valid envelope, so the guard is all this needs. */ const getObjectQL = (): IDataEngine | undefined => { @@ -460,147 +473,105 @@ export function makeExecutionContextResolver( }; const resolveCtx = async (c: any): Promise => { try { - const authService = ctx.getService('auth'); - if (!authService) return undefined; - let api = authService.api; - if (!api && typeof authService.getApi === 'function') { - api = await authService.getApi(); - } - const getSession = api?.getSession?.bind(api); - if (!getSession) return undefined; - // [#22258] The in-process session-read rule: these routes answer - // with their own response, so a renewal here would move the - // session's expiry while its renewed cookie is discarded. A request - // carrying a session cookie reads without renewal; a bearer-only - // request renews as before. - const readSession = async () => { - const session = await getSession(inProcessSessionReadInput(c.req.raw.headers)); - return session?.user?.id ? session : null; - }; - // THE delegation (#6334), and ONE resolution per request (#24061). - // - // The session read above is better-auth's `getSession`, whose - // `customSession` hook (plugin-auth) resolves this principal's - // grants for the payload's `positions[]`. This face then asked - // `resolveUserAuthzGrants` for the same principal again, outside any - // request scope, so every `/auth/me/*` and `/me/apps` request issued - // every grant read twice — measured on a real engine, 15 reads in 9 - // round trips before the handler, where the REST door paid 10 in 7. - // `resolveAuthzContext` runs both inside ONE request-scoped grants - // memo (`request-grants-memo.ts`): the hook's resolution and this - // one share the scope and the arguments, so the second is served - // the first. The memo closes when the call settles — nothing - // outlives the request — and it serves only while no write has - // started or landed on the engine since the first resolution - // opened, so a revocation inside the request is still read fresh. - // - // SESSION ONLY, as before: this face never opened an API-key door, - // so the request's headers are withheld from the resolver - // (`headers: undefined` — no key is extracted, no admission runs) - // and the session is read by `readSession` over the request's own - // headers. No tenancy posture is passed, so the session's claimed - // organization stands as the session made it, exactly as this face - // read `activeOrganizationId` before. Never throws for a missing - // engine or an absent table — an empty-but-valid envelope - // (cloud#927); a permission-store read that FAILS raises, and the - // `catch` below re-raises that outage. - const authz = await resolveAuthzContext({ - ql: getObjectQL(), - headers: undefined, - getSession: readSession, - // Bound from the auth service, as the resolver requires - // wherever one is reachable. Both are read by the API-key - // admission seam alone, which no request here reaches. - userAuthGates: userAuthGateSourceOf(authService), - apiKeyLifecycle: apiKeyLifecyclePolicySourceOf(authService), - }); - if (!authz.userId) return undefined; - // [#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`. The rung - // is no longer re-derived here onto a throw-away object: - // `authz.posture` IS the authoritative derivation (ADR-0095 D2/D3 — - // from held capability grants, never a better-auth role), so this - // surface and the dispatcher hand the shared predicate the same value. - // A no-op when perf-tuning is off (no ambient gate). - if (isPerfDisclosurePrincipal({ isSystem: false, posture: authz.posture } as ExecutionContext)) { - allowPerfDisclosure(); - } - // [#15747] THE assembly (the second delegation). This used to be a - // hand-rolled object literal cast `as any` — beside the very module - // that exists to make that shape unrepresentable. It omitted SIX - // fields of the closed entry set (`principalKind`, `onBehalfOf`, - // `audience`, `accessToken`, `authGate`, `oauthScopes`), which is - // the #6071 drift class exactly: a field exists on - // `ExecutionContext`, one transport carries it, another silently - // does not. `ExecutionContextEntryFields` closes that set with a - // type, so a field added to `ExecutionContext` from here on fails - // to COMPILE until this face decides it — and the `as any` that - // suppressed the whole question is gone. + // Read per request, from the kernel that owns the request. A locator + // that THROWS for an absent `auth` slot lands in the `catch` below, + // which answers no context; an absent one is handed on as + // `undefined`, and `sessionApi: 'required'` admits nobody for it. + const authService = ctx.getService('auth'); + // THE delegation (#6334), ONE resolution per request (#24061), and + // the one composition (ruling record 6227928430, letter A; ruling + // record 6257816853, slice 5 letter A). This face's differences, + // each a named parameter of the composition: // - // The DEFAULT, fail-closed entry (commit f586f1a89, the ruled Option A), not the guest - // one: a sessionless request never reaches this line (the - // `authz.userId` guard above already returned `undefined`) and - // all three handlers answer their own no-session body. Adopting - // `assembleExecutionContextOrGuest` here would convert those into - // an authorization evaluation over a guest envelope, which is not - // these faces' semantics. - // - // Every per-face divergence below is passed EXPLICITLY, which is - // what turns each from an invisible omission into a named decision: - // - // - `oauth` — this face resolves its principal from the better-auth - // SESSION and opens no OAuth 2.1 door: `acceptOAuthAccessToken` - // is set by the `/mcp` dispatch path alone, on a resolver this - // one does not call. So `principalKind` here is 'human' - // unconditionally, and `onBehalfOf` / `oauthScopes` are not - // representable — pinned in + // - `anonymous: 'fail-closed'` (commit f586f1a89, the ruled + // Option A): a sessionless request yields no context, and all + // three handlers answer their own no-session body. The guest + // entry would convert those into an authorization evaluation + // over a guest envelope, which is not these faces' semantics. + // - `credentials: 'session-only'`: this face never opened an + // API-key door, so no key is extracted and no admission runs. + // - `sessionApi: 'required'`: with no auth service, or one + // exposing no session read, this face authenticates nobody. + // - `session: 'request'`: [#22258] the in-process session-read + // rule. These routes answer with their own response, so a + // renewal here would move the session's expiry while its renewed + // cookie is discarded. A request carrying a session cookie reads + // without renewal; a bearer-only request renews as before. + // - `tenancy: undefined`: no posture is read, so the session's + // claimed organization stands as the session made it, exactly as + // this face has always read `activeOrganizationId`. + // - `oauth: undefined`: this face resolves its principal from the + // better-auth SESSION and opens no OAuth 2.1 door: + // `acceptOAuthAccessToken` is set by the `/mcp` dispatch path + // alone, on a resolver this one does not call. So `principalKind` + // here is 'human' unconditionally, and `onBehalfOf` / + // `oauthScopes` are not representable — pinned in // `current-user-endpoints-execution-context-envelope.test.ts`, so // acquiring such a door turns that pin red rather than silently // making an absent `principalKind` mean something new. - // - `localization` / `requestLocale` — withheld. #15387 moved this - // surface's `locale` / `timezone` / `currency` to the ENDPOINT, - // which reads the cascade itself (`resolveCurrentUserLocalization`); - // resolving it here too would add a `sys_setting` read to every - // request to all three routes and give the localization endpoint - // two readings of one cascade to keep in agreement. - // - `accessToken` — withheld, the same stance the REST face takes: + // - `localization: 'withhold'`, `requestLocale: undefined`: #15387 + // moved this surface's `locale` / `timezone` / `currency` to the + // ENDPOINT, which reads the cascade itself + // (`resolveCurrentUserLocalization`); resolving it here too would + // add a `sys_setting` read to every request to all three routes + // and give the localization endpoint two readings of one cascade + // to keep in agreement. + // - `accessToken: 'withhold'`: the same stance the REST face takes; // surfacing the session bearer to hooks on a second transport is // a product decision, not a refactor. - // - `authGate` — withheld. Its consumer reads it off the envelope + // - `authGate: 'withhold'`: its consumer reads it off the envelope // at the REST seam (`RestServer.enforceAuth`); nothing on these // three routes does, and `core/security/auth-gate.ts` allow-lists // `/me/apps` + `/me/localization` as endpoints a GATED user must // still reach, so carrying it here would be an unread copy on a // surface whose whole point is that the gate does not close it. - return assembleExecutionContext({ - // The shared resolver's `ResolvedAuthzContext`. `positions`, - // not `roles`: the ExecutionContext field name every reader uses - // (ADR-0090 D3), carrying the ADR-0057 D4 `sys_user_position` - // assignments, the normalized org-membership names, the - // implicit `everyone` anchor and the derived `platform_admin`. - // [ADR-0105 D2] `accessible_org_ids` rides along: the `group` - // posture's Layer 0 wall is `organization_id IN (...)`, so a - // context without it fails every read closed on this surface. - authz, - oauth: undefined, - localization: undefined, + // - `onAdmission: undefined`: nothing here needs a word about + // which credential admitted the caller. + // + // Never throws for a missing engine or an absent table — an + // empty-but-valid envelope (cloud#927); a permission-store read that + // FAILS raises, and the `catch` below re-raises that outage. + const context = await resolveRequestExecutionContext({ + headers: c.req.raw.headers, + ql: getObjectQL(), + authService, + tenancy: undefined, + anonymous: 'fail-closed', + authGate: 'withhold', + credentials: 'session-only', + sessionApi: 'required', + session: 'request', + localization: 'withhold', requestLocale: undefined, - accessToken: undefined, - authGate: undefined, + accessToken: 'withhold', + oauth: undefined, + onAdmission: undefined, }); + if (!context) return undefined; + // [#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`. The rung + // is not re-derived here: `context.posture` IS the authoritative + // derivation (ADR-0095 D2/D3 — from held capability grants, never a + // better-auth role), so this surface and the dispatcher hand the + // shared predicate the same value. A no-op when perf-tuning is off + // (no ambient gate). + if (isPerfDisclosurePrincipal(context)) { + allowPerfDisclosure(); + } + return context; } catch (err) { // [commit 6a180e42d — maintainer ruling 2026-08-30, quoted verbatim // in the header of core's `authz-store-unavailable.test.ts`] Fail // closed, with ONE exception: an authorization store that could - // not be read is an outage, not an anonymous caller, at every - // transport that authorizes through `resolveAuthzContext`. Since - // #24061 this face is one, so it is in that test's transport - // ledger: the outage is re-raised to the host's error seam — `503 - // SERVICE_UNAVAILABLE` on `HonoHttpServer` — instead of answering - // `{ authenticated: false }` or `{ apps: [] }`, bodies - // byte-identical to a signed-out caller's. Every other fault still - // answers no context. + // not be read is an outage, not an anonymous caller. The + // composition propagates it, and it is re-raised to the host's + // error seam — `503 SERVICE_UNAVAILABLE` on `HonoHttpServer` — + // instead of answering `{ authenticated: false }` or + // `{ apps: [] }`, bodies byte-identical to a signed-out caller's. + // Every other fault still answers no context, the composition's + // own propagated rejections included (an auth service whose + // `getApi()` rejects). return rethrowAuthzStoreUnavailable(err); } }; @@ -736,8 +707,8 @@ interface CurrentUserLocalization { * `currency` and `timezone` come off that same cascade reading, which is the * repair #15387 names: they used to be read off the request * `ExecutionContext`, and the resolver serving THIS surface - * ({@link makeExecutionContextResolver}) is a hand-rolled envelope that never - * carried them — so the endpoint answered `null` for both to every + * ({@link makeExecutionContextResolver}) never carried them (it withholds the + * localization) — so the endpoint answered `null` for both to every * authenticated caller, whatever the `localization` settings said. The * dispatcher's shared assembler fills them from this very function * (`core/security/assemble-execution-context.ts` ⇒ `resolveLocalizationContext`),