Skip to content

Commit e75dced

Browse files
feat(spec): AuthSessionApi.getSession declares the optional query.disableRefresh its readers send (#22406)
Fixes #22384 Clause-②: yes (widening) `AuthSessionApi.getSession` now declares the optional `query.disableRefresh` that the in-process readers send. A type-level pin in `packages/types` keeps the helper's return inside that declaration, key for key. ## What changes - **`packages/spec/src/contracts/auth-service.ts`.** - The declaration: `getSession?(input: { headers: unknown; query?: { disableRefresh?: boolean } })`. It was `{ headers: unknown }`. The return type and the rest of `AuthSessionApi` are unchanged. - The docblock now states the two read forms. A request carrying a better-auth session cookie reads with `{ headers, query: { disableRefresh: true } }`. A bearer-only request reads with `{ headers }` alone. - It names `inProcessSessionReadInput` (`packages/types/src/in-process-session-read.ts`) as the one source of both forms, and says what a hand-built `{ headers }` costs on a cookie request. - The old line "Widening this is for whoever needs more, with the call site to prove it" stays. The docblock now cites the ten call sites from PR #22367 that send `query`: `rest-server.ts` (x2), `http-dispatcher.ts` (x2), `resolve-session-principal.ts`, `resolve-execution-context.ts`, `current-user-endpoints.ts`, `cloud-connection-plugin.ts` and `marketplace-install-local-plugin.ts` (x2). - **`packages/types/src/in-process-session-read.contract.test.ts`** (new). This is the pin, and it is the one file declared on #6024 (comment `6072295794`). It is described below. - **`.changeset/22384-auth-session-api-getsession-input-query.md`.** `@objectstack/spec` `minor`. `@objectstack/types` gets no entry, because the only file changed there is a test, and `files[]` ships only `dist`, `README.md` and `CHANGELOG.md`. No consumer was touched. No reader changed in `domain:cli` or `domain:services`. ## The pin, and a dispatch assumption it falsified The dispatch asked for a type-level test that the helper's return is *assignable* to the declared input, and asked that narrowing the declaration back should make it fail. **Plain assignability cannot fail here.** TypeScript refuses an undeclared key only on an object literal. A non-literal value with an extra optional key is assignable to a type that omits that key. That is how all ten readers compiled against `{ headers: unknown }` while sending `query`. This was measured in the ablation below. With the declaration narrowed back, a throwaway probe compiled with **0 errors**. The probe held `const x: DeclaredInput = inProcessSessionReadInput(new Headers())` and the readers' own call shape, `api.getSession?.(inProcessSessionReadInput(...))`. So the pin checks assignability **key for key**. `FitsDeclared` applies the object-literal rule to a type: - the value is assignable; - it carries no key the declaration does not name; - this holds at every depth where the declaration names a shape (`headers` is `unknown`, so it is not looked into); - a union is judged one member at a time, never by its common keys. The file carries: - three `holds(true)` lines, each typed with `FitsDeclared` applied to `HelperInput` of a header type and `DeclaredInput`. There is one line per header shape the readers hand the helper: Web `Headers`, a Node header record, and `unknown`; - four `@ts-expect-error` controls that prove the instrument can fail at all. If `FitsDeclared` ever went vacuous, each directive would stop matching an error and tsc would report TS2578; - one runtime case: an implementer typed by the contract reads `input.query?.disableRefresh`. That read is itself a compile-time check, and it asserts a cookie read does not renew while a bearer read does. **Mechanism.** The test runs under the package's existing `typecheck` script (`tsc --noEmit`). `packages/types/tsconfig.json` includes `src/**/*`, tests included, and `--listFiles` lists the new file once. CI's `TypeScript Type Check` runs it, and it reads `@objectstack/spec` from its built `.d.ts`. This follows the `@ts-expect-error` compile-time pins already in `response-envelope.test.ts`. No new runner was added. ## Reverse verification (one-off; nothing kept) The run is at `769d9f4db`, after the fix was committed. It used `scripts/ablation-replace.mjs` in wrap mode and `scripts/ablation-dist-preflight.mjs`, under the shared verify lock. The predicted direction was red on the three `holds` and on the `query` read, with the controls unchanged. That is what happened. | leg | reading | |---|---| | pristine dist | marker `disableRefresh?: boolean` present in `dist/contracts/index.d.ts` and `.d.mts`; tree clean | | mutate | anchor `{ headers: unknown; query?: { disableRefresh?: boolean } }` x1 -> x0; blob `698dd54bd9e8` -> `2df53d7829bd`; spec rebuilt (exit 0); preflight `--absent`: marker absent from all 232 built files | | `tsc --noEmit` in `packages/types`, mutated | **exit 2**: `TS2344` at `:76`, `:77`, `:78` (the three `holds`), `TS2339` at `:96` (`Property 'query' does not exist on type '{ headers: unknown; }'`); **0** errors in the plain-assignability probe | | restore | blob after restore `698dd54bd9e8` == HEAD; `git diff HEAD` empty; spec rebuilt; preflight (present) green; tree clean | | `tsc --noEmit` in `packages/types`, restored | **exit 0** | The head after that run (`57d9d9a8c`) changes only docblock text in `auth-service.ts`: 6 lines added and 5 removed, all inside the comment. The declaration line is byte-identical. ## Consumers Every consumer that types against `AuthSessionApi` keeps compiling, and none was edited: - The ten readers pass `inProcessSessionReadInput(...)`. That input is now declared instead of tolerated. - The readers that still pass `{ headers }` by hand: `plugin-auth` (x4), `plugin-webhooks`, `plugin-sharing`, `service-storage`, `service-settings`, `service-datasource`, and the dogfood `armed.ts` harness. `query` is optional, so they type as before. Moving them to the helper is #22258's remaining half and is not done here. - An implementer of `getSession` that declares `{ headers: unknown }` still satisfies the contract, because the wider input is assignable to it. The `typecheck` of both edited packages is green (below). The downstream consumer typecheck is left to CI's `TypeScript Type Check`. ## Verification At `57d9d9a8c` (final head): - **Build.** `pnpm exec turbo run build --filter='!@objectstack/docs' --concurrency=2`: 72 of 72 tasks successful, and `git status --porcelain` was empty afterwards. `packages/spec/dist/contracts/index.d.ts` carries the new declaration and docblock. - **`@objectstack/types`.** - `typecheck`: exit 0. `tsc --noEmit --listFiles` lists `in-process-session-read.contract.test.ts` once. - `test` (local): 26 files, 750 tests passed. - `test:repo`: 1 file, 11 tests passed. - The pin file run alone: 1 file, 1 test passed. - **`@objectstack/spec`.** - `typecheck`: exit 0. This covers `tsc --noEmit`, `check:scripts-typecheck` and `check:test-typecheck`. - `test:repo`: 54 files, 915 tests passed. - `test` (local, `--maxWorkers=2`): 626 files, 18742 tests passed, 1 todo. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 85 commands at `57d9d9a8c`. All 85 ran, each exit code captured before any pipe, and all 85 exited 0. The `--ran` reconciliation reads: "85 derived famil(ies) accounted for — 85 run, 0 NOT-MEASURED (a DERIVED zero — all 85 recorded an exit code and none of them is 3)". Selected verdict lines: - `check:api-surface`: "public API surface + factory signatures unchanged". - `check-adr-0087-registration`: "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)". - `check:nul-bytes`: OK. - `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:doc-formula-expressions` were measured after the full build. Their earlier runs at `769d9f4db` exited 3 ("prerequisite not met"); those runs are not counted. - **`check-changeset-no-major --base origin/main`.** Run locally, it prints "LEVEL AXIS: NOT APPLICABLE" because there is no `pull_request` payload. The reading of `Clause-②: yes (widening)` against `minor` is CI's on this PR. - **Lint, narrowed (CI owns the full run).** I ran `pnpm exec eslint --no-inline-config --format json` on the two changed `.ts` files: 2 files, 0 errors, 0 warnings. The `.changeset` file falls outside eslint's config (eslint reports it as ignored). `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`; see its comment near `:326`), so this diff cannot change a verdict on any untouched file. - **Base.** The branch is 8 commits behind `origin/main` (`11d119ab1`). None of those commits touches `packages/spec/src/contracts/` or `packages/types/`, and the merge queue rebuilds the merged generation. ## Acceptance notes - **The declaration's value is `boolean`, but the helper only ever sends `true`.** This follows the card and the claim. `FitsDeclared` accepts `true` against `boolean`. A helper that started sending `false` would also fit, and `false` would mean renewal. - **The docblock names files rather than lines.** The call sites move often, so a line number would go stale on the next edit. The pin names the helper, not the readers, and nothing checks the list of ten. A reader added or moved later leaves the list stale but the declaration correct. - `check:entry-nameability` prints a standing `NOT MEASURED` for `@objectstack/spec/api-assembled` and `@objectstack/spec/qa` (no callable export). This is unrelated to `contracts`. The gate exits 0. --- _Generated by [Claude Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 83e7ae9 commit e75dced

3 files changed

Lines changed: 145 additions & 5 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
`AuthSessionApi.getSession` (`@objectstack/spec/contracts`) now declares the optional `query.disableRefresh` that the in-process session readers send
6+
7+
Clause-②: yes (widening)
8+
9+
- **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.
10+
- **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.
11+
- **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.

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

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,13 +169,36 @@ export interface IAuthService {
169169
* The slice of the session API the platform actually uses.
170170
*
171171
* [#4127 batch 4] Deliberately NOT a re-declaration of better-auth's handle,
172-
* which is far wider and belongs to that library. Every dispatcher-side reader
173-
* calls exactly `getSession({ headers })` and reads exactly the fields below,
174-
* so that is what is declared. Widening this is for whoever needs more, with
175-
* the call site to prove it.
172+
* which is far wider and belongs to that library. What is declared is exactly
173+
* the input the in-process readers send and exactly the fields below that they
174+
* read. Widening this is for whoever needs more, with the call site to prove it.
175+
*
176+
* [#22384] `getSession`'s input has two read forms, decided by what the
177+
* request carries:
178+
*
179+
* - a request carrying a better-auth session cookie (a browser) reads with
180+
* `{ headers, query: { disableRefresh: true } }` — the session then renews
181+
* only on the `get-session` route, which re-issues the cookie, so cookie
182+
* expiry and session expiry cannot split;
183+
* - a bearer-only request (the SDK outside a browser, the CLI) reads with
184+
* `{ headers }` alone, renewal included.
185+
*
186+
* Their one source is `inProcessSessionReadInput(headers)` in
187+
* `@objectstack/types` (`packages/types/src/in-process-session-read.ts`):
188+
* read with `api.getSession(inProcessSessionReadInput(headers))`, not with a
189+
* hand-built input — a hand-built `{ headers }` on a cookie-carrying request
190+
* renews the session behind a cookie nobody re-issues. `query` is declared
191+
* because readers send it (#22258): `rest-server.ts` in `@objectstack/rest`
192+
* (twice); in `@objectstack/runtime`, `http-dispatcher.ts` (twice),
193+
* `security/resolve-session-principal.ts` and
194+
* `security/resolve-execution-context.ts`; `current-user-endpoints.ts` in
195+
* `@objectstack/plugin-hono-server`; and in `@objectstack/cloud-connection`,
196+
* `cloud-connection-plugin.ts` and `marketplace-install-local-plugin.ts`
197+
* (twice). `packages/types/src/in-process-session-read.contract.test.ts`
198+
* holds the helper's return inside this declaration, key for key.
176199
*/
177200
export interface AuthSessionApi {
178-
getSession?(input: { headers: unknown }): Promise<{
201+
getSession?(input: { headers: unknown; query?: { disableRefresh?: boolean } }): Promise<{
179202
user?: { id?: string };
180203
session?: { userId?: string; activeOrganizationId?: string };
181204
} | undefined>;
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22384] The in-process session-read input stays inside the contract that
5+
* declares it.
6+
*
7+
* Every in-process reader calls `api.getSession(inProcessSessionReadInput(headers))`
8+
* with `api` typed by `AuthSessionApi` (`@objectstack/spec/contracts`), so the
9+
* helper's return IS what that declaration receives. This file holds the two
10+
* together: every key the helper returns is a key the declaration names. It
11+
* lives in this package because only here are both visible — the helper is
12+
* this package's, the contract is its dependency's, and `@objectstack/spec`
13+
* must not import this package.
14+
*
15+
* ## Why plain assignability is not the check
16+
*
17+
* TypeScript refuses an undeclared key only on an object LITERAL. A
18+
* non-literal carrying an extra optional key is assignable to a type that
19+
* omits it, so `const input: DeclaredInput = inProcessSessionReadInput(headers)`
20+
* compiles against a declaration of `{ headers: unknown }` alone — which is how
21+
* the readers compiled while the declaration did not describe what they sent.
22+
* `FitsDeclared` applies the literal's rule to a type: assignable, and no key
23+
* the declaration does not name, at every depth where the declaration names a
24+
* shape (`headers` is declared `unknown`, so it is not looked into).
25+
*
26+
* ## Where it bites
27+
*
28+
* At compile time. This package's `typecheck` (`tsc --noEmit`) compiles this
29+
* file, reading the contract from `@objectstack/spec`'s BUILT `.d.ts`. A
30+
* declaration that stops naming `query` or `disableRefresh`, or a helper that
31+
* starts sending a key the declaration does not name, turns a `holds` below
32+
* into TS2344, and reading `query` off the declared input in the runtime case
33+
* into TS2339. The `@ts-expect-error` controls prove the instrument can fail at
34+
* all: if `FitsDeclared` went vacuous, each directive would stop matching an
35+
* error and tsc would report TS2578.
36+
*/
37+
38+
import { expect, it } from 'vitest';
39+
import type { AuthSessionApi } from '@objectstack/spec/contracts';
40+
import { inProcessSessionReadInput } from './in-process-session-read.js';
41+
42+
/** What `AuthSessionApi.getSession` declares it accepts. */
43+
type DeclaredInput = Parameters<NonNullable<AuthSessionApi['getSession']>>[0];
44+
45+
/** What the helper returns for a reader's headers of type `H`. */
46+
type HelperInput<H> = ReturnType<typeof inProcessSessionReadInput<H>>;
47+
48+
/** `Out` fits `In` the way an object literal must; `true` or `false`, never both. */
49+
type FitsDeclared<Out, In> = [Fits<Out, In>] extends [true] ? true : false;
50+
51+
/**
52+
* One verdict per union member of `Out` — a union is judged member by member,
53+
* never by its common keys — and per declared key below it. The verdicts union
54+
* together, and `FitsDeclared` reads a single `false` among them as a miss.
55+
*/
56+
type Fits<Out, In> = Out extends unknown
57+
? [Out] extends [In]
58+
? unknown extends In
59+
? true
60+
: [Out] extends [object]
61+
? [Exclude<keyof Out, keyof In>] extends [never]
62+
? { [K in keyof Out & keyof In]-?: Fits<Exclude<Out[K], undefined>, Exclude<In[K], undefined>> }[keyof Out & keyof In]
63+
: false
64+
: true
65+
: false
66+
: never;
67+
68+
/** Compiles only when its type argument is `true`. */
69+
function holds<T extends true>(verdict: T): T {
70+
return verdict;
71+
}
72+
73+
// The pin, for each header shape the readers hand the helper: a Web `Headers`
74+
// (`c.req.raw.headers`, the dispatcher's), a Node header record (`req.headers`
75+
// on the Node adapters), and an untyped one.
76+
holds<FitsDeclared<HelperInput<Headers>, DeclaredInput>>(true);
77+
holds<FitsDeclared<HelperInput<Record<string, string | string[] | undefined>>, DeclaredInput>>(true);
78+
holds<FitsDeclared<HelperInput<unknown>, DeclaredInput>>(true);
79+
80+
// The instrument's controls: each of these MUST fail to compile.
81+
// @ts-expect-error a top-level key the declaration does not name
82+
holds<FitsDeclared<{ headers: unknown; cookie: string }, DeclaredInput>>(true);
83+
// @ts-expect-error a key under `query` the declaration does not name
84+
holds<FitsDeclared<{ headers: unknown; query: { disableRefresh: true; notDeclared: true } }, DeclaredInput>>(true);
85+
// @ts-expect-error a union member with an undeclared key, beside one without
86+
holds<FitsDeclared<{ headers: unknown } | { headers: unknown; cookie: string }, DeclaredInput>>(true);
87+
// @ts-expect-error a declared key with a value the declaration does not accept
88+
holds<FitsDeclared<{ headers: unknown; query: { disableRefresh: 'yes' } }, DeclaredInput>>(true);
89+
90+
it('[#22384] an implementer typed by the contract reads the renewal decision the helper made', async () => {
91+
const renews: boolean[] = [];
92+
const api: AuthSessionApi = {
93+
async getSession(input) {
94+
// `input` is the DECLARED input: on a declaration without `query`
95+
// this read is a compile error, not an `undefined`.
96+
renews.push(input.query?.disableRefresh !== true);
97+
return undefined;
98+
},
99+
};
100+
101+
await api.getSession?.(inProcessSessionReadInput(new Headers({ cookie: 'better-auth.session_token=tok3n.c2ln' })));
102+
await api.getSession?.(inProcessSessionReadInput(new Headers({ authorization: 'Bearer tok3n.c2ln' })));
103+
104+
// A browser's read does not renew; a bearer-only read renews as before.
105+
expect(renews).toEqual([false, true]);
106+
});

0 commit comments

Comments
 (0)