diff --git a/AGENTS.md b/AGENTS.md index cb84447..b182164 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,8 @@ 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?, resolutionTimeoutMs? }` — 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?, productFamily? }` — there is no storage-adapter seam. Also exposes `devLogin(email)` (CEL-1364) — see "Dev sign-in bypass". +- `productFamily: "producer" | "elabel"` on `createAuthStore` (CEL-1722) — declares the store's **session family**: the store stamps `X-CellarNode-Family` on every `/auth/refresh`, exposes `getProductFamily()` (which `verifyOtp` reads to add `productFamily` to the login body), and the backend partitions refresh chains/cookies per family (`cn_rt_producer` / `cn_rt_elabel`). Producer and e-label each create their own store with their own family; importer stays family-less (legacy `refresh_token` cookie, exact legacy wire shape). Helpers `SESSION_FAMILY_HEADER`, `refreshCookieNameFor`, and `withProductFamily(body, family)` are exported for consumers that call `/auth/registration/session` directly. - `createAuthClient({ baseUrl, store, onAuthFailure })` — fetch wrapper, auto-attaches Bearer, calls `onAuthFailure` on 401. Every package-owned request requires HTTPS. HTTP is accepted automatically @@ -175,9 +176,9 @@ OTP flow against backend V2 public API (port 4000): | Method | Path | Purpose | |---|---|---| | POST | `/auth/otp/request` | Send OTP via SendGrid | -| POST | `/auth/otp/verify` | Exchange OTP for JWE access + refresh tokens | -| POST | `/auth/refresh` | Rotate access token (replay-detection revokes session) | -| POST | `/auth/logout` | Revoke session in Redis (`cellarnode:session:*`) | +| POST | `/auth/otp/verify` | Exchange OTP for JWE access + refresh tokens. Optional `productFamily: "producer" \| "elabel"` body field (CEL-1722) → family-stamped session + family-scoped refresh cookie. | +| POST | `/auth/refresh` | Rotate access token (replay-detection revokes session). Optional `X-CellarNode-Family` header (CEL-1722): server reads that family's cookie (legacy `refresh_token` stays the read fallback) and grants the bounded same-family lost-response grace window. | +| POST | `/auth/logout` | Revoke session in Redis (`cellarnode:session:*`). CEL-1722: clears only the current session family's refresh cookie — the other dashboard stays signed in. Sign out everywhere is the backend's `revokeAllUserSessions` (admin path; no public endpoint yet). | | GET | `/auth/me` | Current user; backend `authGuard()` accepts EITHER Bearer JWE (OTP path) OR cookie (admin BFF path). Cookie wins. | | POST | `/test/login` | LOCAL DEV ONLY (CEL-1364). Body `{ email }` → `{ accessToken, userId, orgId }` + the OTP flow's refresh cookies. 404s uniformly unless the API runs with `ENABLE_TEST_ENDPOINTS=true` outside production. | diff --git a/__tests__/auth-api.test.ts b/__tests__/auth-api.test.ts index 907b0e9..d2795b0 100644 --- a/__tests__/auth-api.test.ts +++ b/__tests__/auth-api.test.ts @@ -390,4 +390,70 @@ describe("createAuthApi", () => { expect.objectContaining({ method: "POST", skipAuth: true }), ); }); + + it("signOutEverywhere calls POST /auth/sessions/revoke-all with the current access token and clears the store", async () => { + const client = mockClient(); + const store = mockStore(); + (store.getAccessToken as ReturnType).mockReturnValue("tok_live"); + (client.fetch as ReturnType).mockResolvedValue({ + success: true, + revokedSessions: 3, + }); + + const api = createAuthApi({ client, store }); + const result = await api.signOutEverywhere(); + + expect(result).toEqual({ revokedSessions: 3 }); + expect(client.fetch).toHaveBeenCalledWith( + "/auth/sessions/revoke-all", + { + method: "POST", + skipAuth: true, + headers: { Authorization: "Bearer tok_live" }, + }, + ); + expect(store.clearAccessToken).toHaveBeenCalledTimes(1); + }); + + it("signOutEverywhere defaults revokedSessions to 0 when the response omits it", async () => { + const client = mockClient(); + (client.fetch as ReturnType).mockResolvedValue({ + success: true, + }); + + const api = createAuthApi({ client, store: mockStore() }); + const result = await api.signOutEverywhere(); + + expect(result).toEqual({ revokedSessions: 0 }); + }); + + it("signOutEverywhere clears local credentials and rethrows on 401", async () => { + const client = mockClient(); + const store = mockStore(); + (store.getAccessToken as ReturnType).mockReturnValue("tok_dead"); + (client.fetch as ReturnType).mockRejectedValue( + new AuthError(401, "UNAUTHORIZED", "Session is unauthorized"), + ); + + const api = createAuthApi({ client, store }); + await expect(api.signOutEverywhere()).rejects.toMatchObject({ + status: 401, + code: "UNAUTHORIZED", + }); + expect(store.clearAccessToken).toHaveBeenCalledTimes(1); + }); + + it("signOutEverywhere does not clear the store on non-401 failures", async () => { + const client = mockClient(); + const store = mockStore(); + (client.fetch as ReturnType).mockRejectedValue( + new AuthError(503, "NETWORK", "API unreachable"), + ); + + const api = createAuthApi({ client, store }); + await expect(api.signOutEverywhere()).rejects.toMatchObject({ + status: 503, + }); + expect(store.clearAccessToken).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/session-family.test.ts b/__tests__/session-family.test.ts new file mode 100644 index 0000000..5daf5e0 --- /dev/null +++ b/__tests__/session-family.test.ts @@ -0,0 +1,392 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createAuthStore } from "../src/auth-store.js"; +import { createAuthClient } from "../src/auth-client.js"; +import { createAuthApi } from "../src/auth-api.js"; +import type { AuthUser } from "../src/types.js"; + +/** + * CEL-1722 — client half of session families. + * + * Producer and e-label dashboards run independent `@cellarnode/auth` stores + * against the same public API on the same browser origin. Backend PR + * cellarnode-backend-v2#709 partitions refresh chains by a client-declared + * family: + * + * - `productFamily` in the `/auth/verify-otp` (+ `/auth/registration/session`) + * body at login → family-stamped session + family-scoped refresh cookie + * (`cn_rt_producer` / `cn_rt_elabel`). + * - `X-CellarNode-Family` header on `POST /auth/refresh` → server reads the + * family-scoped cookie (legacy `refresh_token` name stays the read + * fallback), and grants the bounded lost-response grace window. + * - Family-less clients (importer, admin, pre-upgrade) keep the exact legacy + * wire shape: no header, no body field. + * + * These tests drive the real store/client/api with an intercepted transport + * and a shared cookie jar to prove the two families cannot clobber each other. + */ + +const baseMe: AuthUser = { + id: "user_123", + email: "alice@example.com", + name: "Alice", + userType: "producer", + orgId: "org_1", + roles: ["member"], + entitlements: ["producer-dashboard", "elabel"], + createdAt: "2024-01-01T00:00:00.000Z", +}; + +interface CapturedRequest { + path: string; + method: string; + headers: Headers; + body: unknown; +} + +function jsonResponse(body: unknown, ok = true, status = 200) { + return { + ok, + status, + json: () => Promise.resolve(body), + }; +} + +/** + * Emulated shared browser cookie jar + refresh-token backend. + * + * The jar is keyed by cookie NAME, exactly like a browser cookie store, so + * `cn_rt_producer` and `cn_rt_elabel` (and the legacy `refresh_token`) are + * independent slots. The refresh handler plays the CEL-1722 server role: + * it reads the family from `X-CellarNode-Family`, consumes the matching + * cookie, and rotates it in place. A family-less request reads the legacy + * cookie. + */ +function createFamilyBackend( + opts: { me?: AuthUser | null } = {}, +): ReturnType { + return buildBackend({ me: "me" in opts ? opts.me : baseMe }); +} + +function buildBackend(opts: { me?: AuthUser | null }) { + const jar = new Map(); + const requests: CapturedRequest[] = []; + + const fetchMock = vi.fn((url: string | URL | Request, init?: RequestInit) => { + const path = typeof url === "string" ? url : new URL(url.toString()).pathname; + let headers: Headers; + let body: unknown = null; + if (init && typeof init.body === "string") { + try { + body = JSON.parse(init.body); + } catch { + body = init.body; + } + } + if (init?.headers instanceof Headers) { + headers = init.headers; + } else { + headers = new Headers((init?.headers as Record) ?? {}); + } + requests.push({ + path, + method: init?.method ?? "GET", + headers, + body, + }); + + if (path.endsWith("/auth/me")) { + if (opts.me == null) { + return Promise.resolve(jsonResponse({ error: "unauthorized" }, false, 401)); + } + return Promise.resolve(jsonResponse(opts.me)); + } + + if (path.endsWith("/auth/refresh")) { + const declared = headers.get("x-cellarnode-family"); + const cookieName = + declared === "producer" + ? "cn_rt_producer" + : declared === "elabel" + ? "cn_rt_elabel" + : "refresh_token"; + const presented = jar.get(cookieName); + if (!presented) { + return Promise.resolve( + jsonResponse({ error: "No refresh token" }, false, 401), + ); + } + const rotated = `rt_${cookieName}_${Math.random().toString(36).slice(2, 8)}`; + jar.set(cookieName, rotated); + return Promise.resolve( + jsonResponse({ accessToken: `tok_${cookieName}`, expiresIn: 900 }), + ); + } + + if (path.endsWith("/auth/verify-otp")) { + const bodyObj = (body ?? {}) as Record; + const family = bodyObj.productFamily; + const cookieName = + family === "producer" + ? "cn_rt_producer" + : family === "elabel" + ? "cn_rt_elabel" + : "refresh_token"; + jar.set(cookieName, `rt_${cookieName}_mint`); + return Promise.resolve( + jsonResponse({ + accessToken: `tok_verify_${cookieName}`, + expiresIn: 900, + user: { + id: baseMe.id, + email: baseMe.email, + name: baseMe.name, + userType: baseMe.userType, + orgId: baseMe.orgId, + roles: baseMe.roles, + }, + }), + ); + } + + if (path.endsWith("/auth/logout")) { + // CEL-1722 server: ordinary logout clears only the CURRENT family's + // cookie (derived from the bearer session server-side, no header). + return Promise.resolve(jsonResponse({ success: true })); + } + + return Promise.resolve(jsonResponse({}, false, 404)); + }); + + return { jar, requests, fetchMock }; +} + +function lastRequest(requests: CapturedRequest[], path: string): CapturedRequest { + const found = [...requests].reverse().find((r) => r.path.endsWith(path)); + if (!found) throw new Error(`no captured request for ${path}`); + return found; +} + +describe("CEL-1722 session families", () => { + beforeEach(() => { + // per-test backend set up inside each test + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("family declaration on refresh (header)", () => { + it("sends X-CellarNode-Family on POST /auth/refresh when configured", async () => { + const backend = createFamilyBackend(); + global.fetch = backend.fetchMock as unknown as typeof fetch; + const store = createAuthStore({ + baseUrl: "http://localhost:4000", + productFamily: "producer", + }); + store.setAccessToken("tok_seed", 900); + // Let the explicit-adoption flight (identity read) settle first — a + // refresh requested mid-adoption joins the adoption per resolveSession. + await store.resolveSession(); + await store.resolveSession({ refresh: true }); + + const refreshReq = lastRequest(backend.requests, "/auth/refresh"); + expect(refreshReq.headers.get("x-cellarnode-family")).toBe("producer"); + }); + + it("sends the elabel family value for an elabel store", async () => { + const backend = createFamilyBackend(); + global.fetch = backend.fetchMock as unknown as typeof fetch; + const store = createAuthStore({ + baseUrl: "http://localhost:4000", + productFamily: "elabel", + }); + store.setAccessToken("tok_seed", 900); + // Let the explicit-adoption flight (identity read) settle first — a + // refresh requested mid-adoption joins the adoption per resolveSession. + await store.resolveSession(); + await store.resolveSession({ refresh: true }); + + const refreshReq = lastRequest(backend.requests, "/auth/refresh"); + expect(refreshReq.headers.get("x-cellarnode-family")).toBe("elabel"); + }); + + it("backward compat: family-less store sends NO family header", async () => { + const backend = createFamilyBackend(); + global.fetch = backend.fetchMock as unknown as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + store.setAccessToken("tok_seed", 900); + // Let the explicit-adoption flight (identity read) settle first — a + // refresh requested mid-adoption joins the adoption per resolveSession. + await store.resolveSession(); + await store.resolveSession({ refresh: true }); + + const refreshReq = lastRequest(backend.requests, "/auth/refresh"); + expect(refreshReq.headers.get("x-cellarnode-family")).toBeNull(); + }); + }); + + describe("family declaration at login (verify-otp body)", () => { + it("includes productFamily in the verify-otp body when the store declares one", async () => { + const backend = createFamilyBackend(); + global.fetch = backend.fetchMock as unknown as typeof fetch; + const store = createAuthStore({ + baseUrl: "http://localhost:4000", + productFamily: "elabel", + }); + const client = createAuthClient({ + baseUrl: "http://localhost:4000", + store, + }); + const api = createAuthApi({ client, store }); + + const result = await api.verifyOtp("alice@example.com", "123456"); + expect(result.accessToken).toBe("tok_verify_cn_rt_elabel"); + + const verifyReq = lastRequest(backend.requests, "/auth/verify-otp"); + expect(verifyReq.body).toEqual({ + email: "alice@example.com", + code: "123456", + productFamily: "elabel", + }); + }); + + it("backward compat: family-less verify-otp body carries no productFamily key", async () => { + const backend = createFamilyBackend(); + global.fetch = backend.fetchMock as unknown as typeof fetch; + const store = createAuthStore({ baseUrl: "http://localhost:4000" }); + const client = createAuthClient({ + baseUrl: "http://localhost:4000", + store, + }); + const api = createAuthApi({ client, store }); + + await api.verifyOtp("alice@example.com", "123456"); + + const verifyReq = lastRequest(backend.requests, "/auth/verify-otp"); + expect(verifyReq.body).toEqual({ + email: "alice@example.com", + code: "123456", + }); + expect( + (verifyReq.body as Record).productFamily, + ).toBeUndefined(); + }); + }); + + describe("per-family refresh coordination (concurrent stores)", () => { + it("producer and elabel stores refresh independent cookies without clobbering", async () => { + const backend = createFamilyBackend(); + global.fetch = backend.fetchMock as unknown as typeof fetch; + backend.jar.set("cn_rt_producer", "p1"); + backend.jar.set("cn_rt_elabel", "e1"); + backend.jar.set("refresh_token", "legacy1"); + + const producerStore = createAuthStore({ + baseUrl: "http://localhost:4000", + productFamily: "producer", + }); + const elabelStore = createAuthStore({ + baseUrl: "http://localhost:4000", + productFamily: "elabel", + }); + producerStore.setAccessToken("tok_p", 900); + elabelStore.setAccessToken("tok_e", 900); + await producerStore.resolveSession(); + await elabelStore.resolveSession(); + + // Interleaved refreshes — the exact simultaneous-bootstrap / + // simultaneous-expiry shape CEL-1722 requires to coexist. + const [producerResult, elabelResult] = await Promise.all([ + producerStore.resolveSession({ refresh: true }), + elabelStore.resolveSession({ refresh: true }), + ]); + + expect(producerResult.status).toBe("ready"); + expect(elabelResult.status).toBe("ready"); + + const producerReq = backend.requests.find( + (r) => + r.path.endsWith("/auth/refresh") && + r.headers.get("x-cellarnode-family") === "producer", + ); + const elabelReq = backend.requests.find( + (r) => + r.path.endsWith("/auth/refresh") && + r.headers.get("x-cellarnode-family") === "elabel", + ); + expect(producerReq).toBeDefined(); + expect(elabelReq).toBeDefined(); + + // Both family cookies survived rotation — neither refresh consumed the + // other family's cookie. + expect(backend.jar.has("cn_rt_producer")).toBe(true); + expect(backend.jar.has("cn_rt_elabel")).toBe(true); + expect(backend.jar.get("cn_rt_producer")).not.toBe("p1"); + expect(backend.jar.get("cn_rt_elabel")).not.toBe("e1"); + // Legacy cookie untouched by family-declared refreshes. + expect(backend.jar.get("refresh_token")).toBe("legacy1"); + }); + + it("legacy family-less store keeps working against the legacy cookie", async () => { + const backend = createFamilyBackend(); + global.fetch = backend.fetchMock as unknown as typeof fetch; + backend.jar.set("refresh_token", "legacy1"); + + const legacyStore = createAuthStore({ baseUrl: "http://localhost:4000" }); + legacyStore.setAccessToken("tok_legacy", 900); + await legacyStore.resolveSession(); + const result = await legacyStore.resolveSession({ refresh: true }); + + expect(result.status).toBe("ready"); + expect(backend.jar.get("refresh_token")).not.toBe("legacy1"); + }); + }); + + describe("logout semantics", () => { + it("ordinary logout posts /auth/logout with the bearer and no family header", async () => { + const backend = createFamilyBackend(); + global.fetch = backend.fetchMock as unknown as typeof fetch; + const store = createAuthStore({ + baseUrl: "http://localhost:4000", + productFamily: "producer", + }); + store.setAccessToken("tok_p", 900); + // let identity settle so client.fetch continuity capture works + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + const client = createAuthClient({ + baseUrl: "http://localhost:4000", + store, + }); + const api = createAuthApi({ client, store }); + await api.logout(); + + const logoutReq = lastRequest(backend.requests, "/auth/logout"); + expect(logoutReq.method).toBe("POST"); + // The server derives the family from the bearer session's own claim — + // the client must not need (and must not send) a family header here. + expect(logoutReq.headers.get("x-cellarnode-family")).toBeNull(); + expect(logoutReq.headers.get("authorization")).toBe("Bearer tok_p"); + }); + }); + + describe("store surface", () => { + it("exposes the configured family via getProductFamily()", () => { + const producer = createAuthStore({ + baseUrl: "http://localhost:4000", + productFamily: "producer", + }); + const elabel = createAuthStore({ + baseUrl: "http://localhost:4000", + productFamily: "elabel", + }); + const legacy = createAuthStore({ baseUrl: "http://localhost:4000" }); + expect(producer.getProductFamily?.()).toBe("producer"); + expect(elabel.getProductFamily?.()).toBe("elabel"); + expect(legacy.getProductFamily?.()).toBeNull(); + }); + }); +}); diff --git a/src/auth-api.ts b/src/auth-api.ts index d81fd82..84ad72d 100644 --- a/src/auth-api.ts +++ b/src/auth-api.ts @@ -9,6 +9,7 @@ import type { } from "./types.js"; import { extractAccessToken } from "./extract-token.js"; import { parseAuthUser, parseVerifyOtpUser } from "./auth-user.js"; +import { withProductFamily } from "./session-family.js"; export function createAuthApi(config: { client: AuthClient; @@ -42,7 +43,15 @@ export function createAuthApi(config: { { method: "POST", skipAuth: true, - body: JSON.stringify({ email, code }), + // CEL-1722: declare the store's product family at login so the + // backend stamps the session family and delivers the family-scoped + // refresh cookie. Family-less stores keep the exact legacy body. + body: JSON.stringify( + withProductFamily( + { email, code }, + store.getProductFamily?.() ?? null, + ), + ), }, ); @@ -83,6 +92,36 @@ export function createAuthApi(config: { }); }, + // CEL-1722: revoke every session in the family server-side (backend PR + // #713), then drop local credentials the same way ordinary logout does. + // A 401 means the access token is already dead, so local state is still + // cleared before the error propagates. + async signOutEverywhere() { + const token = store.getAccessToken(); + try { + const raw = await client.fetch<{ + success?: boolean; + revokedSessions?: number; + }>("/auth/sessions/revoke-all", { + method: "POST", + skipAuth: true, + ...(token + ? { headers: { Authorization: `Bearer ${token}` } } + : {}), + }); + store.clearAccessToken(); + return { + revokedSessions: + typeof raw.revokedSessions === "number" ? raw.revokedSessions : 0, + }; + } catch (error) { + if (error instanceof AuthError && error.status === 401) { + store.clearAccessToken(); + } + throw error; + } + }, + async getMe(token?: string) { const currentToken = store.getAccessToken(); const explicitCurrentToken = token !== undefined && token === currentToken; diff --git a/src/auth-store.ts b/src/auth-store.ts index 0e53d8c..4191444 100644 --- a/src/auth-store.ts +++ b/src/auth-store.ts @@ -1,6 +1,7 @@ import { copyAuthUser, parseAuthUser } from "./auth-user.js"; import { extractAccessToken } from "./extract-token.js"; import { fetchAuthRequest } from "./auth-transport.js"; +import { SESSION_FAMILY_HEADER } from "./session-family.js"; import type { AccessTokenSetListener, AuthStoreConfig, @@ -81,6 +82,7 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { revalidatePath = "/auth/revalidate", refreshBuffer = 60, resolutionTimeoutMs = DEFAULT_RESOLUTION_TIMEOUT_MS, + productFamily = null, } = config; const requestTimeoutMs = Number.isFinite(resolutionTimeoutMs) && resolutionTimeoutMs > 0 @@ -512,7 +514,16 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { result = await fetchResolutionResponse(refreshPath, { method: "POST", credentials: "include", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + // CEL-1722: family declaration routes the server to this family's + // scoped refresh cookie (legacy `refresh_token` stays the read + // fallback) and opts the request into the bounded lost-response + // grace window. Family-less stores send no header — legacy wire. + ...(productFamily + ? { [SESSION_FAMILY_HEADER]: productFamily } + : {}), + }, }); } catch { return markUnavailable(refreshGeneration, accessToken); @@ -939,6 +950,8 @@ export function createAuthStore(config: AuthStoreConfig): ConcreteAuthStore { }; }, + getProductFamily: () => productFamily, + getUserId: () => identity?.id ?? null, getOrgId: () => identity?.orgId ?? null, getUserType: () => identity?.userType ?? null, diff --git a/src/index.ts b/src/index.ts index 22f65e3..5f6dc69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,14 @@ export { createAuthClient } from "./auth-client.js"; export { createAuthApi } from "./auth-api.js"; export { validateUserType, hasEntitlement } from "./auth-guard.js"; export { extractAccessToken } from "./extract-token.js"; +export { + SESSION_FAMILIES, + SESSION_FAMILY_HEADER, + LEGACY_REFRESH_COOKIE_NAME, + isSessionFamily, + refreshCookieNameFor, + withProductFamily, +} from "./session-family.js"; export { captureSessionContinuity, canReplaySession, diff --git a/src/session-family.ts b/src/session-family.ts new file mode 100644 index 0000000..0226852 --- /dev/null +++ b/src/session-family.ts @@ -0,0 +1,68 @@ +/** + * Session families — client half of CEL-1722 (backend PR + * cellarnode-backend-v2#709, contract CEL-1718 §3). + * + * Producer and e-label dashboards run independent `@cellarnode/auth` stores + * against the same public API in one browser. A *session family* partitions + * each product's refresh-token chain so the two can coexist: + * + * - The client declares its family at login (`productFamily` in the + * `/auth/verify-otp` and `/auth/registration/session` bodies) and on + * every refresh (`X-CellarNode-Family` header). + * - The server delivers refresh cookies under family-scoped names + * (`cn_rt_producer` / `cn_rt_elabel`); the legacy shared `refresh_token` + * cookie remains the read fallback, so pre-upgrade sessions — and + * family-less (importer) clients — keep working unchanged. + * - Replay revocation is scoped to `userId` AND family server-side, and a + * family-declared lost-response retry gets a bounded idempotent-rotate + * grace window. The header declaration is what opts a client into both. + * + * A family is a session-partition key only — never a permission. Entitlements + * are still enforced per user by the backend. + */ + +/** The two concurrent product families. Importer/admin sessions stay family-less. */ +export const SESSION_FAMILIES = ["producer", "elabel"] as const; + +export type SessionFamily = (typeof SESSION_FAMILIES)[number]; + +export function isSessionFamily(value: unknown): value is SessionFamily { + return value === "producer" || value === "elabel"; +} + +/** Request header a family-aware client declares on refresh (CEL-1722). */ +export const SESSION_FAMILY_HEADER = "X-CellarNode-Family"; + +/** Shared cookie name used before session families existed (and by importer). */ +export const LEGACY_REFRESH_COOKIE_NAME = "refresh_token"; + +const FAMILY_COOKIE_NAMES: Record = { + producer: "cn_rt_producer", + elabel: "cn_rt_elabel", +}; + +/** + * Cookie name the server delivers a refresh token under for the given family. + * The cookies are HttpOnly — clients never read or write them directly; this + * mapping exists for diagnostics, tests, and consumer documentation. + */ +export function refreshCookieNameFor( + family: SessionFamily | null | undefined, +): string { + return family ? FAMILY_COOKIE_NAMES[family] : LEGACY_REFRESH_COOKIE_NAME; +} + +/** + * Merge a declared `productFamily` field into a login/registration body. + * + * Family-less (`undefined`) keeps the wire shape EXACTLY as before — the + * backend treats an absent field as a legacy/importer client. Consumers that + * call `/auth/registration/session` directly (this package does not wrap it) + * should spread this helper into their body. + */ +export function withProductFamily( + body: T, + productFamily: SessionFamily | null | undefined, +): T & { productFamily?: SessionFamily } { + return productFamily ? { ...body, productFamily } : { ...body }; +} diff --git a/src/types.ts b/src/types.ts index b69a741..25b5a1a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,7 @@ +import type { SessionFamily } from "./session-family.js"; + +export type { SessionFamily }; + export interface AuthUser { id: string; email: string; @@ -71,6 +75,20 @@ export interface AuthStoreConfig { refreshBuffer?: number; /** Maximum duration for internal refresh and identity requests. Default 10000ms. */ resolutionTimeoutMs?: number; + /** + * Product session family this store belongs to (CEL-1722): "producer" or + * "elabel". Producer and e-label dashboards each create their OWN store with + * their own family so their refresh chains (and the server-set refresh + * cookies `cn_rt_producer` / `cn_rt_elabel`) stay independent on the same + * origin. + * + * When set, the store declares the family on every `POST /auth/refresh` via + * the `X-CellarNode-Family` header and exposes it via `getProductFamily()` + * (which `createAuthApi().verifyOtp` reads to stamp `productFamily` into + * the login body). Omit it for family-less products (importer) and legacy + * behavior — no header, no body field, legacy `refresh_token` cookie. + */ + productFamily?: SessionFamily; } export interface RevalidateSessionOptions { @@ -239,6 +257,15 @@ export interface AuthStore { */ devLogin?(email: string): Promise; + /** + * Product session family this store was configured with (CEL-1722), or null + * for a family-less (importer/legacy) store. Optional on the interface so + * custom stores stay source-compatible; `createAuthStore()` always provides + * it. `createAuthApi().verifyOtp` reads it to declare `productFamily` at + * login. + */ + getProductFamily?(): SessionFamily | null; + /** * userId of the current session, or null. * @@ -298,6 +325,8 @@ export interface AuthApi { requestOtp(email: string): Promise; verifyOtp(email: string, code: string): Promise; logout(): Promise; + /** Revoke all sessions in the family (POST /auth/sessions/revoke-all) and clear local credentials. */ + signOutEverywhere(): Promise<{ revokedSessions: number }>; getMe(token?: string): Promise; }