@gusnips/react 0.8.1 → 0.9.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
4
4
  "description": "The headless runtime under a Vite + React SPA: typed fetch client with single-flight refresh, auth store, query defaults, route guards, error boundary, stale-tab recovery, i18n bootstrap.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,6 +22,10 @@
22
22
  "types": "./dist/auth-store.d.ts",
23
23
  "default": "./dist/auth-store.js"
24
24
  },
25
+ "./supabase": {
26
+ "types": "./dist/supabase-session.d.ts",
27
+ "default": "./dist/supabase-session.js"
28
+ },
25
29
  "./guards": {
26
30
  "types": "./dist/guards.d.ts",
27
31
  "default": "./dist/guards.js"
@@ -78,6 +82,7 @@
78
82
  },
79
83
  "peerDependencies": {
80
84
  "@base-ui/react": "^1.6.0",
85
+ "@supabase/supabase-js": "^2",
81
86
  "@tanstack/react-query": "^5",
82
87
  "i18next": ">=25",
83
88
  "react": "^19",
@@ -90,6 +95,9 @@
90
95
  "@base-ui/react": {
91
96
  "optional": true
92
97
  },
98
+ "@supabase/supabase-js": {
99
+ "optional": true
100
+ },
93
101
  "@tanstack/react-query": {
94
102
  "optional": true
95
103
  },
@@ -111,6 +119,7 @@
111
119
  },
112
120
  "devDependencies": {
113
121
  "@base-ui/react": "^1.8.0",
122
+ "@supabase/supabase-js": "^2.112.4",
114
123
  "@tanstack/react-query": "^5.102.8",
115
124
  "@types/react": "^19.2.14",
116
125
  "@types/react-dom": "^19.2.3",
@@ -26,7 +26,10 @@ function session(overrides: Partial<SessionAdapter> = {}): SessionAdapter {
26
26
  };
27
27
  }
28
28
 
29
- afterEach(() => vi.unstubAllGlobals());
29
+ afterEach(() => {
30
+ vi.useRealTimers();
31
+ vi.unstubAllGlobals();
32
+ });
30
33
 
31
34
  describe("createApiClient", () => {
32
35
  it("unwraps the success envelope", async () => {
@@ -164,6 +167,49 @@ describe("createApiClient", () => {
164
167
  expect((error as ApiError).expected).toBe(false);
165
168
  });
166
169
 
170
+ it("remembers an auth answer across later failed refresh attempts", async () => {
171
+ stubFetch(() => unauthorized());
172
+ const onSessionDead = vi.fn();
173
+ let refreshes = 0;
174
+ const api = createApiClient({
175
+ baseUrl: "https://api.test",
176
+ session: session({
177
+ refresh: async () =>
178
+ ++refreshes === 1
179
+ ? { token: null, reachedAuth: true }
180
+ : { token: null, reachedAuth: false },
181
+ }),
182
+ onSessionDead,
183
+ refreshRetryDelayMs: 0,
184
+ });
185
+
186
+ const error = await api.get("/me").catch((e: unknown) => e);
187
+ expect(refreshes).toBe(2);
188
+ expect(onSessionDead).toHaveBeenCalledTimes(1);
189
+ expect((error as ApiError).expected).toBe(true);
190
+ });
191
+
192
+ it("uses the fail-safe when sign-out never settles", async () => {
193
+ vi.useFakeTimers();
194
+ stubFetch(() => unauthorized());
195
+ const onSessionDead = vi.fn();
196
+ const api = createApiClient({
197
+ baseUrl: "https://api.test",
198
+ session: session({
199
+ refresh: async () => ({ token: null, reachedAuth: true }),
200
+ signOut: () => new Promise(() => {}),
201
+ }),
202
+ onSessionDead,
203
+ maxRefreshAttempts: 1,
204
+ signOutTimeoutMs: 3_000,
205
+ });
206
+
207
+ await api.get("/me").catch(() => {});
208
+ expect(onSessionDead).not.toHaveBeenCalled();
209
+ await vi.advanceTimersByTimeAsync(3_000);
210
+ expect(onSessionDead).toHaveBeenCalledTimes(1);
211
+ });
212
+
167
213
  it("signs out once when auth actually says no", async () => {
168
214
  stubFetch(() => unauthorized());
169
215
  const onSessionDead = vi.fn();
package/src/api-client.ts CHANGED
@@ -354,7 +354,9 @@ export function createApiClient({
354
354
  for (let attempt = 0; attempt < maxRefreshAttempts; attempt++) {
355
355
  if (attempt > 0) await sleep(refreshRetryDelayMs);
356
356
  const result = await refresh();
357
- answered = result.reachedAuth;
357
+ // A later network miss cannot erase an earlier answer from auth. The attempts are one
358
+ // investigation of this session, and one definitive "no" settles it.
359
+ answered ||= result.reachedAuth;
358
360
  if (!result.token) continue;
359
361
  refused = result.token;
360
362
  res = await send(path, result.token, options);
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { ApiError, parseRetryAfter, retryAfterSecs } from "./api-error.ts";
2
+ import { ApiError, parseRetryAfter, retryAfterSecs, waitingNeverHelps } from "./api-error.ts";
3
3
 
4
4
  /**
5
5
  * Two APIs, two places for the same fact: one states the wait in the `Retry-After` header (the
@@ -38,6 +38,51 @@ describe("retryAfterSecs", () => {
38
38
  expect(retryAfterSecs(new ApiError(429, { code: "X", message: "", details: null }))).toBeNull();
39
39
  expect(retryAfterSecs(new ApiError(429, null))).toBeNull();
40
40
  });
41
+
42
+ // And it keeps answering `null` for a stated never, which is what its callers want: there is
43
+ // no countdown to show. The claim is a separate question, asked separately.
44
+ it("answers null for a stated never, same as for silence", () => {
45
+ const stated = new ApiError(429, {
46
+ code: "X",
47
+ message: "",
48
+ details: { retryAfterSecs: null },
49
+ });
50
+ expect(retryAfterSecs(stated)).toBeNull();
51
+ expect(retryAfterSecs(new ApiError(429, null))).toBeNull();
52
+ });
53
+ });
54
+
55
+ /**
56
+ * The claim `retryAfterSecs` cannot carry: an explicit `null` in the body, meaning waiting will
57
+ * never fix this. It has to be told apart from an absent key, which is silence.
58
+ */
59
+ describe("waitingNeverHelps", () => {
60
+ const withDetails = (details: unknown) => new ApiError(429, { code: "X", message: "", details });
61
+
62
+ it("reads a present null as the claim", () => {
63
+ expect(waitingNeverHelps(withDetails({ retryAfterSecs: null }))).toBe(true);
64
+ });
65
+
66
+ it("reads silence as no claim", () => {
67
+ expect(waitingNeverHelps(withDetails({ scope: "account" }))).toBe(false);
68
+ expect(waitingNeverHelps(withDetails({}))).toBe(false);
69
+ expect(waitingNeverHelps(new ApiError(429, null))).toBe(false);
70
+ });
71
+
72
+ it("reads a stated wait as no claim", () => {
73
+ expect(waitingNeverHelps(withDetails({ retryAfterSecs: 30 }))).toBe(false);
74
+ // The header is not a channel for this — there is no `Retry-After` that says "never".
75
+ expect(waitingNeverHelps(new ApiError(429, null, { retryAfterSecs: 30 }))).toBe(false);
76
+ });
77
+
78
+ // `details` is whatever the server put there, so the shape is a trust boundary. A JSON `null`
79
+ // is the one that bites: `typeof null` is `"object"`, so a missing guard reads a property off
80
+ // it and throws inside a retry rule.
81
+ it("survives a details that is not an object", () => {
82
+ expect(waitingNeverHelps(withDetails(null))).toBe(false);
83
+ expect(waitingNeverHelps(withDetails("nope"))).toBe(false);
84
+ expect(waitingNeverHelps(withDetails(7))).toBe(false);
85
+ });
41
86
  });
42
87
 
43
88
  /**
package/src/api-error.ts CHANGED
@@ -70,6 +70,11 @@ export class ApiError extends Error {
70
70
  * The header first, because it is the standard one and it is what a limiter emits without being
71
71
  * asked; then `details.retryAfterSecs`, which one donor's API sends in the body instead. A
72
72
  * caller asking "how long" should not have to know which server it is talking to.
73
+ *
74
+ * It deliberately does not answer whether waiting helps at all. `null` here means "no countdown
75
+ * to show", which is the right answer both for a refusal that said nothing and for one that said
76
+ * waiting will never fix it — and the adopters' countdown UIs read it for exactly that. Telling
77
+ * those two apart is {@link waitingNeverHelps}.
73
78
  */
74
79
  export function retryAfterSecs(error: ApiError): number | null {
75
80
  if (error.retryAfterSecs !== undefined) return error.retryAfterSecs;
@@ -79,6 +84,32 @@ export function retryAfterSecs(error: ApiError): number | null {
79
84
  return typeof value === "number" && Number.isFinite(value) ? value : null;
80
85
  }
81
86
 
87
+ /**
88
+ * Did the answer say, in so many words, that waiting will never fix this?
89
+ *
90
+ * The server half of this fleet sends `details.retryAfterSecs: null` on a refusal no amount of
91
+ * time clears — a cap that frees only when somebody deletes something, a concurrency slot that
92
+ * frees when another job ends. An explicit `null` is a claim somebody has to read; an omitted key
93
+ * is invisible. That is the whole reason the two must not collapse into one answer: this is the
94
+ * server naming what `durableLimitCodes` otherwise makes every app name by hand, one code at a
95
+ * time, and keep in step with a backend it does not own.
96
+ *
97
+ * Present-and-`null` is the entire test. An absent key reads as `undefined`, so `=== null`
98
+ * already separates the claim from the silence and an `in` check would only widen it to every
99
+ * stated wait. Body-only by design: `Retry-After` has no spelling for "never".
100
+ *
101
+ * Not exported from the package, and that is the point: it is not "is this refusal durable?". It
102
+ * says nothing about a durable CODE or a wait too long to hold, so an error surface branching on
103
+ * it would answer differently from the retry rule for every limit the app names itself. The one
104
+ * question worth asking out loud is `shouldRetry`, and `describeError` already derives the
105
+ * control it offers from exactly that — invariant 8, one rule and two consumers.
106
+ */
107
+ export function waitingNeverHelps(error: ApiError): boolean {
108
+ const details = error.details;
109
+ if (typeof details !== "object" || details === null) return false;
110
+ return (details as { retryAfterSecs?: unknown }).retryAfterSecs === null;
111
+ }
112
+
82
113
  /**
83
114
  * `Retry-After` in either spec form: delay-seconds, or an HTTP-date.
84
115
  *
@@ -13,7 +13,7 @@ import {
13
13
  // lets the redirect cases assert a destination without rendering anything.
14
14
  const router = vi.hoisted(() => ({
15
15
  Navigate: vi.fn(),
16
- useLocation: vi.fn(() => ({ pathname: "/posts", search: "?page=2" })),
16
+ useLocation: vi.fn(() => ({ pathname: "/posts", search: "?page=2", hash: "#comments" })),
17
17
  }));
18
18
  vi.mock("react-router-dom", () => router);
19
19
 
@@ -96,13 +96,12 @@ describe("createRequireAuth", () => {
96
96
  expect(branch(render(guard(false, true)))).toBe("LOADING");
97
97
  });
98
98
 
99
- // The redirect carries where they were headed, query string included: an adopter's sign-in screen
100
- // reads `location.state.from` to land them on the page they actually wanted rather than the home
101
- // screen. One donor hardcoded this in four places, which is what made it a factory.
102
- it("remembers the address it turned away", () => {
99
+ // The URL carries the whole destination because router state is lost on reload, OAuth and email
100
+ // links. The sign-in screen reads `next`, validates it, and replaces this history entry.
101
+ it("remembers the whole address it turned away", () => {
103
102
  expect(branch(render(guard(false)))).toEqual({
104
- to: "/login",
105
- state: { from: "/posts?page=2" },
103
+ to: "/login?next=%2Fposts%3Fpage%3D2%23comments",
104
+ state: undefined,
106
105
  });
107
106
  });
108
107
 
@@ -123,6 +122,27 @@ describe("createRequireAnonymous", () => {
123
122
  expect(branch(render(guard(true)))).toEqual({ to: "/", state: undefined });
124
123
  });
125
124
 
125
+ it("resumes a carried internal route", () => {
126
+ router.useLocation.mockReturnValueOnce({
127
+ pathname: "/login",
128
+ search: "?next=%2Fposts%3Fpage%3D2%23comments",
129
+ hash: "",
130
+ });
131
+ expect(branch(render(guard(true)))).toEqual({
132
+ to: "/posts?page=2#comments",
133
+ state: undefined,
134
+ });
135
+ });
136
+
137
+ it("falls home instead of following an external target", () => {
138
+ router.useLocation.mockReturnValueOnce({
139
+ pathname: "/login",
140
+ search: "?next=%2F%2Fevil.test",
141
+ hash: "",
142
+ });
143
+ expect(branch(render(guard(true)))).toEqual({ to: "/", state: undefined });
144
+ });
145
+
126
146
  it("lets an anonymous visitor through", () => {
127
147
  expect(branch(render(guard(false)))).toBe("CONTENT");
128
148
  });
package/src/guards.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { UseQueryResult } from "@tanstack/react-query";
2
2
  import type { ReactNode } from "react";
3
3
  import { Navigate, useLocation } from "react-router-dom";
4
+ import { safeInternalPath } from "./internal-path.ts";
4
5
 
5
6
  /**
6
7
  * Route guards.
@@ -58,6 +59,14 @@ export interface GuardOptions {
58
59
  loading: ReactNode;
59
60
  }
60
61
 
62
+ /** Query parameter carrying the route a sign-in interrupted. */
63
+ export const AUTH_RETURN_PARAM = "next";
64
+
65
+ function signInUrl(signInPath: string, returnTo: string): string {
66
+ const separator = signInPath.includes("?") ? "&" : "?";
67
+ return `${signInPath}${separator}${AUTH_RETURN_PARAM}=${encodeURIComponent(returnTo)}`;
68
+ }
69
+
61
70
  /**
62
71
  * Signed in, or off to sign in — remembering where they were headed, so the redirect afterwards
63
72
  * lands on the page they actually wanted rather than the home screen.
@@ -71,10 +80,13 @@ export function createRequireAuth(
71
80
  const { isAuthenticated, isLoading } = useSession();
72
81
  const location = useLocation();
73
82
  if (isLoading) return <>{loading}</>;
74
- if (!isAuthenticated)
75
- return (
76
- <Navigate to={signInPath} replace state={{ from: location.pathname + location.search }} />
77
- );
83
+ if (!isAuthenticated) {
84
+ // Query state survives a reload, an OAuth round trip and an email-link round trip. Router
85
+ // state survives none of them, so it only looked like a remembered destination on the
86
+ // password path. The hash belongs too: tabs and anchored settings are real destinations.
87
+ const returnTo = location.pathname + location.search + location.hash;
88
+ return <Navigate to={signInUrl(signInPath, returnTo)} replace />;
89
+ }
78
90
  return <>{children}</>;
79
91
  };
80
92
  }
@@ -87,8 +99,12 @@ export function createRequireAnonymous(
87
99
  ) {
88
100
  return function RequireAnonymous({ children }: { children: ReactNode }) {
89
101
  const { isAuthenticated, isLoading } = useSession();
102
+ const location = useLocation();
90
103
  if (isLoading) return <>{loading}</>;
91
- if (isAuthenticated) return <Navigate to={homePath} replace />;
104
+ if (isAuthenticated) {
105
+ const carried = new URLSearchParams(location.search).get(AUTH_RETURN_PARAM);
106
+ return <Navigate to={safeInternalPath(carried) ?? homePath} replace />;
107
+ }
92
108
  return <>{children}</>;
93
109
  };
94
110
  }
package/src/index.ts CHANGED
@@ -8,10 +8,11 @@
8
8
  // with the error moved from install time to the adopter's first build, which is the worse of the
9
9
  // two places to find out.
10
10
  //
11
- // @gusnips/react/ui the Base UI wrappers → @base-ui/react
12
- // @gusnips/react/store createAuthStore → zustand
13
- // @gusnips/react/guards the route guards → react-router-dom
14
- // @gusnips/react/hydrate hydrateOrMount → react-dom
11
+ // @gusnips/react/ui the Base UI wrappers → @base-ui/react
12
+ // @gusnips/react/store createAuthStore → zustand
13
+ // @gusnips/react/guards the route guards → react-router-dom
14
+ // @gusnips/react/hydrate hydrateOrMount → react-dom
15
+ // @gusnips/react/supabase the session adapter → @supabase/supabase-js
15
16
  //
16
17
  // `react-dom` is the one the second migration added, and it is the sharpest case yet: that
17
18
  // adopter ships a REACT NATIVE app beside its two web apps, and React Native has no react-dom to
@@ -28,6 +29,7 @@
28
29
  // identical in source.
29
30
 
30
31
  export { cn } from "./cn.ts";
32
+ export { safeInternalPath } from "./internal-path.ts";
31
33
 
32
34
  export { ApiError, isAbortError, retryAfterSecs } from "./api-error.ts";
33
35
  export {
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { safeInternalPath } from "./internal-path.ts";
3
+
4
+ describe("safeInternalPath", () => {
5
+ it.each([
6
+ ["/", "/"],
7
+ ["/posts?page=2#comments", "/posts?page=2#comments"],
8
+ ["/safe\\child", "/safe/child"],
9
+ ["/%252f%252fevil.test", "/%252f%252fevil.test"],
10
+ ])("keeps %s on this origin", (value, expected) => {
11
+ expect(safeInternalPath(value)).toBe(expected);
12
+ });
13
+
14
+ it.each([
15
+ null,
16
+ undefined,
17
+ 7,
18
+ "",
19
+ "posts",
20
+ "https://evil.test",
21
+ "//evil.test",
22
+ "/\\evil.test",
23
+ `/\t/evil.test`,
24
+ `/\n/evil.test`,
25
+ ])("refuses an external or malformed target: %s", (value) => {
26
+ expect(safeInternalPath(value)).toBeNull();
27
+ });
28
+ });
@@ -0,0 +1,29 @@
1
+ const INTERNAL_ORIGIN = "https://frontkit.invalid";
2
+
3
+ function containsControl(value: string): boolean {
4
+ for (const char of value) {
5
+ const code = char.charCodeAt(0);
6
+ if (code <= 0x1f || code === 0x7f) return true;
7
+ }
8
+ return false;
9
+ }
10
+
11
+ /**
12
+ * A route the browser can follow without leaving the current origin.
13
+ *
14
+ * Return targets cross a trust boundary: query strings, history state and storage are all writable
15
+ * by somebody outside the app. `startsWith("/")` is not enough — URL parsers read both `//host`
16
+ * and `/\\host` as another origin, and strip tabs or newlines before making the same decision.
17
+ * Parsing against a fixed origin applies the browser's own rules rather than trying to copy them.
18
+ */
19
+ export function safeInternalPath(value: unknown): string | null {
20
+ if (typeof value !== "string" || !value.startsWith("/") || containsControl(value)) return null;
21
+
22
+ try {
23
+ const url = new URL(value, INTERNAL_ORIGIN);
24
+ if (url.origin !== INTERNAL_ORIGIN) return null;
25
+ return `${url.pathname}${url.search}${url.hash}`;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
package/src/query.test.ts CHANGED
@@ -9,6 +9,14 @@ const refusal = (status: number, code?: string) =>
9
9
  const waited = (status: number, secs: number) =>
10
10
  new ApiError(status, null, { retryAfterSecs: secs });
11
11
 
12
+ /** A refusal whose body says, in so many words, that waiting will never clear it. */
13
+ const never = (status: number) =>
14
+ new ApiError(status, { code: "LIMIT_REACHED", message: "", details: { retryAfterSecs: null } });
15
+
16
+ /** The same body with no such key: details, but no claim either way. */
17
+ const silent = (status: number) =>
18
+ new ApiError(status, { code: "LIMIT_REACHED", message: "", details: { scope: "account" } });
19
+
12
20
  // Invariant 8, and the reason this module exists: three donors wrote three retry rules and
13
21
  // each was right about something the other two got wrong. Every case below is one of those.
14
22
  describe("shouldRetry", () => {
@@ -56,6 +64,38 @@ describe("shouldRetry", () => {
56
64
  expect(shouldRetry(refusal(503, "QUOTA_EXCEEDED"), ["QUOTA_EXCEEDED"])).toBe(false);
57
65
  });
58
66
 
67
+ // The same judgement with the server doing the naming. The server half of this fleet sends
68
+ // `details.retryAfterSecs: null` on a refusal no amount of time clears — a cap that frees only
69
+ // when somebody deletes something, a slot that frees when another job ends. Read as "nothing
70
+ // said how long", which is how every version before this one read it, that 429 gets retried
71
+ // against a limit that cannot move, and the app has to keep a `durableLimitCodes` entry for a
72
+ // claim the answer already carries.
73
+ it("does not retry a refusal whose body says waiting never helps", () => {
74
+ expect(shouldRetry(never(429))).toBe(false);
75
+ // And it outranks the status, exactly as a durable code does.
76
+ expect(shouldRetry(never(503))).toBe(false);
77
+ });
78
+
79
+ // The distinction the rule above rests on: an absent key is silence, not a claim, and silence
80
+ // has always meant "retry a transient failure". Only a key that is present and `null` is the
81
+ // claim — which is why this reads `=== null` and not `in`.
82
+ it("still retries when the body carries details but no stated wait", () => {
83
+ expect(shouldRetry(silent(500))).toBe(true);
84
+ expect(shouldRetry(silent(429))).toBe(true);
85
+ });
86
+
87
+ // A header wait and a body `null` contradict each other. The `null` is written by whoever
88
+ // raised this particular refusal; the number can come from a limiter that attaches one to
89
+ // every 429 it emits, so the hand-written claim is the more specific one.
90
+ it("takes an explicit never over a stated wait", () => {
91
+ const both = new ApiError(
92
+ 429,
93
+ { code: "LIMIT_REACHED", message: "", details: { retryAfterSecs: null } },
94
+ { retryAfterSecs: 2 },
95
+ );
96
+ expect(shouldRetry(both)).toBe(false);
97
+ });
98
+
59
99
  // The second migration's rule. Its limiter answers `Retry-After: 60` on a per-minute window,
60
100
  // so retrying at 1s and 2s spends two more requests that cannot possibly succeed.
61
101
  it("does not retry a stated wait longer than we will hold", () => {
package/src/query.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { DefaultOptions } from "@tanstack/react-query";
2
- import { ApiError, retryAfterSecs } from "./api-error.ts";
2
+ import { ApiError, retryAfterSecs, waitingNeverHelps } from "./api-error.ts";
3
3
 
4
4
  /**
5
5
  * react-query defaults — the merge of three repos that each got part of this right.
@@ -21,6 +21,11 @@ import { ApiError, retryAfterSecs } from "./api-error.ts";
21
21
  * same limiter, and three seconds added before the screen says anything. So a stated wait is
22
22
  * waited out when it is short, and is an ANSWER when it is not.
23
23
  *
24
+ * The fifth rule came from the server half of this fleet rather than from a donor: a refusal can
25
+ * state that waiting will never fix it at all, by sending `details.retryAfterSecs: null`. Read as
26
+ * "did not say how long" — which is what every version before this one did — that 429 is retried
27
+ * against a limit no amount of time moves.
28
+ *
24
29
  * This is invariant 8.
25
30
  */
26
31
 
@@ -34,6 +39,10 @@ export interface QueryDefaultsOptions {
34
39
  * Error codes that mean "this limit does not clear by waiting" — a spent monthly quota, a
35
40
  * balance that needs topping up. They usually arrive as 402 or 429; the status alone cannot
36
41
  * tell them apart from a burst limit, which is why the caller names them.
42
+ *
43
+ * Only needed where the server does not say so itself. One that sends
44
+ * `details.retryAfterSecs: null` has already made the claim and `shouldRetry` reads it, which
45
+ * is one entry fewer to keep in step with a backend this app does not own.
37
46
  */
38
47
  durableLimitCodes?: readonly string[];
39
48
  /** How many times to retry a transient failure. Two donors used 2, one used 1. */
@@ -74,6 +83,13 @@ export function shouldRetry(
74
83
 
75
84
  if (error.code !== undefined && durableLimitCodes.includes(error.code)) return false;
76
85
 
86
+ // The same judgement, made by the server instead of by a list the app maintains: an explicit
87
+ // `details.retryAfterSecs: null` means waiting will never fix this. Checked before the stated
88
+ // wait because the two can contradict each other, and the `null` is written by whoever raised
89
+ // this particular refusal where a number can come from a limiter that attaches one to every
90
+ // 429 it emits.
91
+ if (waitingNeverHelps(error)) return false;
92
+
77
93
  // It told us when it clears. A wait we are not willing to hold is a refusal, not a hiccup —
78
94
  // and it is the same judgement `durableLimitCodes` makes, except the server did the naming.
79
95
  const wait = retryAfterSecs(error);
@@ -0,0 +1,91 @@
1
+ import {
2
+ AuthApiError,
3
+ AuthRefreshDiscardedError,
4
+ AuthRetryableFetchError,
5
+ } from "@supabase/supabase-js";
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import { createSupabaseSessionAdapter, type SupabaseSessionAuth } from "./supabase-session.ts";
8
+
9
+ function auth(overrides: Partial<SupabaseSessionAuth> = {}): SupabaseSessionAuth {
10
+ return {
11
+ getSession: async () => ({ data: { session: { access_token: "old-token" } } }),
12
+ refreshSession: async () => ({
13
+ data: { session: { access_token: "new-token" } },
14
+ error: null,
15
+ }),
16
+ signOut: async () => ({ error: null }),
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ describe("createSupabaseSessionAdapter", () => {
22
+ it("reads the current token and the refreshed token", async () => {
23
+ const adapter = createSupabaseSessionAdapter(auth());
24
+
25
+ await expect(adapter.getToken()).resolves.toBe("old-token");
26
+ await expect(adapter.refresh()).resolves.toEqual({
27
+ token: "new-token",
28
+ reachedAuth: true,
29
+ });
30
+ });
31
+
32
+ it("keeps the session when a refresh never reached auth", async () => {
33
+ const retryable = new AuthRetryableFetchError("offline", 0);
34
+ const returned = createSupabaseSessionAdapter(
35
+ auth({
36
+ refreshSession: async () => ({ data: { session: null }, error: retryable }),
37
+ }),
38
+ );
39
+ const thrown = createSupabaseSessionAdapter(
40
+ auth({
41
+ refreshSession: async () => {
42
+ throw new TypeError("fetch failed");
43
+ },
44
+ }),
45
+ );
46
+
47
+ await expect(returned.refresh()).resolves.toEqual({ token: null, reachedAuth: false });
48
+ await expect(thrown.refresh()).resolves.toEqual({ token: null, reachedAuth: false });
49
+ });
50
+
51
+ it("marks a terminal auth refusal whether it is returned or thrown", async () => {
52
+ const refusal = new AuthApiError("expired", 401, "refresh_token_not_found");
53
+ const returned = createSupabaseSessionAdapter(
54
+ auth({
55
+ refreshSession: async () => ({ data: { session: null }, error: refusal }),
56
+ }),
57
+ );
58
+ const thrown = createSupabaseSessionAdapter(
59
+ auth({
60
+ refreshSession: async () => {
61
+ throw refusal;
62
+ },
63
+ }),
64
+ );
65
+
66
+ await expect(returned.refresh()).resolves.toEqual({ token: null, reachedAuth: true });
67
+ await expect(thrown.refresh()).resolves.toEqual({ token: null, reachedAuth: true });
68
+ });
69
+
70
+ it("does not kill a session whose rotated refresh was discarded after a local race", async () => {
71
+ const adapter = createSupabaseSessionAdapter(
72
+ auth({
73
+ refreshSession: async () => ({
74
+ data: { session: null },
75
+ error: new AuthRefreshDiscardedError(),
76
+ }),
77
+ }),
78
+ );
79
+
80
+ await expect(adapter.refresh()).resolves.toEqual({ token: null, reachedAuth: false });
81
+ });
82
+
83
+ it("turns a resolved sign-out error into a rejection", async () => {
84
+ const refusal = new AuthApiError("unavailable", 503, "unexpected_failure");
85
+ const signOut = vi.fn(async () => ({ error: refusal }));
86
+ const adapter = createSupabaseSessionAdapter(auth({ signOut }));
87
+
88
+ await expect(adapter.signOut()).rejects.toBe(refusal);
89
+ expect(signOut).toHaveBeenCalledOnce();
90
+ });
91
+ });
@@ -0,0 +1,56 @@
1
+ import { isAuthError, isAuthRetryableFetchError, type SupabaseClient } from "@supabase/supabase-js";
2
+ import type { SessionAdapter } from "./api-client.ts";
3
+
4
+ interface SupabaseSessionResult {
5
+ data: { session: { access_token: string } | null };
6
+ }
7
+
8
+ /** The three auth calls the API client needs, kept structural so any Supabase schema fits. */
9
+ export interface SupabaseSessionAuth {
10
+ getSession(): Promise<SupabaseSessionResult>;
11
+ refreshSession(): Promise<SupabaseSessionResult & { error: unknown }>;
12
+ signOut(): Promise<{ error: unknown }>;
13
+ }
14
+
15
+ type Satisfied<T extends true> = T;
16
+ type _SupabaseAuthFitsAdapter = Satisfied<
17
+ SupabaseClient["auth"] extends SupabaseSessionAuth ? true : false
18
+ >;
19
+
20
+ function reachedAuth(error: unknown): boolean {
21
+ if (!isAuthError(error) || isAuthRetryableFetchError(error)) return false;
22
+ // Newer clients name a refresh that reached GoTrue but was deliberately discarded because the
23
+ // local session changed mid-flight. That race is a no-op, not proof that either session died.
24
+ return error.name !== "AuthRefreshDiscardedError";
25
+ }
26
+
27
+ /**
28
+ * Connects a Supabase auth client to {@link SessionAdapter}.
29
+ *
30
+ * The distinction this preserves is the one that decides whether anybody is signed out: a
31
+ * retryable fetch error never reached GoTrue and says nothing about the session; any other auth
32
+ * error is an answer. A thrown network error stays on the first side of that line too.
33
+ */
34
+ export function createSupabaseSessionAdapter(auth: SupabaseSessionAuth): SessionAdapter {
35
+ return {
36
+ getToken: async () => {
37
+ const { data } = await auth.getSession();
38
+ return data.session?.access_token ?? null;
39
+ },
40
+ refresh: async () => {
41
+ try {
42
+ const { data, error } = await auth.refreshSession();
43
+ return {
44
+ token: data.session?.access_token ?? null,
45
+ reachedAuth: error === null || reachedAuth(error),
46
+ };
47
+ } catch (error) {
48
+ return { token: null, reachedAuth: reachedAuth(error) };
49
+ }
50
+ },
51
+ signOut: async () => {
52
+ const { error } = await auth.signOut();
53
+ if (error) throw error;
54
+ },
55
+ };
56
+ }