@remit/web-client 0.0.29 → 0.0.30

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": "@remit/web-client",
3
- "version": "0.0.29",
3
+ "version": "0.0.30",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
+ import { AuthTokenError } from "./better-auth-config";
3
4
  import { type AuthProvider, noneAuthProvider } from "./provider";
4
5
 
5
6
  type RequestFn = (req: Request) => Promise<Request>;
@@ -47,7 +48,28 @@ describe("installAuthInterceptor", () => {
47
48
  assert.equal(out.headers.get("Authorization"), "Bearer ID-TOKEN-123");
48
49
  });
49
50
 
50
- test("omits Authorization header when the provider returns no token", async () => {
51
+ test("abandons the request when the token cannot be minted — never sends it unauthenticated", async () => {
52
+ const mod = await loadInterceptor();
53
+ mod.installAuthInterceptor(
54
+ providerWithToken(async () => {
55
+ throw new AuthTokenError("Could not mint a session token: 429", 429);
56
+ }),
57
+ );
58
+ const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
59
+ assert.ok(fn);
60
+ const req = new Request("https://api.example.com/thing");
61
+ await assert.rejects(
62
+ () => fn(req),
63
+ (error: unknown) => {
64
+ assert.ok(error instanceof AuthTokenError);
65
+ assert.equal(error.status, 429);
66
+ return true;
67
+ },
68
+ );
69
+ assert.equal(req.headers.get("Authorization"), null);
70
+ });
71
+
72
+ test("omits Authorization header when the deployment presents no identity", async () => {
51
73
  const mod = await loadInterceptor();
52
74
  mod.installAuthInterceptor(providerWithToken(async () => null));
53
75
  const fn = globalThis.__REMIT_CLIENT_MOCKS__?.requestFns[0];
@@ -7,6 +7,11 @@ let installed = false;
7
7
  * Attach the composed provider's bearer token to every API request. Called once
8
8
  * from `mountApp` with the app's chosen provider, so the token source is the
9
9
  * same `AuthProvider` the shell and screens read — no separate seam.
10
+ *
11
+ * A provider that cannot produce a token throws, and that throw is left to
12
+ * propagate: the request is abandoned rather than sent without an Authorization
13
+ * header. Only the deployments that present no identity at all resolve to null,
14
+ * and for those an unauthenticated request is the intended one.
10
15
  */
11
16
  export const installAuthInterceptor = (authProvider: AuthProvider): void => {
12
17
  if (installed) return;
@@ -51,11 +51,32 @@ const decodeExp = (token: string): number => {
51
51
  }
52
52
  };
53
53
 
54
- const requestToken = async (): Promise<string | null> => {
54
+ /**
55
+ * A session exists but no bearer token could be produced for it. Distinct from
56
+ * "signed out": there is nothing to fall back to, so the request that needed the
57
+ * token must not be sent. Carries the mint's HTTP status where there was one, so
58
+ * the shared classifier reads it like any other API failure.
59
+ */
60
+ export class AuthTokenError extends Error {
61
+ readonly status: number | undefined;
62
+
63
+ constructor(message: string, status?: number) {
64
+ super(message);
65
+ this.name = "AuthTokenError";
66
+ this.status = status;
67
+ }
68
+ }
69
+
70
+ const requestToken = async (): Promise<string> => {
55
71
  const res = await taggedFetch("/api/auth/token", {
56
72
  credentials: "include",
57
73
  });
58
- if (!res.ok) return null;
74
+ if (!res.ok) {
75
+ throw new AuthTokenError(
76
+ `Could not mint a session token: ${res.status} ${res.statusText}`,
77
+ res.status,
78
+ );
79
+ }
59
80
  const body: unknown = await res.json();
60
81
  if (
61
82
  body &&
@@ -65,7 +86,29 @@ const requestToken = async (): Promise<string | null> => {
65
86
  ) {
66
87
  return (body as { token: string }).token;
67
88
  }
68
- return null;
89
+ throw new AuthTokenError("The token endpoint returned no token");
90
+ };
91
+
92
+ let inFlight: Promise<string> | null = null;
93
+
94
+ /**
95
+ * One mint at a time. A cold load renders many screens at once and each of their
96
+ * requests needs the same token; without this every one of them mints its own,
97
+ * and the burst is large enough to spend the auth tier's rate-limit budget. The
98
+ * slot is released once the mint settles, so a failure is not replayed to later
99
+ * callers — they mint again.
100
+ */
101
+ const mint = (): Promise<string> => {
102
+ if (inFlight) return inFlight;
103
+ inFlight = requestToken()
104
+ .then((token) => {
105
+ cached = { value: token, expiresAt: decodeExp(token) };
106
+ return token;
107
+ })
108
+ .finally(() => {
109
+ inFlight = null;
110
+ });
111
+ return inFlight;
69
112
  };
70
113
 
71
114
  /**
@@ -73,14 +116,16 @@ const requestToken = async (): Promise<string | null> => {
73
116
  * when the cached token is missing or within the skew window of expiry. The API
74
117
  * interceptor attaches it as a Bearer token; the backend verifies it against the
75
118
  * JWKS.
119
+ *
120
+ * Never resolves to null: a failed mint throws. Returning null would put the
121
+ * request on the wire without an Authorization header, and the backend answers
122
+ * that with a 401 that names a missing token rather than the mint that failed.
76
123
  */
77
- export const fetchBetterAuthToken = async (): Promise<string | null> => {
124
+ export const fetchBetterAuthToken = async (): Promise<string> => {
78
125
  if (cached && cached.expiresAt - EXPIRY_SKEW_SECONDS > nowSeconds()) {
79
126
  return cached.value;
80
127
  }
81
- const token = await requestToken();
82
- cached = token ? { value: token, expiresAt: decodeExp(token) } : null;
83
- return token;
128
+ return mint();
84
129
  };
85
130
 
86
131
  export const resetBetterAuthTokenCache = (): void => {
@@ -0,0 +1,152 @@
1
+ import assert from "node:assert";
2
+ import { afterEach, describe, test } from "node:test";
3
+ import {
4
+ AuthTokenError,
5
+ fetchBetterAuthToken,
6
+ resetBetterAuthTokenCache,
7
+ } from "./better-auth-config";
8
+
9
+ const base64url = (value: string): string =>
10
+ Buffer.from(value, "utf8")
11
+ .toString("base64")
12
+ .replace(/\+/g, "-")
13
+ .replace(/\//g, "_")
14
+ .replace(/=+$/, "");
15
+
16
+ /** A token shaped like the one better-auth mints, valid for an hour. */
17
+ const jwt = (label: string): string =>
18
+ `header.${base64url(
19
+ JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600, label }),
20
+ )}.signature`;
21
+
22
+ const realFetch = globalThis.fetch;
23
+
24
+ interface Stub {
25
+ calls: number;
26
+ release: () => void;
27
+ }
28
+
29
+ /** Stand in for the token endpoint, holding every request open until released. */
30
+ const stubTokenEndpoint = (respond: (call: number) => Response): Stub => {
31
+ let open: () => void = () => {};
32
+ const gate = new Promise<void>((resolve) => {
33
+ open = resolve;
34
+ });
35
+ const stub: Stub = { calls: 0, release: () => open() };
36
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
37
+ assert.match(String(input), /\/api\/auth\/token$/);
38
+ stub.calls += 1;
39
+ const call = stub.calls;
40
+ await gate;
41
+ return respond(call);
42
+ }) as typeof fetch;
43
+ return stub;
44
+ };
45
+
46
+ const tokenResponse = (token: string): Response =>
47
+ new Response(JSON.stringify({ token }), {
48
+ status: 200,
49
+ headers: { "Content-Type": "application/json" },
50
+ });
51
+
52
+ afterEach(() => {
53
+ globalThis.fetch = realFetch;
54
+ resetBetterAuthTokenCache();
55
+ });
56
+
57
+ describe("fetchBetterAuthToken", () => {
58
+ test("concurrent callers share one mint instead of each firing their own", async () => {
59
+ const stub = stubTokenEndpoint((call) => tokenResponse(jwt(`t${call}`)));
60
+
61
+ const pending = Array.from({ length: 12 }, () => fetchBetterAuthToken());
62
+ stub.release();
63
+ const tokens = await Promise.all(pending);
64
+
65
+ assert.equal(stub.calls, 1);
66
+ assert.equal(new Set(tokens).size, 1);
67
+ });
68
+
69
+ test("a token minted once is reused from cache, without a second request", async () => {
70
+ const stub = stubTokenEndpoint(() => tokenResponse(jwt("cached")));
71
+ stub.release();
72
+
73
+ const first = await fetchBetterAuthToken();
74
+ const second = await fetchBetterAuthToken();
75
+
76
+ assert.equal(stub.calls, 1);
77
+ assert.equal(second, first);
78
+ });
79
+
80
+ test("a rejected mint throws with its status rather than resolving to null", async () => {
81
+ const stub = stubTokenEndpoint(
82
+ () => new Response("", { status: 429, statusText: "Too Many Requests" }),
83
+ );
84
+ stub.release();
85
+
86
+ await assert.rejects(
87
+ () => fetchBetterAuthToken(),
88
+ (error: unknown) => {
89
+ assert.ok(error instanceof AuthTokenError);
90
+ assert.equal(error.status, 429);
91
+ return true;
92
+ },
93
+ );
94
+ });
95
+
96
+ test("a response carrying no token throws rather than resolving to null", async () => {
97
+ const stub = stubTokenEndpoint(
98
+ () =>
99
+ new Response(JSON.stringify({}), {
100
+ status: 200,
101
+ headers: { "Content-Type": "application/json" },
102
+ }),
103
+ );
104
+ stub.release();
105
+
106
+ await assert.rejects(() => fetchBetterAuthToken(), AuthTokenError);
107
+ });
108
+
109
+ test("every caller sharing a failed mint sees the failure", async () => {
110
+ const stub = stubTokenEndpoint(
111
+ () => new Response("", { status: 429, statusText: "Too Many Requests" }),
112
+ );
113
+
114
+ const pending = Array.from({ length: 5 }, () =>
115
+ fetchBetterAuthToken().then(
116
+ () => "resolved",
117
+ (error: unknown) =>
118
+ error instanceof AuthTokenError ? "threw" : "other",
119
+ ),
120
+ );
121
+ stub.release();
122
+ const outcomes = await Promise.all(pending);
123
+
124
+ assert.equal(stub.calls, 1);
125
+ assert.deepEqual(new Set(outcomes), new Set(["threw"]));
126
+ });
127
+
128
+ test("a failed mint is not replayed — the next caller mints again", async () => {
129
+ const failing = stubTokenEndpoint(() => new Response("", { status: 500 }));
130
+ failing.release();
131
+ await assert.rejects(() => fetchBetterAuthToken());
132
+
133
+ const succeeding = stubTokenEndpoint(() =>
134
+ tokenResponse(jwt("after-failure")),
135
+ );
136
+ succeeding.release();
137
+
138
+ assert.ok(await fetchBetterAuthToken());
139
+ assert.equal(succeeding.calls, 1);
140
+ });
141
+
142
+ test("a network failure propagates instead of yielding a tokenless request", async () => {
143
+ globalThis.fetch = (async () => {
144
+ throw new TypeError("Failed to fetch");
145
+ }) as typeof fetch;
146
+
147
+ await assert.rejects(
148
+ () => fetchBetterAuthToken(),
149
+ /could not be completed|Failed to fetch/i,
150
+ );
151
+ });
152
+ });
@@ -22,9 +22,14 @@ export interface AuthProvider {
22
22
  /** Called once at boot, before render, to initialize the identity SDK. */
23
23
  configure(): void;
24
24
  /**
25
- * The current session's bearer token, or `null` when signed out. The API
26
- * interceptor and the raw content fetch read it through here, so the identity
27
- * SDK stays inside the provider.
25
+ * The current session's bearer token. The API interceptor and the raw content
26
+ * fetch read it through here, so the identity SDK stays inside the provider.
27
+ *
28
+ * `null` means this deployment has no identity to present — no identity system
29
+ * is composed, or none is configured — and the request is expected to travel
30
+ * unauthenticated. It never means the token could not be produced: a provider
31
+ * that holds a session and fails to mint a token throws, because a request
32
+ * that cannot be authenticated must not be sent.
28
33
  */
29
34
  getToken(): Promise<string | null>;
30
35
  /** Drop any cached token so the next `getToken` re-mints. */