From 769d9f4db8a7817219ca99806a006678a055ab9a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 01:24:53 +0000 Subject: [PATCH 1/2] feat(spec): AuthSessionApi.getSession declares the optional query.disableRefresh its readers send The in-process readers call api.getSession(inProcessSessionReadInput(headers)), which adds query: { disableRefresh: true } for a request carrying a session cookie. The contract declared { headers } alone. Widen the declared input to { headers: unknown; query?: { disableRefresh?: boolean } }, restate the docblock as the two read forms with the helper as their one source and the call sites that send query, and pin the helper's return inside the declaration key for key from packages/types. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- ...auth-session-api-getsession-input-query.md | 11 ++ packages/spec/src/contracts/auth-service.ts | 32 +++++- .../in-process-session-read.contract.test.ts | 106 ++++++++++++++++++ 3 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 .changeset/22384-auth-session-api-getsession-input-query.md create mode 100644 packages/types/src/in-process-session-read.contract.test.ts 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..698dd54bd9e 100644 --- a/packages/spec/src/contracts/auth-service.ts +++ b/packages/spec/src/contracts/auth-service.ts @@ -169,13 +169,35 @@ 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`); a + * reader calls `api.getSession(inProcessSessionReadInput(headers))` and never + * builds either form by hand. `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]); +}); From 57d9d9a8c3c2adab740ebcb0bf11977388f956f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 02:24:53 +0000 Subject: [PATCH 2/2] docs(spec): AuthSessionApi docblock prescribes the helper instead of describing every reader State the helper as the way to read and what a hand-built input costs on a cookie-carrying request, rather than asserting that every reader already reads through it. Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn Co-authored-by: Claude --- packages/spec/src/contracts/auth-service.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/spec/src/contracts/auth-service.ts b/packages/spec/src/contracts/auth-service.ts index 698dd54bd9e..d5a8d35a375 100644 --- a/packages/spec/src/contracts/auth-service.ts +++ b/packages/spec/src/contracts/auth-service.ts @@ -184,11 +184,12 @@ export interface IAuthService { * `{ headers }` alone, renewal included. * * Their one source is `inProcessSessionReadInput(headers)` in - * `@objectstack/types` (`packages/types/src/in-process-session-read.ts`); a - * reader calls `api.getSession(inProcessSessionReadInput(headers))` and never - * builds either form by hand. `query` is declared because readers send it - * (#22258): `rest-server.ts` in `@objectstack/rest` (twice); in - * `@objectstack/runtime`, `http-dispatcher.ts` (twice), + * `@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`,