diff --git a/.changeset/22384-auth-session-api-getsession-input-query.md b/.changeset/22384-auth-session-api-getsession-input-query.md new file mode 100644 index 00000000000..81abb2c9ab8 --- /dev/null +++ b/.changeset/22384-auth-session-api-getsession-input-query.md @@ -0,0 +1,11 @@ +--- +"@objectstack/spec": minor +--- + +`AuthSessionApi.getSession` (`@objectstack/spec/contracts`) now declares the optional `query.disableRefresh` that the in-process session readers send + +Clause-②: yes (widening) + +- **The declaration.** `getSession`'s input is `{ headers: unknown; query?: { disableRefresh?: boolean } }`. It was `{ headers: unknown }`. The return type and the rest of `AuthSessionApi` are unchanged. +- **Why.** The in-process readers call `api.getSession(inProcessSessionReadInput(headers))` (`@objectstack/types`). For a request that carries a better-auth session cookie, that input is `{ headers, query: { disableRefresh: true } }`, so the session renews only on the `get-session` route, which re-issues the cookie. A bearer-only request is still read with `{ headers }` alone. The declaration said the readers send only `{ headers }`, which stopped being true when the readers moved to the helper. +- **Nothing to migrate.** The added key is optional. A caller that passes `{ headers }` compiles as before, and so does an implementation of `getSession` that accepts `{ headers }` and ignores the rest. An implementation that reads `input.query?.disableRefresh` now type-checks against the contract instead of needing a cast. diff --git a/packages/spec/src/contracts/auth-service.ts b/packages/spec/src/contracts/auth-service.ts index e835e0b70e4..d5a8d35a375 100644 --- a/packages/spec/src/contracts/auth-service.ts +++ b/packages/spec/src/contracts/auth-service.ts @@ -169,13 +169,36 @@ export interface IAuthService { * The slice of the session API the platform actually uses. * * [#4127 batch 4] Deliberately NOT a re-declaration of better-auth's handle, - * which is far wider and belongs to that library. Every dispatcher-side reader - * calls exactly `getSession({ headers })` and reads exactly the fields below, - * so that is what is declared. Widening this is for whoever needs more, with - * the call site to prove it. + * which is far wider and belongs to that library. What is declared is exactly + * the input the in-process readers send and exactly the fields below that they + * read. Widening this is for whoever needs more, with the call site to prove it. + * + * [#22384] `getSession`'s input has two read forms, decided by what the + * request carries: + * + * - a request carrying a better-auth session cookie (a browser) reads with + * `{ headers, query: { disableRefresh: true } }` — the session then renews + * only on the `get-session` route, which re-issues the cookie, so cookie + * expiry and session expiry cannot split; + * - a bearer-only request (the SDK outside a browser, the CLI) reads with + * `{ headers }` alone, renewal included. + * + * Their one source is `inProcessSessionReadInput(headers)` in + * `@objectstack/types` (`packages/types/src/in-process-session-read.ts`): + * read with `api.getSession(inProcessSessionReadInput(headers))`, not with a + * hand-built input — a hand-built `{ headers }` on a cookie-carrying request + * renews the session behind a cookie nobody re-issues. `query` is declared + * because readers send it (#22258): `rest-server.ts` in `@objectstack/rest` + * (twice); in `@objectstack/runtime`, `http-dispatcher.ts` (twice), + * `security/resolve-session-principal.ts` and + * `security/resolve-execution-context.ts`; `current-user-endpoints.ts` in + * `@objectstack/plugin-hono-server`; and in `@objectstack/cloud-connection`, + * `cloud-connection-plugin.ts` and `marketplace-install-local-plugin.ts` + * (twice). `packages/types/src/in-process-session-read.contract.test.ts` + * holds the helper's return inside this declaration, key for key. */ export interface AuthSessionApi { - getSession?(input: { headers: unknown }): Promise<{ + getSession?(input: { headers: unknown; query?: { disableRefresh?: boolean } }): Promise<{ user?: { id?: string }; session?: { userId?: string; activeOrganizationId?: string }; } | undefined>; diff --git a/packages/types/src/in-process-session-read.contract.test.ts b/packages/types/src/in-process-session-read.contract.test.ts new file mode 100644 index 00000000000..f4439ad8b17 --- /dev/null +++ b/packages/types/src/in-process-session-read.contract.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22384] The in-process session-read input stays inside the contract that + * declares it. + * + * Every in-process reader calls `api.getSession(inProcessSessionReadInput(headers))` + * with `api` typed by `AuthSessionApi` (`@objectstack/spec/contracts`), so the + * helper's return IS what that declaration receives. This file holds the two + * together: every key the helper returns is a key the declaration names. It + * lives in this package because only here are both visible — the helper is + * this package's, the contract is its dependency's, and `@objectstack/spec` + * must not import this package. + * + * ## Why plain assignability is not the check + * + * TypeScript refuses an undeclared key only on an object LITERAL. A + * non-literal carrying an extra optional key is assignable to a type that + * omits it, so `const input: DeclaredInput = inProcessSessionReadInput(headers)` + * compiles against a declaration of `{ headers: unknown }` alone — which is how + * the readers compiled while the declaration did not describe what they sent. + * `FitsDeclared` applies the literal's rule to a type: assignable, and no key + * the declaration does not name, at every depth where the declaration names a + * shape (`headers` is declared `unknown`, so it is not looked into). + * + * ## Where it bites + * + * At compile time. This package's `typecheck` (`tsc --noEmit`) compiles this + * file, reading the contract from `@objectstack/spec`'s BUILT `.d.ts`. A + * declaration that stops naming `query` or `disableRefresh`, or a helper that + * starts sending a key the declaration does not name, turns a `holds` below + * into TS2344, and reading `query` off the declared input in the runtime case + * into TS2339. The `@ts-expect-error` controls prove the instrument can fail at + * all: if `FitsDeclared` went vacuous, each directive would stop matching an + * error and tsc would report TS2578. + */ + +import { expect, it } from 'vitest'; +import type { AuthSessionApi } from '@objectstack/spec/contracts'; +import { inProcessSessionReadInput } from './in-process-session-read.js'; + +/** What `AuthSessionApi.getSession` declares it accepts. */ +type DeclaredInput = Parameters>[0]; + +/** What the helper returns for a reader's headers of type `H`. */ +type HelperInput = ReturnType>; + +/** `Out` fits `In` the way an object literal must; `true` or `false`, never both. */ +type FitsDeclared = [Fits] extends [true] ? true : false; + +/** + * One verdict per union member of `Out` — a union is judged member by member, + * never by its common keys — and per declared key below it. The verdicts union + * together, and `FitsDeclared` reads a single `false` among them as a miss. + */ +type Fits = Out extends unknown + ? [Out] extends [In] + ? unknown extends In + ? true + : [Out] extends [object] + ? [Exclude] extends [never] + ? { [K in keyof Out & keyof In]-?: Fits, Exclude> }[keyof Out & keyof In] + : false + : true + : false + : never; + +/** Compiles only when its type argument is `true`. */ +function holds(verdict: T): T { + return verdict; +} + +// The pin, for each header shape the readers hand the helper: a Web `Headers` +// (`c.req.raw.headers`, the dispatcher's), a Node header record (`req.headers` +// on the Node adapters), and an untyped one. +holds, DeclaredInput>>(true); +holds>, DeclaredInput>>(true); +holds, DeclaredInput>>(true); + +// The instrument's controls: each of these MUST fail to compile. +// @ts-expect-error a top-level key the declaration does not name +holds>(true); +// @ts-expect-error a key under `query` the declaration does not name +holds>(true); +// @ts-expect-error a union member with an undeclared key, beside one without +holds>(true); +// @ts-expect-error a declared key with a value the declaration does not accept +holds>(true); + +it('[#22384] an implementer typed by the contract reads the renewal decision the helper made', async () => { + const renews: boolean[] = []; + const api: AuthSessionApi = { + async getSession(input) { + // `input` is the DECLARED input: on a declaration without `query` + // this read is a compile error, not an `undefined`. + renews.push(input.query?.disableRefresh !== true); + return undefined; + }, + }; + + await api.getSession?.(inProcessSessionReadInput(new Headers({ cookie: 'better-auth.session_token=tok3n.c2ln' }))); + await api.getSession?.(inProcessSessionReadInput(new Headers({ authorization: 'Bearer tok3n.c2ln' }))); + + // A browser's read does not renew; a bearer-only read renews as before. + expect(renews).toEqual([false, true]); +});