Skip to content

Commit b036ac5

Browse files
feat(spec): AuthSessionApi.getSession declares the result fields its readers read (session.id, token, impersonatedBy, user.email, authGate) (#24220)
Fixes #24196 Clause-②: yes ## What `AuthSessionApi.getSession` (`packages/spec/src/contracts/auth-service.ts`) says its result declares exactly the fields its in-process readers read, but it only declared `user.id`, `session.userId` and `session.activeOrganizationId`. This PR declares the other fields the card's readers read. Each one is optional, carries a TSDoc naming its reader, and is typed the way that reader takes it: | Declared | Type | Reader | |---|---|---| | `user.email` | `string` | `resolveAuthzContext` session arm (`@objectstack/core`) → `ctx.email` | | `user.authGate` | `unknown` | `normalizeAuthGate` from the session arm. It validates the value against the closed posture vocabulary, so the contract declares it unvalidated, which is how that reader takes it | | `session.id` | `string` | session arm, dropped-claim log line (the row id, never the token) | | `session.token` | `string` | session arm → `ctx.accessToken` | | `session.impersonatedBy` | `string` or `null` | print door `impersonatorOf` (`@objectstack/service-print`). The stored column `sys_session.impersonated_by` is a nullable lookup, and better-auth's admin plugin types it `string` or `undefined` | The two readers now read through the declared type. Both changes are type-only: - **`packages/core/src/security/resolve-authz-context.ts`**: the session arm's `sessionData` is annotated with the type `AuthSessionApi.getSession` declares it answers (`SessionRead`, derived locally with `Awaited`/`ReturnType`, so no new export). Before, it was the `any` that `getSession`'s input type returns. ⛔ `ResolveAuthzInput.getSession`'s own type is **left at `any`**. Tightening it would narrow a published input of `@objectstack/core`, which is a `(narrowing)` arm and breaking, and this card is a widening. That option goes back to the seat as an open question. - **`packages/services/service-print/src/print-routes.ts`**: `impersonatorOf` takes the declared `SessionRead` instead of `unknown`. That drops the `as { session?: { impersonatedBy?: unknown } }` cast. The same branches run on the same values: the optional chain replaces cast-then-chain, and the `typeof by === 'string'` narrowing stays. `packages/types/src/in-process-session-read.contract.test.ts` (H4) pins the declaration's INPUT key for key (`DeclaredInput`), never the result. It needed no change and passes unchanged. ## Verification All of this ran at `7a60e0e5bb`. The later merge of `origin/main` (`b7f29ddfe2`) brought only cli / metadata-protocol / rest / runtime / platform-objects files, none of which this diff touches. - `pnpm --filter @objectstack/spec build` → exit 0. `check:generated` → exit 0, "All 14 generated artifacts are up to date". No artifact changed: the result type is inline in an existing export. - Typecheck, downstream of `@objectstack/spec`, after building each package's closure: `@objectstack/spec`, `@objectstack/types`, `@objectstack/core`, `@objectstack/service-print` → exit 0. Then the four packages that implement or read `AuthSessionApi`: `@objectstack/plugin-auth` (its `AuthManager.getApi()` answers better-auth's own API, which still fits the widened result), `@objectstack/plugin-sharing`, `@objectstack/service-datasource`, `@objectstack/plugin-hono-server` → exit 0. plugin-auth's examples leg resolves plugin-auth's own `dist`, so it was re-run after building it. - Tests: `@objectstack/core` 109 files / 2617 tests passed. `@objectstack/service-print` 8 / 109 passed. `@objectstack/types` 27 / 759 passed. - Reverse verification, committed first, mutation via `scripts/ablation-replace.mjs`, each leg restored and checked as `blob == HEAD`, `git diff HEAD` empty: - core `session?.token` → `session?.tokenRenamed`: `tsc` exit 2, `TS2339: Property 'tokenRenamed' does not exist on type '{ userId?…; id?…; token?…; impersonatedBy?… }'`. - print `session?.impersonatedBy;` → `session?.impersonatorId;`: `tsc` exit 2, TS2339 on the same declared type. - A first print attempt used a replacement that contained its own anchor. The tool refused it (anchor 1 → 1) and restored the file. That attempt measured nothing, and the second leg above is the reading. - `dispatch-gates --commands` (84 families, change set derived against merge base `8259ea03e`): 83 exit 0. The one exception is `pnpm check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT MET: it reads every package's `dist`, and this box built only the closures above. It is NOT MEASURED locally and is CI's reading. `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 on a shallow clone. After `git fetch --unshallow` it exits 0 (48 cases). `--ran` reconciliation: 84 derived, 83 run, 1 NOT-MEASURED (derived from the recorded exit 3). ## Acceptance notes The other `getSession` callers were **read, not edited** (the hold-off). Their reads of the result still go through `any` or a cast. Read on this branch: - `plugin-auth/src/auth-plugin.ts`: `:2839` and `:3229` call `(authApi as any).getSession(…)`. `:3229` also casts the result to `{ user?: { id?: unknown } }`. `:2776` and `:2911` are typed by better-auth's `Auth['api']`, not `AuthSessionApi`. All four hand the session to `judgePlatformAdmin` / `isPlatformAdminUser`, which take `unknown` and cast. They read `user.isPlatformAdmin`, `user.role`, `user.email`, `user.id` and `user.authGate`. - `rest/src/rest-server.ts:3416`: `gatedSession: any` → `normalizeAuthGate(gatedSession?.user)`. The closure at `:3202` goes to `resolveAuthzContext`. - `runtime/src/dispatcher-plugin.ts:214`: `const session: any` → `evaluateAuthGate`. `runtime/src/http-dispatcher.ts:1556` (`let session: any` → `evaluateAuthGate`) and `:1678` (implicitly `any`; reads `user.id`, `session.userId`, `session.activeOrganizationId`). `runtime/src/security/resolve-session-principal.ts:57`: `api: any`; its callers read `user.id` / `session.userId`. - `cloud-connection-plugin.ts:317` (`any` → `evaluateAuthGate`, then `resolveAuthzContext`). `marketplace-install-local-plugin.ts:3352` reads `session.activeOrganizationId` **or a top-level `activeOrganizationId`**. `service-storage/src/storage-service-plugin.ts:1176` reads the same top-level fallback. - `plugin-webhooks/src/webhook-outbox-plugin.ts:627` and `service-storage` `:953` / `:1279`: `any`, then `evaluateAuthGate` and `user.id` / `session.activeOrganizationId`. - `normalizeAuthGate` / `evaluateAuthGate` (`packages/core/src/security/auth-gate.ts`) keep their `any` parameter. They also validate non-session input (`{ authGate: raw }` from `resolveUserAuthGates`, and deliberately malformed test fixtures). Typing them is a separate change, not this card's. The fields only those callers read (`user.role`, `user.isPlatformAdmin`, the top-level `activeOrganizationId`) are **not** declared here. The card scopes the declaration to its named readers, and each of those callers would have to drop its cast for the declaration to have a reader. --- _Generated by [Claude Code](https://claude.ai/code/session_01A6LabtpD5uUXcktHHnReQa)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 99e9d29 commit b036ac5

4 files changed

Lines changed: 62 additions & 10 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/core": patch
4+
"@objectstack/service-print": patch
5+
---
6+
7+
feat(spec): `AuthSessionApi.getSession`'s result declares the fields its in-process readers read
8+
9+
Clause-②: yes (widening)
10+
11+
- **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains five optional members**, each declared for a named reader and typed as that reader takes it: `user.email` and `session.id` / `session.token` (read by `resolveAuthzContext`'s session arm in `@objectstack/core`), `user.authGate` as `unknown` (the ADR-0069 posture, which `normalizeAuthGate` validates against the closed posture vocabulary), and `session.impersonatedBy` as `string | null` (read by the print door's `impersonatorOf` in `@objectstack/service-print`). Before this, those reads went through `any` or a cast, so a field better-auth dropped or renamed compiled and read `undefined`. For `impersonatedBy`, that would have silently turned off the impersonation refusal. Every member is optional, so every implementer and caller typechecks unchanged.
12+
- **`@objectstack/core`**: `resolveAuthzContext`'s session arm reads the session through the declared type instead of `any`. `ResolveAuthzInput.getSession`'s own type is unchanged. Runtime behaviour is unchanged.
13+
- **`@objectstack/service-print`**: `impersonatorOf` reads `session.impersonatedBy` through the declared type instead of a cast. Runtime behaviour is unchanged.

‎packages/core/src/security/resolve-authz-context.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { SystemObjectName } from '@objectstack/spec/system';
6363
import type { AuthzPosture, TenancyPosture } from '@objectstack/spec/security';
6464
import { postureEnforcesWall } from '@objectstack/spec/security';
6565
import { isValueDomainMember } from '@objectstack/spec/shared';
66+
import type { AuthSessionApi } from '@objectstack/spec/contracts';
6667

6768
import { ApiKeyLifecycleRefusalError, resolveApiKeyAdmission } from './api-key.js';
6869
import type { ApiKeyLifecyclePolicySource, ApiKeyRefusalReason } from './api-key.js';
@@ -146,6 +147,9 @@ export interface ResolvedAuthzContext {
146147
authGate?: AuthGate;
147148
}
148149

150+
/** What an in-process better-auth session read answers, as `AuthSessionApi` declares it. */
151+
type SessionRead = Awaited<ReturnType<NonNullable<AuthSessionApi['getSession']>>>;
152+
149153
export interface ResolveAuthzInput {
150154
/** Data engine (ObjectQL) exposing `find(object, { where, limit, context })`. */
151155
ql: any;
@@ -539,7 +543,9 @@ async function resolveAuthzContextInScope(input: ResolveAuthzInput): Promise<Res
539543
// 2. Session / Bearer path — fall back when no API key resolved a user.
540544
if (!userId && typeof input.getSession === 'function') {
541545
try {
542-
const sessionData = await input.getSession(headers);
546+
// [#24196] Read as `AuthSessionApi.getSession` declares its answer, so
547+
// every field this arm reads is one the contract names for it.
548+
const sessionData: SessionRead | null = await input.getSession(headers);
543549
userId = sessionData?.user?.id ?? sessionData?.session?.userId;
544550
tenantId = tenantId ?? sessionData?.session?.activeOrganizationId;
545551
if (userId) {

‎packages/services/service-print/src/print-routes.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
userAuthGateSourceOf,
1515
} from '@objectstack/core';
1616
import type { ErrorCode } from '@objectstack/spec/api';
17-
import type { IAuthService, IHttpRequest, IHttpResponse, IHttpServer } from '@objectstack/spec/contracts';
17+
import type { AuthSessionApi, IAuthService, IHttpRequest, IHttpResponse, IHttpServer } from '@objectstack/spec/contracts';
1818
import type { ExecutionContext } from '@objectstack/spec/kernel';
1919
import type { TenancyPosture } from '@objectstack/spec/security';
2020
import {
@@ -132,10 +132,13 @@ export function registerPrintRoutes(http: IHttpServer, deps: PrintRoutesDeps): v
132132
}
133133

134134
/** The caller, as the door resolved it: the context, and the session read that admitted it. */
135+
/** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */
136+
type SessionRead = Awaited<ReturnType<NonNullable<AuthSessionApi['getSession']>>>;
137+
135138
interface ResolvedCaller {
136139
context: ExecutionContext | undefined;
137140
/** The session payload the auth service returned, when a session (not an API key) admitted the caller. */
138-
session: unknown;
141+
session: SessionRead;
139142
authGate: ReturnType<typeof evaluateAuthGate>;
140143
}
141144

@@ -226,9 +229,9 @@ async function resolveCaller(req: IHttpRequest, deps: PrintRoutesDeps): Promise<
226229
headers.set(name, Array.isArray(value) ? value.join(',') : String(value));
227230
}
228231
const auth = deps.getAuth();
229-
let sessionRead: unknown;
232+
let sessionRead: SessionRead;
230233
const getSession = auth
231-
? async (h: Headers): Promise<unknown> => {
234+
? async (h: Headers): Promise<SessionRead> => {
232235
const api = auth.api ?? (await auth.getApi?.());
233236
sessionRead = await api?.getSession?.(inProcessSessionReadInput(h));
234237
return sessionRead;
@@ -267,9 +270,8 @@ async function resolveCaller(req: IHttpRequest, deps: PrintRoutesDeps): Promise<
267270
* plugin writes `impersonatedBy` on the session it mints for an impersonation,
268271
* and `@objectstack/plugin-auth` stores it as `sys_session.impersonated_by`.
269272
*/
270-
function impersonatorOf(sessionRead: unknown): string | undefined {
271-
const session = (sessionRead as { session?: { impersonatedBy?: unknown } } | null | undefined)?.session;
272-
const by = session?.impersonatedBy;
273+
function impersonatorOf(sessionRead: SessionRead): string | undefined {
274+
const by = sessionRead?.session?.impersonatedBy;
273275
return typeof by === 'string' && by.trim() !== '' ? by : undefined;
274276
}
275277

‎packages/spec/src/contracts/auth-service.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,10 +379,41 @@ export interface IAuthService {
379379
* `cloud-connection-plugin.ts` and `marketplace-install-local-plugin.ts`
380380
* (twice). `packages/types/src/in-process-session-read.contract.test.ts`
381381
* holds the helper's return inside this declaration, key for key.
382+
*
383+
* [#24196] Each result field beyond `user.id`, `session.userId` and
384+
* `session.activeOrganizationId` is declared for the reader named on it, typed
385+
* as that reader takes it. Read undeclared, through `any` or a cast, a field
386+
* better-auth drops or renames compiles and reads `undefined` — for
387+
* `impersonatedBy`, an impersonation refusal switched off.
382388
*/
383389
export interface AuthSessionApi {
384390
getSession?(input: { headers: unknown; query?: { disableRefresh?: boolean } }): Promise<{
385-
user?: { id?: string };
386-
session?: { userId?: string; activeOrganizationId?: string };
391+
user?: {
392+
id?: string;
393+
/** Read by `resolveAuthzContext`'s session arm (`@objectstack/core`) onto `ctx.email`. */
394+
email?: string;
395+
/**
396+
* The ADR-0069 posture plugin-auth's `customSession` attaches. Read by
397+
* `normalizeAuthGate` (`@objectstack/core`) from `resolveAuthzContext`'s
398+
* session arm, which validates it against the closed posture vocabulary,
399+
* so it is declared as that reader takes it: unvalidated.
400+
*/
401+
authGate?: unknown;
402+
};
403+
session?: {
404+
userId?: string;
405+
activeOrganizationId?: string;
406+
/** The session ROW id. Read by `resolveAuthzContext`'s session arm for its dropped-claim log line. */
407+
id?: string;
408+
/** The session credential. Read by `resolveAuthzContext`'s session arm onto `ctx.accessToken`. */
409+
token?: string;
410+
/**
411+
* The administrator who started an impersonation session (better-auth's
412+
* admin plugin; stored as the nullable `sys_session.impersonated_by`).
413+
* Read by the print door's `impersonatorOf` (`@objectstack/service-print`),
414+
* which refuses a print from an impersonation session.
415+
*/
416+
impersonatedBy?: string | null;
417+
};
387418
} | undefined>;
388419
}

0 commit comments

Comments
 (0)