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
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"id": "adr:2026-09-07-centralize-stale-401-replay-authority-resolution",
"alternatives": "Let each transport adapter refresh and compare tokens independently; replay whenever any replacement access token is available.",
"anchors": [
{
"hash": "00fdd4c39886ddda1b2894ea7840e1318f09ee3b871d0319a66eff18bce83e8d",
"kind": "Function",
"name": "resolveSessionForReplay",
"node_id": "rs1:7dc1dbe8d512:func:src/session-continuity.ts#resolveSessionForReplay@3",
"path": "src/session-continuity.ts"
}
],
"body_hash": "v2:4525b2559bef1708547e1890c80eb0981a739792035c7f172f200f8ea806611e",
"consequences": "Adapters share one authority-preserving concurrency contract. Unavailable identity authority blocks replay while retaining the credential. Same-user token rotation remains replayable after validation.",
"context": "Multiple transport adapters handle 401 responses while session credentials can rotate concurrently. Duplicating refresh and replay checks in each adapter permits a stale request to refresh, clear, or replay under a replacement user's session.",
"decided_at": "2026-09-07",
"decided_by": "agent",
"decision": "Route 401 recovery through resolveSessionForReplay. Capture validated user and organisation authority before transport, resolve refresh centrally, and require a final synchronous canReplaySession check immediately before retry transport.",
"paths": [
"src/session-continuity.ts",
"src/auth-store.ts",
"src/auth-client.ts"
],
"status": "proposed",
"supersedes": [],
"title": "Centralize stale-401 replay authority resolution",
"trigger": {
"kind": "manual"
}
}
1 change: 1 addition & 0 deletions .reposkein/summaries/12.jsonl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .reposkein/summaries/db.jsonl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .reposkein/summaries/fd.jsonl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,13 @@ The dev-bypass internals (`DevSignInBypass`, `DEV_LOGIN_EMAIL_STORAGE_KEY`,

### Core API

- `createAuthStore({ baseUrl })` — holds the access token in a **module-closure variable, not `localStorage`**; durability across reloads comes from the backend's HttpOnly refresh cookie, which `performRefresh()` sends with `credentials: "include"`. `AuthStoreConfig` is `{ baseUrl, refreshPath?, refreshBuffer? }` — there is no storage-adapter seam. Also exposes `devLogin(email)` (CEL-1364) — see "Dev sign-in bypass".
- `createAuthStore({ baseUrl })` — holds the access token in a **module-closure variable, not `localStorage`**; durability across reloads comes from the backend's HttpOnly refresh cookie, which `performRefresh()` sends with `credentials: "include"`. `AuthStoreConfig` is `{ baseUrl, refreshPath?, refreshBuffer?, resolutionTimeoutMs? }` — there is no storage-adapter seam. Also exposes `devLogin(email)` (CEL-1364) — see "Dev sign-in bypass".
- `createAuthClient({ baseUrl, store, onAuthFailure })` — fetch wrapper, auto-attaches Bearer, calls `onAuthFailure` on 401.

Every package-owned request requires HTTPS. HTTP is accepted automatically
only for exact parsed loopback hosts `localhost`, `127.0.0.1`, and `[::1]` for
local development. Requests reject redirects and cannot escape a configured
base URL path.
- `createAuthApi({ client, store })` — typed login/register/logout helpers.
- `validateUserType(userType)` — `"producer" | "importer" | "distributor" | "admin"`.

Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## 0.15.0

### Added
- Session resolution APIs: `resolveSession`, `getSessionState`, `onSessionStateChange`, continuity capture, and guarded 401 replay. Refresh and identity resolution are generation-checked, single-flight, and bounded by `resolutionTimeoutMs`.
- `VerifyOtpUser` models the sparse user projection returned by `/auth/verify-otp`; `/auth/me` continues to return fully validated `AuthUser`.

### Changed
- Package-owned auth requests require HTTPS. Exact loopback hosts `localhost`, `127.0.0.1`, and `[::1]` retain automatic HTTP support for local development. Unsafe URLs and redirects fail before credentials can leave the configured origin and base path.
- Session-facing `userType` accepts `"distributor"` and nullable profile values. Consumers must handle `null` before portal routing.

### Breaking changes
- `VerifyOtpResponse.user` is now `VerifyOtpUser`, which omits `createdAt` and `entitlements`. Code requiring those full-profile fields must call `getMe()` or resolve the session.
- `AuthUser.userType` now permits `"distributor"` and `null`. Exhaustive switches and portal routing must handle both values.
- Protected startup and 401 retry flows should use the session-resolution API so token, user, and organisation authority come from one guarded generation.

## 0.14.0

### Added
Expand Down
64 changes: 62 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ npm install @cellarnode/auth
### Core (framework-agnostic)

```ts
import { createAuthStore, createAuthClient, createAuthApi } from "@cellarnode/auth";
import {
canReplaySession,
captureSessionContinuity,
createAuthApi,
createAuthClient,
createAuthStore,
resolveSessionForReplay,
} from "@cellarnode/auth";

const authStore = createAuthStore({
baseUrl: "http://localhost:4000",
Expand All @@ -28,6 +35,59 @@ const authClient = createAuthClient({
const authApi = createAuthApi({ client: authClient, store: authStore });
```

All package-owned requests require HTTPS. HTTP works automatically only for
exact loopback hosts `localhost`, `127.0.0.1`, and `[::1]` during local
development. Invalid URLs, userinfo, other schemes, non-loopback HTTP, and
request paths that escape the configured origin or base path fail before
network transport. Redirects are rejected so credentials cannot follow a
cross-origin or HTTPS-to-HTTP redirect.

Resolve token and authoritative identity together before enabling protected
work. `unavailable` preserves credentials while authority getters fail closed.
Caller abort returns `superseded` without cancelling shared adoption.

```ts
const session = await authStore.resolveSession({ refresh: true, signal });
if (session.status === "ready") {
// session.token and validated session.user are from one guarded generation
}
```

`ensureAccessToken()` remains a legacy credential-only wrapper. It may return a
retained token while authority is transiently `unavailable`; authority getters
still fail closed. Never use its token as proof that user or organisation
authority is ready. Use `resolveSession()` and require `status === "ready"` for
authorization decisions.

`onSessionStateChange` immediately reports current state. A synchronous
`resolving` notification precedes credential or authority changes, allowing
consumers to suspend writes and clear captured tenant queries first.
`getSessionState()` returns the same guarded state as a defensive snapshot;
continuity capture requires that snapshot to remain `ready`. Internal refresh
and identity requests time out after 10 seconds by default; configure
`resolutionTimeoutMs` on `createAuthStore` when needed.

Authenticated transports that retry after 401 must capture continuity before
their first request and replay only for same validated user and organisation:

```ts
const before = captureSessionContinuity(authStore);
if (!before) throw new Error("Session authority unavailable");
const response = await fetch(url, init);
if (response.status === 401) {
const after = await resolveSessionForReplay(authStore, before, {
signal: init.signal,
});
if (canReplaySession(before, after, authStore)) {
// retry once with after.token
}
}
```

`resolveSessionForReplay` centralizes stale-401 handling. It never refreshes a
replacement session. A ready rotated token can be reused once only when
validated user and organisation still match captured request authority.

### React Components

```tsx
Expand Down Expand Up @@ -84,7 +144,7 @@ Add this to your CSS file so Tailwind picks up utility classes from the package:

## Exports

- `@cellarnode/auth` — Core: `createAuthStore`, `createAuthClient`, `createAuthApi`, `validateUserType`, `hasEntitlement`, `extractAccessToken`, `AuthError`, types (incl. `DevLoginResult`)
- `@cellarnode/auth` — Core: `createAuthStore`, `createAuthClient`, `createAuthApi`, `captureSessionContinuity`, `resolveSessionForReplay`, `canReplaySession`, `validateUserType`, `hasEntitlement`, `extractAccessToken`, `AuthError`, session-resolution types, and `DevLoginResult`
- `@cellarnode/auth/react` — React: `LoginForm`, `RegisterForm`, `UnauthorizedPage`, `SquircleShift`, `InputOTP` (+ `Group` / `Slot` / `Separator`)

`DevSignInBypass`, `DEV_LOGIN_EMAIL_STORAGE_KEY`, `readDevLoginEmail` and
Expand Down
Loading
Loading