Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/22384-auth-session-api-getsession-input-query.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 28 additions & 5 deletions packages/spec/src/contracts/auth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down
106 changes: 106 additions & 0 deletions packages/types/src/in-process-session-read.contract.test.ts
Original file line number Diff line number Diff line change
@@ -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<NonNullable<AuthSessionApi['getSession']>>[0];

/** What the helper returns for a reader's headers of type `H`. */
type HelperInput<H> = ReturnType<typeof inProcessSessionReadInput<H>>;

/** `Out` fits `In` the way an object literal must; `true` or `false`, never both. */
type FitsDeclared<Out, In> = [Fits<Out, In>] 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, In> = Out extends unknown
? [Out] extends [In]
? unknown extends In
? true
: [Out] extends [object]
? [Exclude<keyof Out, keyof In>] extends [never]
? { [K in keyof Out & keyof In]-?: Fits<Exclude<Out[K], undefined>, Exclude<In[K], undefined>> }[keyof Out & keyof In]
: false
: true
: false
: never;

/** Compiles only when its type argument is `true`. */
function holds<T extends true>(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<FitsDeclared<HelperInput<Headers>, DeclaredInput>>(true);
holds<FitsDeclared<HelperInput<Record<string, string | string[] | undefined>>, DeclaredInput>>(true);
holds<FitsDeclared<HelperInput<unknown>, 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<FitsDeclared<{ headers: unknown; cookie: string }, DeclaredInput>>(true);
// @ts-expect-error a key under `query` the declaration does not name
holds<FitsDeclared<{ headers: unknown; query: { disableRefresh: true; notDeclared: true } }, DeclaredInput>>(true);
// @ts-expect-error a union member with an undeclared key, beside one without
holds<FitsDeclared<{ headers: unknown } | { headers: unknown; cookie: string }, DeclaredInput>>(true);
// @ts-expect-error a declared key with a value the declaration does not accept
holds<FitsDeclared<{ headers: unknown; query: { disableRefresh: 'yes' } }, DeclaredInput>>(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]);
});
Loading