From 3f0e6ed2916e77c21cf49f885a9a020585ac6a50 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:26:17 +0000 Subject: [PATCH 1/2] wip(spec): getSession readers read the declared AuthSessionApi result Claude-Session: https://claude.ai/code/session_018fKVdiy1mpvMbYt2ryCsLz Co-authored-by: Claude --- .../src/cloud-connection-plugin.ts | 12 ++-- ...stall-local-no-active-organization.test.ts | 23 +++++++- .../src/marketplace-install-local-plugin.ts | 15 +++-- .../plugins/plugin-auth/src/auth-plugin.ts | 23 ++++++-- .../plugin-auth/src/platform-admin-gate.ts | 13 ++++- ...orm-admin-gate.undefined-direction.test.ts | 56 +++++++++++++++++++ .../src/webhook-outbox-plugin.ts | 10 +++- packages/rest/src/rest-server.ts | 11 +++- packages/runtime/src/dispatcher-plugin.ts | 5 +- packages/runtime/src/http-dispatcher.ts | 11 +++- .../src/security/resolve-session-principal.ts | 17 ++++-- .../src/storage-service-plugin.ts | 29 +++++++--- .../sys-file-organization-stamping.test.ts | 8 ++- packages/spec/src/contracts/auth-service.ts | 25 +++++++++ 14 files changed, 214 insertions(+), 44 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/platform-admin-gate.undefined-direction.test.ts diff --git a/packages/cloud-connection/src/cloud-connection-plugin.ts b/packages/cloud-connection/src/cloud-connection-plugin.ts index 44de963e5af..40c94a6d476 100644 --- a/packages/cloud-connection/src/cloud-connection-plugin.ts +++ b/packages/cloud-connection/src/cloud-connection-plugin.ts @@ -116,7 +116,7 @@ import { classifyAdmissionTenancyPosture, type TenancyPostureSource, } from '@objectstack/core'; -import type { IAuthService, IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { AuthSessionApi, IAuthService, IObjectQLEngine } from '@objectstack/spec/contracts'; import { inProcessSessionReadInput } from '@objectstack/types'; import { ConnectionCredentialStore } from './connection-credential-store.js'; import { CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js'; @@ -167,6 +167,9 @@ interface EnvironmentKernel { getService?(name: string): T; } +/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */ +type SessionRead = Awaited>>; + /** * A signed-in caller of this surface, and the ADR-0069 posture its session * carries (`authGate`, present only for a session the policy holds). @@ -182,7 +185,7 @@ interface ResolvedSession { * kernel, whose grants are the ones that apply. */ readFrom: { - sessionData: unknown; + sessionData: SessionRead | null; authService: IAuthService | undefined; kernel?: EnvironmentKernel; }; @@ -313,8 +316,9 @@ export class CloudConnectionPlugin implements Plugin { const api = typeof authSvc?.getApi === 'function' ? await authSvc.getApi() : authSvc?.api ?? authSvc; // [#22258] The in-process session-read rule: a request carrying a // session cookie reads without renewal, because this route's - // response never carries a renewed cookie. - const session = await api?.getSession?.(inProcessSessionReadInput(rawReq.headers)); + // response never carries a renewed cookie. [#24222] Typed as + // `AuthSessionApi` declares the answer (`api` itself is `any`). + const session: SessionRead | null = await api?.getSession?.(inProcessSessionReadInput(rawReq.headers)); const userId = session?.user?.id ? String(session.user.id) : undefined; if (!userId) return null; // ADR-0069: the ONE consult of the authentication-policy gate for diff --git a/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts b/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts index cfeff0f42df..0b35d4d8de6 100644 --- a/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-no-active-organization.test.ts @@ -103,9 +103,11 @@ afterEach(() => { rmSync(dir, { recursive: true, force: true }); vi.restoreAllMo /** * Boot the real plugin. `posture` is the `tenancy` service's posture in force; - * `activeOrg` what the session's `activeOrganizationId` says (absent: none). + * `activeOrg` what the session's `activeOrganizationId` says (absent: none); + * `topLevelOrg` an `activeOrganizationId` beside `user` and `session` instead, + * a shape `AuthSessionApi` does not declare. */ -async function boot(opts: { posture: 'single' | 'isolated'; activeOrg?: string }) { +async function boot(opts: { posture: 'single' | 'isolated'; activeOrg?: string; topLevelOrg?: string }) { const routes = new Map(); const rawApp = { get: (p: string, h: Handler) => routes.set(`GET ${p}`, h), @@ -135,6 +137,7 @@ async function boot(opts: { posture: 'single' | 'isolated'; activeOrg?: string } getSession: async () => ({ user: { id: INSTALLER_USER_ID }, session: opts.activeOrg ? { activeOrganizationId: opts.activeOrg } : {}, + ...(opts.topLevelOrg ? { activeOrganizationId: opts.topLevelOrg } : {}), }), }, }, @@ -212,6 +215,22 @@ describe('walled, no active organization — refused at every sample-data door, }); }); +describe('walled, an `activeOrganizationId` only at the top level — not read', () => { + it('reseed: 403 PERMISSION_DENIED as for no active organization, and no seed run', async () => { + // [#24222] `resolveActiveOrgId` reads `session.activeOrganizationId`, where + // `AuthSessionApi` declares it. The top-level operand it dropped tolerated + // a shape no in-process producer answers (better-auth: `{ user, session }`). + const m = await boot({ posture: 'isolated', topLevelOrg: 'org_b' }); + await m.install(); + const res = await m.reseed(); + + expect(res.status).toBe(403); + expect(res.payload.error.code).toBe('PERMISSION_DENIED'); + expect(res.payload.error.message).toContain('this session has no active organization'); + expect(loadCalls).toHaveLength(0); + }); +}); + describe('walled, an active organization — all three doors act in it, as before', () => { it('install seeds inline into the active organization', async () => { const m = await boot({ posture: 'isolated', activeOrg: 'org_b' }); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index abf69d3f397..078c71b76c5 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -186,7 +186,7 @@ import { ConnectionCredentialStore } from './connection-credential-store.js'; import { readJsonRequestBody } from './request-json-body.js'; import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js'; import { matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js'; -import type { IAuthService, IHttpServer, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { AuthSessionApi, IAuthService, IHttpServer, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; import type { DeletePackageRequest, UninstallCleanupOutcome, @@ -202,6 +202,9 @@ import type { import { unmanagedInstallPostureRefusal, isWritablePackage, clientFacingFailureText, captureUninstallDeclared } from '@objectstack/metadata-protocol'; import { resolveThrownHttpError } from '@objectstack/types'; +/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */ +type SessionRead = Awaited>>; + const ROUTE_BASE = '/api/v1/marketplace/install-local'; /** @@ -3360,8 +3363,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (!api?.getSession) return null; // [#22258] A request carrying a session cookie reads without // renewal: this route's response never carries a renewed cookie. - const session = await api.getSession(inProcessSessionReadInput(c.req.raw.headers)); - const direct = session?.session?.activeOrganizationId ?? session?.activeOrganizationId ?? null; + // [#24222] Typed as `AuthSessionApi` declares the answer, and read + // where it declares the active organization. No top-level + // `activeOrganizationId` operand: no in-process producer answers one. + const session: SessionRead | null = await api.getSession(inProcessSessionReadInput(c.req.raw.headers)); + const direct = session?.session?.activeOrganizationId ?? null; if (direct) return String(direct); } catch { /* ignore */ } return null; @@ -3521,7 +3527,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { // The better-auth session bridge — resolved lazily and defensively, // exactly as the previous implementation did, then handed to the // shared resolver instead of being trusted on its own. - const getSession = async (h: any) => { + // [#24222] Answers as `AuthSessionApi` declares the read, not `any`. + const getSession = async (h: any): Promise => { try { const authService: any = ctx.getService('auth'); let api: any = authService?.api; diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 5f18e844aeb..fe7ed0b5138 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -31,7 +31,7 @@ import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platf import { PLATFORM_OWNER_EMAIL_ENV, resolvePlatformOwnerEmail, resolveTenancyPosture } from '@objectstack/types'; import { inProcessSessionReadInput } from '@objectstack/types'; import { postureEnforcesWall, type OrgScopingEntitlement } from '@objectstack/spec/security'; -import type { IDataEngine, IEmailService, II18nService, IObjectQLEngine, ISmsService } from '@objectstack/spec/contracts'; +import type { AuthSessionApi, IDataEngine, IEmailService, II18nService, IObjectQLEngine, ISmsService } from '@objectstack/spec/contracts'; import { AuthManager, resolveOidcProviderEnabled, @@ -147,6 +147,13 @@ interface SettingsReadSurface { /** [#23367] The auth setting that records when the API-key lifecycle policy took effect. */ const API_KEY_POLICY_STARTED_AT_KEY = 'api_key_policy_started_at'; +/** + * [#24222] What the in-process session read answers, as `AuthSessionApi` declares + * it. better-auth's own handle answers `null` for no session, hence the + * `| null` at each read. + */ +type SessionRead = Awaited>>; + /** * The `protocol` slot's metadata reader, as the user-import route uses it. * @@ -2773,7 +2780,8 @@ export class AuthPlugin implements Plugin { // Platform-admin gate (ADR-0068 D2) — one shared judge for every // ObjectStack `/admin/*` mount; see platform-admin-gate.ts. const authApi = await this.authManager!.getApi(); - const session = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers)); + // [#24222] Read as `AuthSessionApi` declares the answer (see `SessionRead`). + const session: SessionRead | null = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers)); const verdict = judgePlatformAdmin(session); if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status); @@ -2836,7 +2844,8 @@ export class AuthPlugin implements Plugin { // spelling instead of accreting per-mount copies. const gateAdmin = async (c: any): Promise => { const authApi = await this.authManager!.getApi(); - const session = await (authApi as any).getSession(inProcessSessionReadInput(c.req.raw.headers)); + // [#24222] Read as `AuthSessionApi` declares the answer (see `SessionRead`), not through `any`. + const session: SessionRead | null = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers)); const verdict = judgePlatformAdmin(session); if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status); return verdict.actor; @@ -2908,7 +2917,8 @@ export class AuthPlugin implements Plugin { // Platform-admin gate (ADR-0068 D2) — see platform-admin-gate.ts. const authApi = await this.authManager!.getApi(); - const session = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers)); + // [#24222] Read as `AuthSessionApi` declares the answer (see `SessionRead`). + const session: SessionRead | null = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers)); const verdict = judgePlatformAdmin(session); if (!verdict.ok) return c.json(verdict.refusal.body, verdict.refusal.status); @@ -3226,8 +3236,9 @@ export class AuthPlugin implements Plugin { rawApp.post(`${basePath}/admin/has-permission`, async (c: any) => { try { const authApi = await this.authManager!.getApi(); - const session = await (authApi as any).getSession(inProcessSessionReadInput(c.req.raw.headers)); - const user = (session as { user?: { id?: unknown } } | null | undefined)?.user; + // [#24222] Read as `AuthSessionApi` declares the answer (see `SessionRead`), not through `any`. + const session: SessionRead | null = await authApi.getSession(inProcessSessionReadInput(c.req.raw.headers)); + const user = session?.user; if (user?.id && isPlatformAdminUser(user)) { const { readEvaluatedPermissionQuery, answerPermissionQueryAsAdmin } = await import( './admin-has-permission-endpoint.js' diff --git a/packages/plugins/plugin-auth/src/platform-admin-gate.ts b/packages/plugins/plugin-auth/src/platform-admin-gate.ts index 9df0358138b..a7015460cf1 100644 --- a/packages/plugins/plugin-auth/src/platform-admin-gate.ts +++ b/packages/plugins/plugin-auth/src/platform-admin-gate.ts @@ -59,6 +59,10 @@ */ import { evaluateAuthGate } from '@objectstack/core'; +import type { AuthSessionApi } from '@objectstack/spec/contracts'; + +/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */ +type SessionRead = Awaited>>; /** The caller a passing gate hands back to the route. */ export interface PlatformAdminActor { @@ -163,9 +167,12 @@ export function judgeHeldSession(sessionUser: unknown): PlatformAdminRefusal | n * the posture refusal rather than `PERMISSION_DENIED`, the same 403 status. */ export function judgePlatformAdmin(session: unknown): PlatformAdminVerdict { - const user = (session as { user?: unknown } | null | undefined)?.user as - | Record - | undefined; + // [#24222] Read as `AuthSessionApi.getSession` declares its answer, so every + // user field this judge reads (`id`, `email`, `authGate`, `isPlatformAdmin`, + // `role`) is one the contract names for it. The parameter stays `unknown`: + // it is a published signature, and narrowing it would break a caller that + // hands over what it holds. + const user = (session as SessionRead | null)?.user; if (!user?.id) { return { diff --git a/packages/plugins/plugin-auth/src/platform-admin-gate.undefined-direction.test.ts b/packages/plugins/plugin-auth/src/platform-admin-gate.undefined-direction.test.ts new file mode 100644 index 00000000000..189a3fb8bba --- /dev/null +++ b/packages/plugins/plugin-auth/src/platform-admin-gate.undefined-direction.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The platform-admin judge reads `user.isPlatformAdmin` and `user.role`, which + * `AuthSessionApi.getSession` declares for it (`@objectstack/spec/contracts`). + * A field better-auth or `customSession` stops sending reads `undefined`; this + * pins the direction that `undefined` is judged in: it REFUSES, never admits. + * + * Every user below carries an `id`, so the identity refusal (401) is not the + * answer that hides the authorization one (403). + */ + +import { describe, it, expect } from 'vitest'; +import { isPlatformAdminUser, judgePlatformAdmin } from './platform-admin-gate.js'; + +const ID = { id: 'usr_signed_in', email: 'signed-in@example.com' }; + +const NOT_ADMIN: ReadonlyArray<[string, Record]> = [ + ['both fields absent', { ...ID }], + ['both fields `undefined`', { ...ID, isPlatformAdmin: undefined, role: undefined }], + ['`isPlatformAdmin` absent, `role` null', { ...ID, role: null }], + ['`isPlatformAdmin` false, `role` absent', { ...ID, isPlatformAdmin: false }], + // Truthy is not `true`, and a near-miss spelling is not `'admin'`. + ['`isPlatformAdmin` the string `"true"`', { ...ID, isPlatformAdmin: 'true' }], + ['`role` `"Admin"`', { ...ID, role: 'Admin' }], +]; + +describe('isPlatformAdminUser: an absent or `undefined` signal is not a platform admin', () => { + for (const [label, user] of NOT_ADMIN) { + it(`${label} → false`, () => { + expect(isPlatformAdminUser(user)).toBe(false); + }); + } + + it('the two admitting signals still admit (the control)', () => { + expect(isPlatformAdminUser({ ...ID, isPlatformAdmin: true })).toBe(true); + expect(isPlatformAdminUser({ ...ID, role: 'admin' })).toBe(true); + }); +}); + +describe('judgePlatformAdmin: a signed-in session whose signals read `undefined` is refused 403', () => { + for (const [label, user] of NOT_ADMIN) { + it(`${label} → 403 PERMISSION_DENIED`, () => { + const verdict = judgePlatformAdmin({ user, session: { userId: ID.id } }); + expect(verdict.ok).toBe(false); + if (verdict.ok) return; + expect(verdict.refusal.status).toBe(403); + expect(verdict.refusal.body.error.code).toBe('PERMISSION_DENIED'); + }); + } + + it('a session with `isPlatformAdmin: true` is admitted (the control)', () => { + const verdict = judgePlatformAdmin({ user: { ...ID, isPlatformAdmin: true }, session: { userId: ID.id } }); + expect(verdict).toEqual({ ok: true, actor: { id: ID.id, email: ID.email } }); + }); +}); diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index 7d2f2da8cb2..79d1c6bb9e9 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -5,6 +5,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; // redeliver door on the session it already reads (see `serveRedeliver`). import { evaluateAuthGate } from '@objectstack/core'; import type { + AuthSessionApi, IDataEngine, II18nService, IMetadataService, @@ -14,6 +15,9 @@ import type { import type { EnqueueHttpInput } from '@objectstack/service-messaging'; import { inProcessSessionReadInput } from '@objectstack/types'; import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js'; + +/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */ +type SessionRead = Awaited>>; import { SysWebhook } from './sys-webhook.object.js'; import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; import { migrateLegacyWebhookSecrets } from './migrate-webhook-secrets.js'; @@ -614,8 +618,12 @@ export class WebhookOutboxPlugin implements Plugin { * (`session.activeOrganizationId`, the tenant threaded into the write). * Resolving them separately would mean two `getSession` calls that can * disagree. + * + * [#24222] Typed as `AuthSessionApi` declares the answer, not `any`: the + * route's three reads (`user.id`, `user` for the posture gate, + * `session.activeOrganizationId`) are all declared there. */ - private async resolveSession(ctx: PluginContext, c: any): Promise { + private async resolveSession(ctx: PluginContext, c: any): Promise { try { const authService: any = this.tryGetService(ctx, ['auth']); if (!authService) return undefined; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index bc2c2161436..9897c160d5e 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -215,7 +215,7 @@ import { // [#22141] The `PUT /meta/:type/:name` door's precondition and lifecycle, read // through the ONE mapping the runtime dispatcher's `/meta` domain asks too. import { metaSaveRequestOptions } from './meta-save-request.js'; -import type { ISecurityService } from '@objectstack/spec/contracts'; +import type { AuthSessionApi, ISecurityService } from '@objectstack/spec/contracts'; import { resolveEffectiveApiMethods, effectiveOperationsArray, @@ -243,6 +243,9 @@ import { // still emits — which this deliberately is not. import { sendError as sendEnvelopeError } from '@objectstack/types'; +/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */ +type SessionRead = Awaited>>; + /** * The protocol slice the REST layer actually consumes (ADR-0076 D9 / #2462 * A1.5): wire-normalized data CRUD plus the metadata control plane — not the @@ -3198,7 +3201,8 @@ export class RestServer { // carrying a session cookie therefore reads without renewal (only // `/get-session`, which re-issues the cookie, renews it); a // bearer-only request renews as before. - const getSession = async (h: any) => { + // [#24222] Answers as `AuthSessionApi` declares the read, not `any`. + const getSession = async (h: any): Promise => { try { return await api.getSession(inProcessSessionReadInput(h)); } catch { return undefined; } }; // [#8287] The EFFECTIVE tenancy posture, from the kernel's `tenancy` @@ -3404,7 +3408,8 @@ export class RestServer { } } if (gateActive) { - let gatedSession: any; + // [#24222] Typed as `AuthSessionApi` declares the answer, not `any`. + let gatedSession: SessionRead | null; try { // ⛔ NOT the `getSession` closure above, and not its // `.catch(() => undefined)`: both convert a THROW into the diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 0136bea52b1..44b10ec0fe5 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -210,8 +210,9 @@ async function readGatedSessionUser( } // [#22258] The in-process session-read rule: a cookie request reads // without renewal, because this door's response never carries a - // renewed cookie. - const session: any = await api.getSession(inProcessSessionReadInput(webHeaders)); + // renewed cookie. [#24222] Typed as `AuthSessionApi` declares the + // answer (`api` is the contract's handle), no longer through `any`. + const session = await api.getSession(inProcessSessionReadInput(webHeaders)); return session?.user; } catch (err) { throw new AuthzStoreUnavailableError('auth_gate', err); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 26dba62e314..8616854c5cd 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -20,7 +20,7 @@ import { import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE, resolveThrownHttpError, demotedDeclaredCode, declaredUserMessage, inProcessSessionReadInput, isEngineDuplicateRecordEnvelope, structuredCodeAnswer } from '@objectstack/types'; import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; import { CoreServiceName, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system'; -import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; +import type { AuthSessionApi, IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; import type { PrimaryDatasourceVerdict } from '@objectstack/objectql'; import { readServiceSelfInfo, readChannelRoute, readAuthFamilies, isSubscribableChannel, DispatcherErrorCode, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; import type { ServiceInfo } from '@objectstack/spec/api'; @@ -57,6 +57,9 @@ import { createMcpDomains, handleMcpRequest, handleMcpSkillRequest, buildMcpBrid import { createMetaDomain, handleMetadataRequest } from './domains/meta.js'; import { createDataDomain, handleDataRequest } from './domains/data.js'; +/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */ +type SessionRead = Awaited>>; + /** Minimal local interface — full EnvironmentScopeManager was removed in Phase R. */ interface EnvironmentScopeManager { touch(environmentId: string): void; @@ -1529,7 +1532,8 @@ export class HttpDispatcher { // `.catch(() => undefined)` on it — that converts a THROW into the // same `undefined` a gate-less session produces, which is the // collapse #23245 repaired. - let session: any; + // [#24222] Typed as `AuthSessionApi` declares the answer, not `any`. + let session: SessionRead | null; try { let api: any = authService.api; if (!api && typeof authService.getApi === 'function') api = await authService.getApi(); @@ -1675,7 +1679,8 @@ export class HttpDispatcher { const authService = await this.resolveService(this.requestKernel(context), CoreServiceName.enum.auth); const api = authService?.api ?? (typeof authService?.getApi === 'function' ? await authService.getApi() : undefined); // [#22258] The in-process session-read rule, as in `enforceAuthGate`. - const sessionData = await api?.getSession?.(inProcessSessionReadInput(context.request?.headers)); + // [#24222] Typed as `AuthSessionApi` declares the answer, not `any`. + const sessionData: SessionRead | null = await api?.getSession?.(inProcessSessionReadInput(context.request?.headers)); userId = sessionData?.user?.id ?? sessionData?.session?.userId; activeOrganizationId = sessionData?.session?.activeOrganizationId; } catch { diff --git a/packages/runtime/src/security/resolve-session-principal.ts b/packages/runtime/src/security/resolve-session-principal.ts index 847eaf8f602..61d965f7995 100644 --- a/packages/runtime/src/security/resolve-session-principal.ts +++ b/packages/runtime/src/security/resolve-session-principal.ts @@ -17,9 +17,12 @@ * with. */ -import type { IAuthService } from '@objectstack/spec/contracts'; +import type { AuthSessionApi, IAuthService } from '@objectstack/spec/contracts'; import { inProcessSessionReadInput } from '@objectstack/types'; +/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */ +type SessionRead = Awaited>>; + /** Headers as adapters deliver them, or an already-built `Headers`. */ export type HeaderBag = Record | Headers; @@ -37,16 +40,20 @@ function toHeaders(headers: HeaderBag): Headers { /** * The better-auth session payload for a request, or `undefined` when there is * no auth service, no session API, or no session. + * + * [#24222] Typed as `AuthSessionApi` declares the answer, read off the + * contract's own `api` / `getApi()` members, no longer through `any`: both + * callers' reads (`user.id`, `session.userId`) are declared there. */ export async function resolveSessionData( authService: IAuthService | undefined, headers: HeaderBag, -): Promise { +): Promise { try { if (!authService) return undefined; - let api: any = (authService as any).api; - if (!api && typeof (authService as any).getApi === 'function') { - api = await (authService as any).getApi(); + let api = authService.api; + if (!api && typeof authService.getApi === 'function') { + api = await authService.getApi(); } if (!api?.getSession) return undefined; // [#22258] The in-process session-read rule: a request carrying a diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index 0c636120541..ff2307ad9e4 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -33,6 +33,7 @@ import type { IStorageService, IFileAccessDelegate, ApiKeyLifecyclePolicy, + AuthSessionApi, } from '@objectstack/spec/contracts'; import { OBSERVABILITY_METRICS_SERVICE, @@ -938,8 +939,16 @@ function toWebHeaders(req: { headers?: unknown }): any | null { return null; } -/** A `getSession(headers)` bound to the kernel's `auth` service, or null. */ -function buildGetSession(registry: StorageGateRegistry): ((headers: any) => Promise) | null { +/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */ +type SessionRead = Awaited>>; + +/** + * A `getSession(headers)` bound to the kernel's `auth` service, or null. + * + * [#24222] It answers as `AuthSessionApi` declares the read, not `any`, so each + * of its three readers reads only fields the contract declares. + */ +function buildGetSession(registry: StorageGateRegistry): ((headers: any) => Promise) | null { let authService: any; try { authService = registry.getService('auth'); @@ -1151,9 +1160,10 @@ export function composeStorageRoutes( * upload routes thread into `createFile` so the new `sys_file` row is stamped. * The read is the platform's existing spelling for that fact — * `session.session.activeOrganizationId`, the same one `HttpDispatcher`, - * `dispatcher-plugin` and `ExecutionContext.tenantId` resolve from — with the - * flattened shape as a fallback for hosts that hand back the session record - * directly. + * `dispatcher-plugin` and `ExecutionContext.tenantId` resolve from. [#24222] + * The flattened shape (a top-level `activeOrganizationId`) is no longer read: + * `AuthSessionApi` declares no such field and no in-process producer answers + * one (better-auth's read is `{ user, session }`). * * ⛔ No membership fallback. A session with no active organization is a * declared state (ADR-0123 D1), never a gap to fill from the user's `sys_member` @@ -1173,11 +1183,12 @@ function buildAuthSessionResolver( try { const headers = toWebHeaders(req); if (!headers) return null; - const session: any = await getSession(headers); + const session = await getSession(headers); const userId = session?.user?.id; if (!userId) return null; - const activeOrganizationId = - session?.session?.activeOrganizationId ?? session?.activeOrganizationId; + // [#24222] Where the contract declares it. No top-level + // `activeOrganizationId` operand: no in-process producer answers one. + const activeOrganizationId = session?.session?.activeOrganizationId; const organizationId = typeof activeOrganizationId === 'string' && activeOrganizationId.length > 0 ? activeOrganizationId @@ -1274,7 +1285,7 @@ function buildAuthGateSessionReader( if (admission.outcome === 'held') return { authGate: admission.gate }; if (admission.outcome !== 'none') return undefined; } - let session: any; + let session: SessionRead | null; try { session = await getSession(headers); } catch (err) { diff --git a/packages/services/service-storage/src/sys-file-organization-stamping.test.ts b/packages/services/service-storage/src/sys-file-organization-stamping.test.ts index 2656289ec9f..7d7b8d309f5 100644 --- a/packages/services/service-storage/src/sys-file-organization-stamping.test.ts +++ b/packages/services/service-storage/src/sys-file-organization-stamping.test.ts @@ -363,14 +363,18 @@ describe('StorageServicePlugin: the session bridge reports the ACTIVE organizati expect(engine._inserts[0].options).toEqual({ context: { tenantId: 'org_A', isSystem: true } }); }); - it('accepts the flattened shape a host may hand back directly', async () => { + it('⛔ does not read a top-level `activeOrganizationId` — no producer answers that shape', async () => { + // [#24222] `AuthSessionApi.getSession` declares the active organization on + // `session.activeOrganizationId` alone, and better-auth answers + // `{ user, session }`. The flattened operand tolerated a shape no + // in-process producer emits; a payload carrying only it stamps nothing. const { res, engine } = await bootPluginAndUpload({ user: { id: 'u1' }, activeOrganizationId: 'org_B', }); expect(res._status).toBe(200); - expect(engine._inserts[0].options).toEqual({ context: { tenantId: 'org_B', isSystem: true } }); + expect(engine._inserts[0].options).toEqual({ context: { isSystem: true } }); }); it('⛔ invents nothing when the session carries no active organization', async () => { diff --git a/packages/spec/src/contracts/auth-service.ts b/packages/spec/src/contracts/auth-service.ts index b9b15e902c7..e275d779c96 100644 --- a/packages/spec/src/contracts/auth-service.ts +++ b/packages/spec/src/contracts/auth-service.ts @@ -385,6 +385,11 @@ export interface IAuthService { * as that reader takes it. Read undeclared, through `any` or a cast, a field * better-auth drops or renames compiles and reads `undefined` — for * `impersonatedBy`, an impersonation refusal switched off. + * + * [#24222] The other in-process readers read the result as declared here too. + * None reads a top-level `activeOrganizationId`: the read answers + * `{ user, session }`, and the active organization is + * `session.activeOrganizationId`. */ export interface AuthSessionApi { getSession?(input: { headers: unknown; query?: { disableRefresh?: boolean } }): Promise<{ @@ -392,6 +397,12 @@ export interface AuthSessionApi { id?: string; /** Read by `resolveAuthzContext`'s session arm (`@objectstack/core`) onto `ctx.email`. */ email?: string; + /** + * better-auth's projection of `sys_user.name`. Read by `dispatcher-plugin`'s + * concrete AI-route `req.user` resolver (`@objectstack/runtime`) as the + * actor's display name. + */ + name?: string; /** * The ADR-0069 posture plugin-auth's `customSession` attaches. Read by * `normalizeAuthGate` (`@objectstack/core`) from `resolveAuthzContext`'s @@ -399,6 +410,20 @@ export interface AuthSessionApi { * so it is declared as that reader takes it: unvalidated. */ authGate?: unknown; + /** + * The ADR-0068 D2 platform-admin posture rung plugin-auth's `customSession` + * derives (`false` when not held). Read by `judgePlatformAdmin` + * (`@objectstack/plugin-auth`, through `isPlatformAdminUser`), which admits + * on `true` alone, so an absent field refuses. + */ + isPlatformAdmin?: boolean; + /** + * better-auth's `admin` plugin role scalar (`type: 'string'`, `required: false`, + * so a row may hold it as null). Read by `judgePlatformAdmin` + * (`@objectstack/plugin-auth`, through `isPlatformAdminUser`) as the legacy + * back-compat signal, which admits on `'admin'` alone, so an absent field refuses. + */ + role?: string | null; }; session?: { userId?: string; From 6f351e9bfac61fb2160f845e8d78cd330f76d01a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:57:20 +0000 Subject: [PATCH 2/2] chore(changeset): 24222 getSession readers read the declared result Claude-Session: https://claude.ai/code/session_018fKVdiy1mpvMbYt2ryCsLz Co-authored-by: Claude --- .../24222-get-session-readers-declared.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/24222-get-session-readers-declared.md diff --git a/.changeset/24222-get-session-readers-declared.md b/.changeset/24222-get-session-readers-declared.md new file mode 100644 index 00000000000..684d7847db7 --- /dev/null +++ b/.changeset/24222-get-session-readers-declared.md @@ -0,0 +1,17 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-auth": patch +"@objectstack/rest": patch +"@objectstack/runtime": patch +"@objectstack/cloud-connection": patch +"@objectstack/plugin-webhooks": patch +"@objectstack/service-storage": patch +--- + +feat(spec): the remaining `getSession` readers read `AuthSessionApi`'s declared result, and none reads a top-level `activeOrganizationId` + +Clause-②: yes (widening) + +- **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains three optional `user` members**, each declared for a named reader: `isPlatformAdmin` (`boolean`, the ADR-0068 D2 rung plugin-auth's `customSession` derives) and `role` (`string | null`, better-auth's `admin` plugin scalar), both read by `judgePlatformAdmin` in `@objectstack/plugin-auth`; and `name` (`string`), read by the runtime dispatcher's AI-route `req.user` resolver as the display name. Every member is optional, so every implementer and caller typechecks unchanged. +- **The readers read through the declared type instead of `any` or a cast** (types only, runtime behaviour unchanged): `judgePlatformAdmin` and the four platform-admin mounts in `@objectstack/plugin-auth` (better-auth's own session type now has to fit the declaration at those reads), the REST server's two session reads (`@objectstack/rest`), the dispatcher's posture read, `resolveSessionData` and both `HttpDispatcher` reads (`@objectstack/runtime`), the cloud-connection and install-local session reads (`@objectstack/cloud-connection`), the webhook redeliver door (`@objectstack/plugin-webhooks`), and the storage session bridge (`@objectstack/service-storage`). +- **Fix — the top-level `activeOrganizationId` fallback is removed** from the storage upload stamp (`@objectstack/service-storage`) and from install-local's `resolveActiveOrgId` (`@objectstack/cloud-connection`). The active organization is read from `session.activeOrganizationId` alone, where the contract declares it. No in-process producer answers a top-level `activeOrganizationId` (better-auth's read is `{ user, session }`), so for the shipped auth plugin nothing changes. An auth service that answered only the flattened shape now gets no organization: the upload is not stamped, and install-local reports no active organization. To fix it, answer the active organization on `session.activeOrganizationId`, as `AuthSessionApi` declares it.