@gusnips/react 0.9.33 → 0.10.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/src/oauth.ts CHANGED
@@ -1,6 +1,14 @@
1
- import { useMutation, useQuery } from "@tanstack/react-query";
2
- import { useEffect, useId, useState, type ChangeEvent } from "react";
3
- import type { ApiClient } from "./api-client.ts";
1
+ import { hashKey, useMutation, useQuery, type QueryKey } from "@tanstack/react-query";
2
+ import {
3
+ useCallback,
4
+ useEffect,
5
+ useId,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useState,
9
+ type ChangeEvent,
10
+ } from "react";
11
+ import type { ApiClient, RequestOptions } from "./api-client.ts";
4
12
 
5
13
  /**
6
14
  * The two pages behind an agent's sign-in: the consent page, where Claude or ChatGPT sends a
@@ -21,7 +29,11 @@ import type { ApiClient } from "./api-client.ts";
21
29
  */
22
30
 
23
31
  export type OAuthRefusalReason =
24
- "unknown-client" | "redirect-mismatch" | "invalid-request" | "wrong-resource" | "unknown-code";
32
+ | "unknown-client"
33
+ | "redirect-mismatch"
34
+ | "invalid-request"
35
+ | "wrong-resource"
36
+ | "unknown-code";
25
37
 
26
38
  /** What the server sends instead of a view. Word your own page for `reason`. */
27
39
  export interface OAuthRefusal {
@@ -54,11 +66,87 @@ export type DeviceView<Extra extends object = object> = Extra & {
54
66
  expiresAt: string;
55
67
  };
56
68
 
69
+ /** Capture this from your app's identity store on each account or workspace generation. */
70
+ export interface OAuthScope {
71
+ /** Query-key prefix, including account, workspace and the identity generation. Never a token. */
72
+ readonly key: QueryKey;
73
+ /** Compare the captured generation with the live store, even before React renders again. */
74
+ isCurrent(): boolean;
75
+ }
76
+
77
+ /** A decision outlived its account, request or page. Aborting does not undo a server-side decision. */
78
+ export class OAuthOperationChangedError extends Error {
79
+ constructor() {
80
+ super(
81
+ "The account, sign-in request or page changed before the decision finished. Check connected apps in the account you used before trying again.",
82
+ );
83
+ this.name = "OAuthOperationChangedError";
84
+ }
85
+ }
86
+
87
+ interface OAuthOperation {
88
+ identity: string;
89
+ signal: AbortSignal;
90
+ isCurrent(): boolean;
91
+ release(): void;
92
+ }
93
+
94
+ function assertCurrent(operation: OAuthOperation): void {
95
+ if (!operation.isCurrent()) throw new OAuthOperationChangedError();
96
+ }
97
+
98
+ function createOperationLifetime(identity: string) {
99
+ let mounted = false;
100
+ let generation = 0;
101
+ const controllers = new Set<AbortController>();
102
+ return {
103
+ identity,
104
+ invalidate() {
105
+ generation++;
106
+ for (const controller of controllers) controller.abort();
107
+ controllers.clear();
108
+ },
109
+ mount() {
110
+ mounted = true;
111
+ return () => {
112
+ mounted = false;
113
+ this.invalidate();
114
+ };
115
+ },
116
+ capture(isCurrent: () => boolean): OAuthOperation {
117
+ const started = generation;
118
+ const controller = new AbortController();
119
+ controllers.add(controller);
120
+ return {
121
+ identity,
122
+ signal: controller.signal,
123
+ isCurrent: () => mounted && started === generation && isCurrent(),
124
+ release: () => controllers.delete(controller),
125
+ };
126
+ },
127
+ };
128
+ }
129
+
130
+ /** Both pages reject late answers, even when the adapter ignores abort or the cache was cleared. */
131
+ function useOAuthOperation(scope: OAuthScope, kind: "consent" | "device", input: string) {
132
+ const key = [...scope.key, "@gusnips/react/oauth", kind, input];
133
+ const identity = hashKey(key);
134
+ const lifetime = useMemo(() => createOperationLifetime(identity), [identity]);
135
+ // Invalidate by the commit, before passive cleanup can leave a late redirect window.
136
+ // Layout effects work on native too and do not run during SSR. StrictMode can mount again.
137
+ useLayoutEffect(() => lifetime.mount(), [lifetime]);
138
+ return { key, lifetime, capture: () => lifetime.capture(() => scope.isCurrent()) };
139
+ }
140
+
57
141
  export interface ConsentApi<Choice = void, Extra extends object = object> {
58
- /** Your API's call to the server's `describe`, with the query string as it came. */
59
- describe(query: string): Promise<ConsentView<Extra> | OAuthRefusal>;
142
+ /** Forward `options`, including its signal, to your HTTP client. */
143
+ describe(query: string, options?: RequestOptions): Promise<ConsentView<Extra> | OAuthRefusal>;
60
144
  /** Your API's call to `approve`. `choice` is anything your page adds, such as a workspace. */
61
- approve(query: string, choice: Choice): Promise<{ ok: true; redirectTo: string } | OAuthRefusal>;
145
+ approve(
146
+ query: string,
147
+ choice: Choice,
148
+ options?: RequestOptions,
149
+ ): Promise<{ ok: true; redirectTo: string } | OAuthRefusal>;
62
150
  }
63
151
 
64
152
  export type ConsentState<Extra extends object = object> =
@@ -79,29 +167,59 @@ export interface ConsentRequest<Choice = void, Extra extends object = object> {
79
167
  }
80
168
 
81
169
  /**
82
- * const consent = useConsentRequest(api.oauth, useLocation().search);
170
+ * const consent = useConsentRequest(api.oauth, useLocation().search, scope);
83
171
  * if (consent.state.status === "ready") ... consent.approve(workspaceId)
84
172
  */
85
173
  export function useConsentRequest<Choice = void, Extra extends object = object>(
86
174
  api: ConsentApi<Choice, Extra>,
87
175
  search: string,
176
+ scope: OAuthScope,
88
177
  ): ConsentRequest<Choice, Extra> {
89
178
  const query = search.replace(/^\?/, "");
179
+ const { key, lifetime, capture } = useOAuthOperation(scope, "consent", query);
90
180
  const request = useQuery({
91
- queryKey: ["@gusnips/react/oauth", "consent", query],
92
- queryFn: () => api.describe(query),
181
+ queryKey: key,
182
+ queryFn: ({ signal }) => api.describe(query, { signal }),
183
+ // A previous description is never a safe stand-in for this approval target.
184
+ placeholderData: () => undefined,
185
+ retry: false,
186
+ refetchOnWindowFocus: false,
93
187
  });
94
188
  const approval = useMutation({
95
- mutationFn: (choice: Choice) => api.approve(query, choice),
96
- onSuccess: (answer) => {
97
- if (answer.ok) window.location.assign(answer.redirectTo);
189
+ mutationFn: async (pressed: {
190
+ api: ConsentApi<Choice, Extra>;
191
+ query: string;
192
+ choice: Choice;
193
+ operation: OAuthOperation;
194
+ }) => {
195
+ const { api, query, choice, operation } = pressed;
196
+ try {
197
+ assertCurrent(operation);
198
+ const answer = await api.approve(query, choice, { signal: operation.signal });
199
+ // Before returning: stale success data must never enter the mutation cache.
200
+ assertCurrent(operation);
201
+ return answer;
202
+ } finally {
203
+ operation.release();
204
+ }
98
205
  },
206
+ onSettled: (answer, error, { operation }) => {
207
+ // Global success and settlement callbacks may await an identity change. Check last.
208
+ if (error !== null) return;
209
+ assertCurrent(operation);
210
+ if (answer?.ok) window.location.assign(answer.redirectTo);
211
+ },
212
+ retry: false,
213
+ networkMode: "always",
99
214
  });
215
+ const currentApproval =
216
+ approval.variables?.operation.identity === lifetime.identity &&
217
+ approval.variables.operation.isCurrent();
100
218
 
101
219
  // A refusal from approve (the request went stale between the two calls) outranks the view.
102
220
  const answer = request.data;
103
- const refusal =
104
- approval.data?.ok === false ? approval.data : answer?.ok === false ? answer : null;
221
+ let refusal = answer?.ok === false ? answer : null;
222
+ if (currentApproval && approval.data?.ok === false) refusal = approval.data;
105
223
  let state: ConsentState<Extra>;
106
224
  if (refusal) state = { status: "refused", refusal };
107
225
  else if (answer?.ok === true) state = { status: "ready", view: answer };
@@ -111,20 +229,21 @@ export function useConsentRequest<Choice = void, Extra extends object = object>(
111
229
 
112
230
  return {
113
231
  state,
114
- approve: (choice) => approval.mutate(choice),
115
- leaving: approval.isPending || approval.data?.ok === true,
116
- approveError: approval.error,
232
+ approve: (choice) => approval.mutate({ api, query, choice, operation: capture() }),
233
+ leaving: currentApproval && (approval.isPending || approval.data?.ok === true),
234
+ approveError: currentApproval ? approval.error : null,
117
235
  };
118
236
  }
119
237
 
120
238
  export interface DeviceApi<Choice = void, Extra extends object = object> {
121
- /** Your API's call to the server's `describeDevice`. */
122
- describe(code: string): Promise<DeviceView<Extra> | OAuthRefusal>;
239
+ /** Forward `options`, including its signal, to your HTTP client. */
240
+ describe(code: string, options?: RequestOptions): Promise<DeviceView<Extra> | OAuthRefusal>;
123
241
  /** Your API's call to `decideDevice`. `choice` comes with a yes only, as on the consent page. */
124
242
  decide(
125
243
  code: string,
126
244
  decision: "approve" | "deny",
127
245
  choice?: Choice,
246
+ options?: RequestOptions,
128
247
  ): Promise<{ ok: true } | OAuthRefusal>;
129
248
  }
130
249
 
@@ -165,6 +284,9 @@ export interface DeviceApproval<Choice = void, Extra extends object = object> {
165
284
  const ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
166
285
  const LENGTH = 8;
167
286
 
287
+ // Private wiring between the approval and field hooks, without adding to the public page API.
288
+ const deviceAddressWrites = new WeakMap<object, (code: string) => void>();
289
+
168
290
  /** Upper case, letters only, at most 8, with the dash after the fourth: what the field shows. */
169
291
  export function formatUserCode(input: string): string {
170
292
  const letters = input
@@ -175,7 +297,7 @@ export function formatUserCode(input: string): string {
175
297
  }
176
298
 
177
299
  /**
178
- * const device = useDeviceApproval(api.device, useLocation().search);
300
+ * const device = useDeviceApproval(api.device, useLocation().search, scope);
179
301
  * <input value={device.code} onChange={(e) => device.setCode(e.target.value)} />
180
302
  *
181
303
  * The link the script prints carries `?code=`, so the field usually starts full.
@@ -183,28 +305,69 @@ export function formatUserCode(input: string): string {
183
305
  export function useDeviceApproval<Choice = void, Extra extends object = object>(
184
306
  api: DeviceApi<Choice, Extra>,
185
307
  search: string,
308
+ scope: OAuthScope,
186
309
  ): DeviceApproval<Choice, Extra> {
187
- const [code, setTyped] = useState(() =>
188
- formatUserCode(new URLSearchParams(search).get("code") ?? ""),
189
- );
310
+ const fromLink = formatUserCode(new URLSearchParams(search).get("code") ?? "");
311
+ const [input, setInput] = useState<{
312
+ fromLink: string;
313
+ code: string;
314
+ ownAddress?: string;
315
+ }>({ fromLink, code: fromLink });
316
+ const recordAddressWrite = useCallback((ownAddress: string) => {
317
+ setInput((current) => ({ ...current, ownAddress }));
318
+ }, []);
319
+ // Only external links replace input. The field also writes the address while typing and
320
+ // clears it after a decision; neither write should replace input or invalidate the decision.
321
+ const code = input.fromLink === fromLink || input.ownAddress === fromLink ? input.code : fromLink;
322
+ if (input.fromLink !== fromLink) setInput({ fromLink, code });
190
323
  const letters = code.replace("-", "");
191
324
  const invalidLetter = [...letters].some((letter) => !ALPHABET.includes(letter));
192
325
  const complete = letters.length === LENGTH && !invalidLetter;
326
+ const { key, lifetime, capture } = useOAuthOperation(scope, "device", letters);
193
327
 
194
328
  const decision = useMutation({
195
- mutationFn: (pressed: { decision: "approve" | "deny"; choice?: Choice }) =>
196
- api.decide(letters, pressed.decision, pressed.choice),
329
+ mutationFn: async (pressed: {
330
+ api: DeviceApi<Choice, Extra>;
331
+ code: string;
332
+ decision: "approve" | "deny";
333
+ choice?: Choice;
334
+ operation: OAuthOperation;
335
+ }) => {
336
+ const { api, code, decision, choice, operation } = pressed;
337
+ try {
338
+ assertCurrent(operation);
339
+ const answer = await api.decide(code, decision, choice, { signal: operation.signal });
340
+ assertCurrent(operation);
341
+ return answer;
342
+ } finally {
343
+ operation.release();
344
+ }
345
+ },
346
+ onSettled: (_answer, error, { operation }) => {
347
+ if (error === null) assertCurrent(operation);
348
+ },
349
+ retry: false,
350
+ networkMode: "always",
197
351
  });
198
- const decided = decision.isSuccess && decision.data.ok ? decision.variables.decision : undefined;
352
+ const currentDecision =
353
+ decision.variables?.operation.identity === lifetime.identity &&
354
+ decision.variables.operation.isCurrent();
355
+ const decided =
356
+ currentDecision && decision.isSuccess && decision.data.ok
357
+ ? decision.variables.decision
358
+ : undefined;
199
359
  const request = useQuery({
200
- queryKey: ["@gusnips/react/oauth", "device", letters],
201
- queryFn: () => api.describe(letters),
360
+ queryKey: key,
361
+ queryFn: ({ signal }) => api.describe(letters, { signal }),
202
362
  enabled: complete && decided === undefined,
363
+ placeholderData: () => undefined,
364
+ retry: false,
365
+ refetchOnWindowFocus: false,
203
366
  });
204
367
 
205
368
  const answer = request.data;
206
- const refusal =
207
- decision.data?.ok === false ? decision.data : answer?.ok === false ? answer : null;
369
+ let refusal = answer?.ok === false ? answer : null;
370
+ if (currentDecision && decision.data?.ok === false) refusal = decision.data;
208
371
  let state: DeviceState<Extra>;
209
372
  if (decided !== undefined) state = { status: decided === "approve" ? "approved" : "denied" };
210
373
  else if (!complete) state = { status: "typing" };
@@ -214,20 +377,26 @@ export function useDeviceApproval<Choice = void, Extra extends object = object>(
214
377
  state = { status: "failed", error: request.error, retry: () => void request.refetch() };
215
378
  else state = { status: "loading" };
216
379
 
217
- return {
380
+ const device: DeviceApproval<Choice, Extra> = {
218
381
  state,
219
382
  code,
220
- setCode: (input) => {
383
+ setCode: (value) => {
384
+ const next = formatUserCode(value);
385
+ if (next === code) return;
386
+ lifetime.invalidate();
221
387
  decision.reset();
222
- setTyped(formatUserCode(input));
388
+ setInput((current) => ({ ...current, code: next }));
223
389
  },
224
390
  invalidLetter,
225
- approve: (choice) => decision.mutate({ decision: "approve", choice }),
226
- deny: () => decision.mutate({ decision: "deny" }),
227
- deciding: decision.isPending,
228
- pending: decision.isPending ? decision.variables.decision : null,
229
- decideError: decision.error,
391
+ approve: (choice) =>
392
+ decision.mutate({ api, code: letters, decision: "approve", choice, operation: capture() }),
393
+ deny: () => decision.mutate({ api, code: letters, decision: "deny", operation: capture() }),
394
+ deciding: currentDecision && decision.isPending,
395
+ pending: currentDecision && decision.isPending ? decision.variables.decision : null,
396
+ decideError: currentDecision ? decision.error : null,
230
397
  };
398
+ deviceAddressWrites.set(device, recordAddressWrite);
399
+ return device;
231
400
  }
232
401
 
233
402
  export interface DeviceCodeField {
@@ -268,13 +437,16 @@ export function useDeviceCodeField<Choice, Extra extends object>(
268
437
  const statusId = `${id}-status`;
269
438
  const { status } = device.state;
270
439
  const inAddress = status === "approved" || status === "denied" ? "" : device.code;
440
+ const recordAddressWrite = deviceAddressWrites.get(device);
271
441
  useEffect(() => {
272
442
  const url = new URL(window.location.href);
273
443
  if (inAddress) url.searchParams.set("code", inAddress);
274
444
  else url.searchParams.delete("code");
275
- if (url.href !== window.location.href)
445
+ if (url.href !== window.location.href) {
446
+ recordAddressWrite?.(inAddress);
276
447
  window.history.replaceState(window.history.state, "", url);
277
- }, [inAddress]);
448
+ }
449
+ }, [inAddress, recordAddressWrite]);
278
450
 
279
451
  return {
280
452
  inputProps: {
@@ -299,8 +471,8 @@ export type OAuthConsoleHttp = Pick<ApiClient, "get" | "post">;
299
471
 
300
472
  /**
301
473
  * export const oauthApi = createOAuthConsoleApi(api, "/auth/oauth");
302
- * useConsentRequest(oauthApi.consent, search);
303
- * useDeviceApproval(oauthApi.device, search);
474
+ * useConsentRequest(oauthApi.consent, search, scope);
475
+ * useDeviceApproval(oauthApi.device, search, scope);
304
476
  *
305
477
  * Both pages' calls to the server's `oauthConsoleRoutes`, mounted at `basePath` behind your
306
478
  * sign-in. A choice travels as `choice`, which is where those routes hand it to your approver.
@@ -316,13 +488,15 @@ export function createOAuthConsoleApi<
316
488
  ): { consent: ConsentApi<Choice, ConsentExtra>; device: DeviceApi<Choice, DeviceExtra> } {
317
489
  return {
318
490
  consent: {
319
- describe: (query) => http.get(`${basePath}/request?${query}`),
320
- approve: (query, choice) => http.post(`${basePath}/approve`, { query, choice }),
491
+ describe: (query, options) => http.get(`${basePath}/request?${query}`, options),
492
+ approve: (query, choice, options) =>
493
+ http.post(`${basePath}/approve`, { query, choice }, options),
321
494
  },
322
495
  device: {
323
- describe: (code) => http.get(`${basePath}/device?code=${encodeURIComponent(code)}`),
324
- decide: (code, decision, choice) =>
325
- http.post(`${basePath}/device`, { code, decision, choice }),
496
+ describe: (code, options) =>
497
+ http.get(`${basePath}/device?code=${encodeURIComponent(code)}`, options),
498
+ decide: (code, decision, choice, options) =>
499
+ http.post(`${basePath}/device`, { code, decision, choice }, options),
326
500
  },
327
501
  };
328
502
  }
@@ -135,6 +135,37 @@ describe("createSupabaseSessionAdapter", () => {
135
135
  });
136
136
  });
137
137
 
138
+ it("shares one refresh between callers that overlap, then refreshes again", async () => {
139
+ let calls = 0;
140
+ let release: () => void = () => {};
141
+ const adapter = createSupabaseSessionAdapter(
142
+ auth({
143
+ refreshSession: async () => {
144
+ calls += 1;
145
+ await new Promise<void>((resolve) => {
146
+ release = resolve;
147
+ });
148
+ return { data: { session: { access_token: `token-${calls}` } }, error: null };
149
+ },
150
+ }),
151
+ );
152
+
153
+ // The API client and a hand-opened stream asking at the same moment.
154
+ const first = adapter.refresh();
155
+ const second = adapter.refresh();
156
+ release();
157
+ await expect(Promise.all([first, second])).resolves.toEqual([
158
+ { token: "token-1", reachedAuth: true },
159
+ { token: "token-1", reachedAuth: true },
160
+ ]);
161
+ expect(calls).toBe(1);
162
+
163
+ const later = adapter.refresh();
164
+ release();
165
+ await expect(later).resolves.toEqual({ token: "token-2", reachedAuth: true });
166
+ expect(calls).toBe(2);
167
+ });
168
+
138
169
  it("keeps the session when a refresh never reached auth", async () => {
139
170
  const retryable = new AuthRetryableFetchError("offline", 0);
140
171
  const returned = createSupabaseSessionAdapter(
@@ -6,7 +6,7 @@ import {
6
6
  type SupabaseClientOptions,
7
7
  type SupportedStorage,
8
8
  } from "@supabase/supabase-js";
9
- import { SIGN_OUT_TIMEOUT_MS, type SessionAdapter } from "./api-client.ts";
9
+ import { SIGN_OUT_TIMEOUT_MS, type RefreshResult, type SessionAdapter } from "./api-client.ts";
10
10
 
11
11
  interface SupabaseSessionResult {
12
12
  data: { session: { access_token: string } | null };
@@ -283,26 +283,39 @@ async function withinSignOutDeadline(
283
283
  * `stored` is optional here but not at a sign-out button. The API client signs out only after
284
284
  * auth has answered: a refresh auth refused, or a new token the API refused. So the network was up
285
285
  * a moment earlier, and what `stored` covers is the connection dropping in between.
286
+ *
287
+ * `refresh` is single-flight here as well as inside the API client. The client's gate only covers
288
+ * the client: a stream opened by hand (React Native's SSE over XHR, which cannot go through
289
+ * `apiStream`) refreshes through this adapter directly. GoTrue rotates the refresh token on use,
290
+ * so a stream starting while the boot queries refresh would race them, and the loser signs the
291
+ * person out. Sharing the call here closes that for every caller of the adapter.
286
292
  */
287
293
  export function createSupabaseSessionAdapter(
288
294
  auth: SupabaseSessionAuth,
289
295
  stored?: SupabaseSessionStorage,
290
296
  ): SessionAdapter {
297
+ let refreshing: Promise<RefreshResult> | null = null;
298
+ const refreshOnce = async (): Promise<RefreshResult> => {
299
+ try {
300
+ const { data, error } = await auth.refreshSession();
301
+ return {
302
+ token: data.session?.access_token ?? null,
303
+ reachedAuth: error === null || reachedAuth(error),
304
+ };
305
+ } catch (error) {
306
+ return { token: null, reachedAuth: reachedAuth(error) };
307
+ }
308
+ };
291
309
  return {
292
310
  getToken: async () => {
293
311
  const { data } = await auth.getSession();
294
312
  return data.session?.access_token ?? null;
295
313
  },
296
- refresh: async () => {
297
- try {
298
- const { data, error } = await auth.refreshSession();
299
- return {
300
- token: data.session?.access_token ?? null,
301
- reachedAuth: error === null || reachedAuth(error),
302
- };
303
- } catch (error) {
304
- return { token: null, reachedAuth: reachedAuth(error) };
305
- }
314
+ refresh: () => {
315
+ refreshing ??= refreshOnce().finally(() => {
316
+ refreshing = null;
317
+ });
318
+ return refreshing;
306
319
  },
307
320
  signOut: async () => {
308
321
  const { error } = stored
@@ -327,7 +340,12 @@ export function createSupabaseSessionAdapter(
327
340
  * after.
328
341
  */
329
342
  export type EmailLinkType =
330
- "signup" | "invite" | "magiclink" | "recovery" | "email_change" | "email";
343
+ | "signup"
344
+ | "invite"
345
+ | "magiclink"
346
+ | "recovery"
347
+ | "email_change"
348
+ | "email";
331
349
 
332
350
  const EMAIL_LINK_TYPES: Record<EmailLinkType, true> = {
333
351
  signup: true,
@@ -39,7 +39,8 @@ export function comboboxInputValue<T>(
39
39
 
40
40
  /** Pass text for the loading state. There is no default text or English fallback. */
41
41
  type ComboboxLoadingState =
42
- { loading: boolean; loadingHint: ReactNode } | { loading?: undefined; loadingHint?: undefined };
42
+ | { loading: boolean; loadingHint: ReactNode }
43
+ | { loading?: undefined; loadingHint?: undefined };
43
44
 
44
45
  export type ComboboxProps<T> = Omit<
45
46
  Primitive.Root.Props<T, false, T>,