@flow-industries/id 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The first-party session route every consumer app serves from its own
3
+ * origin — the only path between a browser and the refresh token. The
4
+ * rotating refresh token lives in an HttpOnly cookie owned by the app's
5
+ * server; the browser client calls this route instead of ever holding the
6
+ * token itself:
7
+ *
8
+ * - `GET` resolve: fresh JWT from the cookies, rotating server-side when
9
+ * the access token is expiring.
10
+ * - `POST` install: one-time handoff of a freshly minted session (refresh
11
+ * token + access JWT from the dialog) into the HttpOnly cookies.
12
+ * The JWT verifies locally against the issuer's cached JWKS — no
13
+ * rotation of a seconds-old token; a bogus refresh token simply
14
+ * dies at the first GET, like any planted cookie.
15
+ * - `DELETE` sign-out: revoke the token's lineage at the issuer and clear
16
+ * the cookies.
17
+ *
18
+ * Framework-agnostic (`Request` → `Response`); `flowSessionHandlers` from
19
+ * `@flow-industries/id/start` adapts it to a TanStack Start route file.
20
+ */
21
+ import { cookieNamesFor, parseCookieHeader } from "./cookies";
22
+ import { DEFAULT_SESSION_PATH, defaultAudience, resolveIdHost, } from "./id-host";
23
+ import { claimsToUser, clearingCookies, isJwtVerificationFailure, resolveSessionCore, sessionCookies, } from "./session-core";
24
+ import { verifyFlowJWT } from "./verify";
25
+ function json(body, status, setCookies = []) {
26
+ const headers = new Headers({
27
+ "Content-Type": "application/json",
28
+ "Cache-Control": "no-store",
29
+ });
30
+ for (const cookie of setCookies)
31
+ headers.append("Set-Cookie", cookie);
32
+ return new Response(JSON.stringify(body), { status, headers });
33
+ }
34
+ /**
35
+ * Cross-site requests must never reach the session route: the cookies are
36
+ * SameSite=Lax already, so this is defense in depth. `Sec-Fetch-Site` is
37
+ * authoritative where present ("none" = direct navigation, harmless); the
38
+ * Origin header — sent by browsers on every non-GET — must match the app.
39
+ * Requests carrying neither header (server-to-server tooling) pass.
40
+ */
41
+ function isCrossSite(request, audience) {
42
+ const site = request.headers.get("Sec-Fetch-Site");
43
+ if (site && site !== "same-origin" && site !== "none")
44
+ return true;
45
+ const origin = request.headers.get("Origin");
46
+ if (origin && origin !== audience && origin !== new URL(request.url).origin) {
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+ /**
52
+ * Handles one session-route request (no path check — the caller routed it).
53
+ * `audience` defaults per `defaultAudience` (`APP_ORIGIN` env, then the
54
+ * request's own origin); `issuerUrl` per `defaultIdHost`.
55
+ */
56
+ export async function handleSessionRequest(request, opts = {}) {
57
+ const audience = opts.audience ?? defaultAudience(new URL(request.url).origin);
58
+ const issuerUrl = resolveIdHost(opts.issuerUrl, audience);
59
+ if (isCrossSite(request, audience)) {
60
+ return json({ state: null }, 403);
61
+ }
62
+ switch (request.method) {
63
+ case "GET": {
64
+ const { session, setCookies } = await resolveSessionCore(request.headers.get("Cookie"), audience, issuerUrl);
65
+ return json({ state: session }, 200, setCookies);
66
+ }
67
+ case "POST": {
68
+ const body = (await request.json().catch(() => null));
69
+ const refreshToken = body?.refreshToken;
70
+ const jwt = body?.jwt;
71
+ if (typeof refreshToken !== "string" ||
72
+ refreshToken.length === 0 ||
73
+ typeof jwt !== "string" ||
74
+ jwt.length === 0) {
75
+ return json({ state: null }, 400);
76
+ }
77
+ // The audience check on the verified JWT is what stops a token minted
78
+ // for another app from being installed here; a failed install must not
79
+ // clear cookies that may hold a live session.
80
+ try {
81
+ const claims = await verifyFlowJWT(jwt, { audience, issuerUrl });
82
+ return json({ state: { user: claimsToUser(claims), jwt } }, 200, sessionCookies(audience, jwt, refreshToken));
83
+ }
84
+ catch (err) {
85
+ return json({ state: null }, isJwtVerificationFailure(err) ? 401 : 502);
86
+ }
87
+ }
88
+ case "DELETE": {
89
+ const names = cookieNamesFor(audience);
90
+ const token = parseCookieHeader(request.headers.get("Cookie"))[names.refresh];
91
+ // Best-effort: clearing the cookies signs this browser out either way;
92
+ // an unreachable issuer just leaves the lineage to expire on its own.
93
+ if (token) {
94
+ try {
95
+ await fetch(`${issuerUrl}/api/session/revoke`, {
96
+ method: "POST",
97
+ headers: { Authorization: `Bearer ${token}` },
98
+ });
99
+ }
100
+ catch { }
101
+ }
102
+ const headers = new Headers({ "Cache-Control": "no-store" });
103
+ for (const cookie of clearingCookies(audience)) {
104
+ headers.append("Set-Cookie", cookie);
105
+ }
106
+ return new Response(null, { status: 204, headers });
107
+ }
108
+ default:
109
+ return new Response(null, {
110
+ status: 405,
111
+ headers: { Allow: "GET, POST, DELETE" },
112
+ });
113
+ }
114
+ }
115
+ /**
116
+ * Wraps `handleSessionRequest` with a path check for servers that route by
117
+ * hand (a Bun/Hono server): returns null for requests that aren't for the
118
+ * session path so the caller can fall through.
119
+ */
120
+ export function createSessionHandler(opts = {}) {
121
+ const path = opts.path ?? DEFAULT_SESSION_PATH;
122
+ return async (request) => {
123
+ if (new URL(request.url).pathname !== path)
124
+ return null;
125
+ return handleSessionRequest(request, opts);
126
+ };
127
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * TanStack Start integration — the whole Flow ID wiring a Start app needs,
3
+ * so integrating a new domain is one route file plus one root wrapper:
4
+ *
5
+ * ```tsx
6
+ * // src/routes/flow.session.ts — the first-party session route + SSR fn
7
+ * import { createServerFn } from "@tanstack/react-start";
8
+ * import { createFileRoute } from "@tanstack/react-router";
9
+ * import { flowSessionHandlers, resolveFlowSessionRequest } from "@flow-industries/id/start/server";
10
+ *
11
+ * export const getFlowSession = createServerFn({ method: "GET" }).handler(resolveFlowSessionRequest);
12
+ * export const Route = createFileRoute("/flow/session")({ server: { handlers: flowSessionHandlers() } });
13
+ *
14
+ * // src/routes/__root.tsx
15
+ * beforeLoad: () => flowSessionContext(getFlowSession),
16
+ * // and in the root component:
17
+ * <FlowRoot session={session}>{children}</FlowRoot>
18
+ * ```
19
+ *
20
+ * The server fn stays in app source because TanStack's compiler splits it
21
+ * into the server bundle there; everything it does lives here.
22
+ */
23
+ import { type ReactNode } from "react";
24
+ import type { FlowSessionState } from "../types";
25
+ /**
26
+ * The `beforeLoad` body: during SSR resolve the session server-side (via the
27
+ * app's `getFlowSession` server fn, which appends rotated Set-Cookie values);
28
+ * in the browser snapshot the live singleton instead — no network.
29
+ */
30
+ export declare function flowSessionContext(getSession: () => Promise<FlowSessionState | null>): Promise<{
31
+ session: FlowSessionState | null;
32
+ }>;
33
+ export type FlowRootProps = {
34
+ /** The session resolved by `flowSessionContext` in `beforeLoad`. */
35
+ session: FlowSessionState | null;
36
+ /** Mint a silent guest for first-time visitors (see `CreateFlowOptions`). */
37
+ autoGuest?: boolean;
38
+ /** Override the Flow ID origin; defaults per `defaultIdHost`. */
39
+ host?: string;
40
+ children: ReactNode;
41
+ };
42
+ /**
43
+ * Renders the subtree under a request-scoped static Flow on the server and
44
+ * the seeded browser singleton on the client, so the first client render
45
+ * matches the SSR HTML and no bootstrap refresh runs while the hydrated JWT
46
+ * is fresh.
47
+ */
48
+ export declare function FlowRoot({ session, autoGuest, host, children, }: FlowRootProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * TanStack Start integration — the whole Flow ID wiring a Start app needs,
4
+ * so integrating a new domain is one route file plus one root wrapper:
5
+ *
6
+ * ```tsx
7
+ * // src/routes/flow.session.ts — the first-party session route + SSR fn
8
+ * import { createServerFn } from "@tanstack/react-start";
9
+ * import { createFileRoute } from "@tanstack/react-router";
10
+ * import { flowSessionHandlers, resolveFlowSessionRequest } from "@flow-industries/id/start/server";
11
+ *
12
+ * export const getFlowSession = createServerFn({ method: "GET" }).handler(resolveFlowSessionRequest);
13
+ * export const Route = createFileRoute("/flow/session")({ server: { handlers: flowSessionHandlers() } });
14
+ *
15
+ * // src/routes/__root.tsx
16
+ * beforeLoad: () => flowSessionContext(getFlowSession),
17
+ * // and in the root component:
18
+ * <FlowRoot session={session}>{children}</FlowRoot>
19
+ * ```
20
+ *
21
+ * The server fn stays in app source because TanStack's compiler splits it
22
+ * into the server bundle there; everything it does lives here.
23
+ */
24
+ import { useMemo } from "react";
25
+ import { createFlow, createStaticFlow, getFlow } from "../client";
26
+ import { FlowIdProvider } from "../react";
27
+ /**
28
+ * The `beforeLoad` body: during SSR resolve the session server-side (via the
29
+ * app's `getFlowSession` server fn, which appends rotated Set-Cookie values);
30
+ * in the browser snapshot the live singleton instead — no network.
31
+ */
32
+ export async function flowSessionContext(getSession) {
33
+ if (typeof window === "undefined")
34
+ return { session: await getSession() };
35
+ const flow = getFlow();
36
+ const session = flow?.user && flow.jwt
37
+ ? { user: flow.user, jwt: flow.jwt, address: flow.address }
38
+ : null;
39
+ return { session };
40
+ }
41
+ /**
42
+ * Renders the subtree under a request-scoped static Flow on the server and
43
+ * the seeded browser singleton on the client, so the first client render
44
+ * matches the SSR HTML and no bootstrap refresh runs while the hydrated JWT
45
+ * is fresh.
46
+ */
47
+ export function FlowRoot({ session, autoGuest, host, children, }) {
48
+ const flow = useMemo(() => typeof window === "undefined"
49
+ ? createStaticFlow(session, { host })
50
+ : createFlow({ initialState: session, autoGuest, host }), [session, autoGuest, host]);
51
+ return _jsx(FlowIdProvider, { flow: flow, children: children });
52
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Server half of the TanStack Start integration: the session-route handlers
3
+ * and the SSR resolve used by the app's `getFlowSession` server fn. Import
4
+ * only from app-source files the Start compiler splits server-side (the
5
+ * session route file) — see `@flow-industries/id/start` for the wiring.
6
+ */
7
+ import type { FlowSessionState, SessionRouteOptions } from "../types";
8
+ /**
9
+ * The `getFlowSession` server-fn handler: resolves the visitor's session
10
+ * from the request cookies during SSR. A server-side refresh rotates the
11
+ * token, so the rotated Set-Cookie values are appended to the response here
12
+ * rather than left to callers. The audience is pinned by `APP_ORIGIN`
13
+ * (mandatory behind a TLS-terminating proxy), falling back to the request
14
+ * origin in dev.
15
+ */
16
+ export declare function resolveFlowSessionRequest(): Promise<FlowSessionState | null>;
17
+ /**
18
+ * Route handlers for the app's session route file:
19
+ * `createFileRoute("/flow/session")({ server: { handlers: flowSessionHandlers() } })`.
20
+ * The route path must match the client's `sessionPath` (default
21
+ * `/flow/session`).
22
+ */
23
+ export declare function flowSessionHandlers(opts?: SessionRouteOptions): {
24
+ GET: ({ request }: {
25
+ request: Request;
26
+ }) => Promise<Response>;
27
+ POST: ({ request }: {
28
+ request: Request;
29
+ }) => Promise<Response>;
30
+ DELETE: ({ request }: {
31
+ request: Request;
32
+ }) => Promise<Response>;
33
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Server half of the TanStack Start integration: the session-route handlers
3
+ * and the SSR resolve used by the app's `getFlowSession` server fn. Import
4
+ * only from app-source files the Start compiler splits server-side (the
5
+ * session route file) — see `@flow-industries/id/start` for the wiring.
6
+ */
7
+ import { getRequestHeader, getRequestUrl, setResponseHeader, } from "@tanstack/react-start/server";
8
+ import { defaultAudience, defaultIdHost } from "../id-host";
9
+ import { resolveSession } from "../server";
10
+ import { handleSessionRequest } from "../session-route";
11
+ /**
12
+ * The `getFlowSession` server-fn handler: resolves the visitor's session
13
+ * from the request cookies during SSR. A server-side refresh rotates the
14
+ * token, so the rotated Set-Cookie values are appended to the response here
15
+ * rather than left to callers. The audience is pinned by `APP_ORIGIN`
16
+ * (mandatory behind a TLS-terminating proxy), falling back to the request
17
+ * origin in dev.
18
+ */
19
+ export async function resolveFlowSessionRequest() {
20
+ const audience = defaultAudience(getRequestUrl().origin);
21
+ const { state, setCookies } = await resolveSession(getRequestHeader("cookie"), { audience, issuerUrl: defaultIdHost(audience) });
22
+ if (setCookies.length > 0)
23
+ setResponseHeader("Set-Cookie", setCookies);
24
+ return state;
25
+ }
26
+ /**
27
+ * Route handlers for the app's session route file:
28
+ * `createFileRoute("/flow/session")({ server: { handlers: flowSessionHandlers() } })`.
29
+ * The route path must match the client's `sessionPath` (default
30
+ * `/flow/session`).
31
+ */
32
+ export function flowSessionHandlers(opts = {}) {
33
+ const handler = ({ request }) => handleSessionRequest(request, opts);
34
+ return { GET: handler, POST: handler, DELETE: handler };
35
+ }
@@ -5,7 +5,7 @@ export type { Bridge, BridgeParameters, FlowAccount, FlowRemote, FlowRemoteConfi
5
5
  export type { Call, ConnectCapabilities, ConnectRequest, ConnectResponse, GuestRequest, GuestResponse, MethodName, MethodParams, MethodResult, RestoreResponse, RpcRequest, SendCallsParams, SendCallsRequest, SendCallsResponse, SendTransactionParams, SendTransactionRequest, SendTransactionResponse, SignMessageRequest, SignMessageResponse, SignOutRequest, SignOutResponse, SignTypedDataRequest, SignTypedDataResponse, TransactionArgs, TypedData, TypedDataDomain, TypedDataField, } from "./protocol";
6
6
  export { getConnectCapabilities, isPersonalSignParams, isSendCallsParams, isSendTransactionParams, } from "./protocol";
7
7
  export type { CreateRoomInput, MyRooms, RoomChannel, RoomDetail, RoomList, RoomMemberEntry, RoomMembers, RoomOccupancy, RoomOwner, RoomPresenceEntry, RoomPresenceSnapshot, RoomRestrictionKind, RoomRole, RoomSummary, RoomSurfaceSettings, RoomsApi, RoomVisibility, UpdateRoomInput, VerifyRoomContext, } from "./rooms";
8
- export type { AccessKeyOptions, AccessKeyPreparation, Address, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowCookieNames, FlowIdProviderProps, FlowSessionState, FlowState, Listener, LoginOptions, MountProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, RefreshCookieConfig, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
9
- export type { ResolvedFlowSession, ResolveSessionOptions } from "./server";
8
+ export type { AccessKeyOptions, AccessKeyPreparation, Address, CreateDialogHostOptions, CreateFlowOptions, DialogHost, DialogOpenOptions, FinalizeAccessKeyParams, Flow, FlowConnectorParameters, FlowCookieNames, FlowIdProviderProps, FlowSessionState, FlowState, Listener, LoginOptions, MountProfileOptions, PrepareArgsWithAuth, PrepareTransactionRequestPhase, ProfileButtonHandle, ProfileButtonProps, ProfilePosition, ResolveAccountParams, ResolvedAccessKeyOptions, RootCredential, RunLoginParams, RunLoginResult, SendCallsArgs, SendTransactionArgs, Session, SigningContext, SignMessageArgs, SignTypedDataArgs, Store, StoredAccessKey, WagmiConnectCapabilities, WagmiConnectParams, } from "./sdk";
9
+ export type { ResolvedFlowSession, ResolveSessionOptions, SessionRouteOptions, SessionRouteResponse, SessionRouteSession, } from "./server";
10
10
  export type { CoinAsset, IdentifiedTx, TxApprove, TxConvert, TxSend, TxSwap, } from "./tx";
11
11
  export type { ActionDayContext, ActionEventKind, ActionSessionState, ActiveActionResponse, LevelProgress, PublicProfile, XpGrantResult, XpRecentGrant, XpSummary, } from "./xp";
@@ -72,14 +72,14 @@ export type CreateFlowOptions = {
72
72
  */
73
73
  autoGuest?: boolean;
74
74
  /**
75
- * If true, the session lives in first-party cookies on the app's own
76
- * origin: the JWT is mirrored there from memory and the rotating refresh
77
- * token is stored there alone (not in localStorage), so the app's server
78
- * can resolve and rotate the session per-request (see
79
- * `@flow-industries/id/server`). Off by default — a backend that never
80
- * reads them shouldn't start receiving refresh tokens on every request.
75
+ * Path of the app's first-party session route (see
76
+ * `@flow-industries/id/server`). The refresh token lives in an HttpOnly
77
+ * cookie owned by the app's server; the client resolves, installs, and
78
+ * revokes the session exclusively through this same-origin route. Override
79
+ * only when the app cannot claim the default path — the route registration
80
+ * must use the same value.
81
81
  */
82
- cookies?: boolean;
82
+ sessionPath?: string;
83
83
  /**
84
84
  * Session state resolved server-side (via `resolveSession`) to seed the
85
85
  * store synchronously, so the first client render matches the SSR HTML and
@@ -115,10 +115,6 @@ export type FlowCookieNames = {
115
115
  jwt: string;
116
116
  refresh: string;
117
117
  };
118
- export type RefreshCookieConfig = {
119
- name: string;
120
- secure: boolean;
121
- };
122
118
  export type LoginOptions = {
123
119
  mode?: "iframe" | "popup";
124
120
  signUp?: boolean;
@@ -1,5 +1,5 @@
1
- import type { VerifiedFlowJWT } from "./auth";
2
- import type { FlowSessionState } from "./sdk";
1
+ import type { FlowCredential, FlowUser, VerifiedFlowJWT } from "./auth";
2
+ import type { Address, FlowSessionState } from "./sdk";
3
3
  import type { PublicProfile } from "./xp";
4
4
  export type ResolveSessionOptions = {
5
5
  /** The calling app's origin, e.g. `https://flow.game` — must match the JWT `aud`. */
@@ -26,3 +26,31 @@ export type ResolvedFlowSession = {
26
26
  /** Public profile, only when requested via `profile: true` and resolvable. */
27
27
  profile?: PublicProfile;
28
28
  };
29
+ export type SessionRouteOptions = {
30
+ /**
31
+ * The app's public origin, e.g. `https://flow.game` — the JWT audience and
32
+ * what cookie names derive from. Defaults to the `APP_ORIGIN` env var, then
33
+ * the request's own origin (fine in dev; behind a TLS-terminating proxy set
34
+ * `APP_ORIGIN`).
35
+ */
36
+ audience?: string;
37
+ /** Flow ID origin; defaults per `defaultIdHost` (`FLOW_ID_HOST` on servers). */
38
+ issuerUrl?: string;
39
+ /** Route path for `createSessionHandler`'s own path check. */
40
+ path?: string;
41
+ };
42
+ /**
43
+ * A session as the session route reports it to the browser client.
44
+ * `credential`/`address` are present only when the token was rotated
45
+ * server-side — the rotation response is authoritative for signing state,
46
+ * while the JWT hot path knows neither and the client must keep what it has.
47
+ */
48
+ export type SessionRouteSession = {
49
+ user: FlowUser;
50
+ jwt: string;
51
+ credential?: FlowCredential | null;
52
+ address?: Address | null;
53
+ };
54
+ export type SessionRouteResponse = {
55
+ state: SessionRouteSession | null;
56
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flow-industries/id",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "main": "./dist/sdk/client/index.js",
5
5
  "module": "./dist/sdk/client/index.js",
6
6
  "types": "./dist/sdk/client/index.d.ts",
@@ -33,6 +33,14 @@
33
33
  "./server": {
34
34
  "types": "./dist/sdk/server.d.ts",
35
35
  "import": "./dist/sdk/server.js"
36
+ },
37
+ "./start": {
38
+ "types": "./dist/sdk/start/index.d.ts",
39
+ "import": "./dist/sdk/start/index.js"
40
+ },
41
+ "./start/server": {
42
+ "types": "./dist/sdk/start/server.d.ts",
43
+ "import": "./dist/sdk/start/server.js"
36
44
  }
37
45
  },
38
46
  "files": [
@@ -66,7 +74,8 @@
66
74
  "viem": ">=2.47.10",
67
75
  "ox": ">=0.14.0",
68
76
  "react": ">=18",
69
- "@wagmi/core": ">=3.0.0"
77
+ "@wagmi/core": ">=3.0.0",
78
+ "@tanstack/react-start": ">=1.168.0"
70
79
  },
71
80
  "peerDependenciesMeta": {
72
81
  "react": {
@@ -74,6 +83,9 @@
74
83
  },
75
84
  "@wagmi/core": {
76
85
  "optional": true
86
+ },
87
+ "@tanstack/react-start": {
88
+ "optional": true
77
89
  }
78
90
  },
79
91
  "devDependencies": {
@@ -83,6 +95,7 @@
83
95
  "@react-email/ui": "^6.6.0",
84
96
  "@tailwindcss/vite": "^4.1.18",
85
97
  "@tanstack/react-router-devtools": "^1.160.0",
98
+ "@tanstack/react-start": "^1.168.28",
86
99
  "@tanstack/router-cli": "^1.167.17",
87
100
  "@tanstack/router-plugin": "^1.160.0",
88
101
  "@types/bun": "latest",
@@ -1 +0,0 @@
1
- export {};
@@ -1,90 +0,0 @@
1
- import { beforeEach, describe, expect, test } from "bun:test";
2
- import { makeRefreshStore } from "./refresh-store";
3
- const HOST = "https://id.flow.industries";
4
- const KEY = `flow.id.refresh:${HOST}`;
5
- const COOKIE = { name: "flow_id.refresh_p5173", secure: false };
6
- let local;
7
- let jar;
8
- function installBrowserStubs() {
9
- local = new Map();
10
- jar = new Map();
11
- globalThis.localStorage = {
12
- getItem: (k) => local.get(k) ?? null,
13
- setItem: (k, v) => void local.set(k, String(v)),
14
- removeItem: (k) => void local.delete(k),
15
- };
16
- globalThis.document = {
17
- get cookie() {
18
- return [...jar.entries()].map(([n, v]) => `${n}=${v}`).join("; ");
19
- },
20
- set cookie(str) {
21
- const [pair = "", ...attrs] = str.split(";");
22
- const eq = pair.indexOf("=");
23
- const name = pair.slice(0, eq).trim();
24
- const value = pair.slice(eq + 1).trim();
25
- if (attrs.some((a) => a.trim() === "Max-Age=0"))
26
- jar.delete(name);
27
- else
28
- jar.set(name, value);
29
- },
30
- };
31
- }
32
- beforeEach(installBrowserStubs);
33
- describe("localStorage mode (no cookie config)", () => {
34
- test("round-trips through localStorage", () => {
35
- const store = makeRefreshStore(HOST);
36
- expect(store.get()).toBeNull();
37
- store.set("tok_a");
38
- expect(local.get(KEY)).toBe("tok_a");
39
- expect(store.get()).toBe("tok_a");
40
- store.clear();
41
- expect(store.get()).toBeNull();
42
- });
43
- test("ignores a missing token instead of persisting a bogus value", () => {
44
- const store = makeRefreshStore(HOST);
45
- store.set(undefined);
46
- expect(store.get()).toBeNull();
47
- });
48
- test("never touches cookies", () => {
49
- const store = makeRefreshStore(HOST);
50
- store.set("tok_a");
51
- expect(jar.size).toBe(0);
52
- });
53
- });
54
- describe("cookie mode", () => {
55
- test("promotes a legacy localStorage token into the cookie and drops the key", () => {
56
- local.set(KEY, "tok_legacy");
57
- const store = makeRefreshStore(HOST, COOKIE);
58
- expect(store.get()).toBe("tok_legacy");
59
- expect(jar.get(COOKIE.name)).toBe("tok_legacy");
60
- expect(local.has(KEY)).toBe(false);
61
- });
62
- test("an existing cookie wins over a stale localStorage copy", () => {
63
- local.set(KEY, "tok_stale");
64
- jar.set(COOKIE.name, "tok_rotated");
65
- const store = makeRefreshStore(HOST, COOKIE);
66
- expect(store.get()).toBe("tok_rotated");
67
- expect(local.has(KEY)).toBe(false);
68
- });
69
- test("reads and writes only the cookie", () => {
70
- const store = makeRefreshStore(HOST, COOKIE);
71
- store.set("tok_b");
72
- expect(jar.get(COOKIE.name)).toBe("tok_b");
73
- expect(local.size).toBe(0);
74
- local.set(KEY, "tok_planted_later");
75
- jar.delete(COOKIE.name);
76
- expect(store.get()).toBeNull();
77
- });
78
- test("clear deletes the cookie", () => {
79
- const store = makeRefreshStore(HOST, COOKIE);
80
- store.set("tok_c");
81
- store.clear();
82
- expect(store.get()).toBeNull();
83
- expect(jar.has(COOKIE.name)).toBe(false);
84
- });
85
- test("ignores a missing token instead of persisting a bogus value", () => {
86
- const store = makeRefreshStore(HOST, COOKIE);
87
- store.set(undefined);
88
- expect(store.get()).toBeNull();
89
- });
90
- });