@gusnips/react 0.9.32 → 0.10.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.
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
@@ -54,11 +62,87 @@ export type DeviceView<Extra extends object = object> = Extra & {
54
62
  expiresAt: string;
55
63
  };
56
64
 
65
+ /** Capture this from your app's identity store on each account or workspace generation. */
66
+ export interface OAuthScope {
67
+ /** Query-key prefix, including account, workspace and the identity generation. Never a token. */
68
+ readonly key: QueryKey;
69
+ /** Compare the captured generation with the live store, even before React renders again. */
70
+ isCurrent(): boolean;
71
+ }
72
+
73
+ /** A decision outlived its account, request or page. Aborting does not undo a server-side decision. */
74
+ export class OAuthOperationChangedError extends Error {
75
+ constructor() {
76
+ super(
77
+ "The account, sign-in request or page changed before the decision finished. Check connected apps in the account you used before trying again.",
78
+ );
79
+ this.name = "OAuthOperationChangedError";
80
+ }
81
+ }
82
+
83
+ interface OAuthOperation {
84
+ identity: string;
85
+ signal: AbortSignal;
86
+ isCurrent(): boolean;
87
+ release(): void;
88
+ }
89
+
90
+ function assertCurrent(operation: OAuthOperation): void {
91
+ if (!operation.isCurrent()) throw new OAuthOperationChangedError();
92
+ }
93
+
94
+ function createOperationLifetime(identity: string) {
95
+ let mounted = false;
96
+ let generation = 0;
97
+ const controllers = new Set<AbortController>();
98
+ return {
99
+ identity,
100
+ invalidate() {
101
+ generation++;
102
+ for (const controller of controllers) controller.abort();
103
+ controllers.clear();
104
+ },
105
+ mount() {
106
+ mounted = true;
107
+ return () => {
108
+ mounted = false;
109
+ this.invalidate();
110
+ };
111
+ },
112
+ capture(isCurrent: () => boolean): OAuthOperation {
113
+ const started = generation;
114
+ const controller = new AbortController();
115
+ controllers.add(controller);
116
+ return {
117
+ identity,
118
+ signal: controller.signal,
119
+ isCurrent: () => mounted && started === generation && isCurrent(),
120
+ release: () => controllers.delete(controller),
121
+ };
122
+ },
123
+ };
124
+ }
125
+
126
+ /** Both pages reject late answers, even when the adapter ignores abort or the cache was cleared. */
127
+ function useOAuthOperation(scope: OAuthScope, kind: "consent" | "device", input: string) {
128
+ const key = [...scope.key, "@gusnips/react/oauth", kind, input];
129
+ const identity = hashKey(key);
130
+ const lifetime = useMemo(() => createOperationLifetime(identity), [identity]);
131
+ // Invalidate by the commit, before passive cleanup can leave a late redirect window.
132
+ // Layout effects work on native too and do not run during SSR. StrictMode can mount again.
133
+ useLayoutEffect(() => lifetime.mount(), [lifetime]);
134
+ return { key, lifetime, capture: () => lifetime.capture(() => scope.isCurrent()) };
135
+ }
136
+
57
137
  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>;
138
+ /** Forward `options`, including its signal, to your HTTP client. */
139
+ describe(query: string, options?: RequestOptions): Promise<ConsentView<Extra> | OAuthRefusal>;
60
140
  /** 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>;
141
+ approve(
142
+ query: string,
143
+ choice: Choice,
144
+ options?: RequestOptions,
145
+ ): Promise<{ ok: true; redirectTo: string } | OAuthRefusal>;
62
146
  }
63
147
 
64
148
  export type ConsentState<Extra extends object = object> =
@@ -79,29 +163,59 @@ export interface ConsentRequest<Choice = void, Extra extends object = object> {
79
163
  }
80
164
 
81
165
  /**
82
- * const consent = useConsentRequest(api.oauth, useLocation().search);
166
+ * const consent = useConsentRequest(api.oauth, useLocation().search, scope);
83
167
  * if (consent.state.status === "ready") ... consent.approve(workspaceId)
84
168
  */
85
169
  export function useConsentRequest<Choice = void, Extra extends object = object>(
86
170
  api: ConsentApi<Choice, Extra>,
87
171
  search: string,
172
+ scope: OAuthScope,
88
173
  ): ConsentRequest<Choice, Extra> {
89
174
  const query = search.replace(/^\?/, "");
175
+ const { key, lifetime, capture } = useOAuthOperation(scope, "consent", query);
90
176
  const request = useQuery({
91
- queryKey: ["@gusnips/react/oauth", "consent", query],
92
- queryFn: () => api.describe(query),
177
+ queryKey: key,
178
+ queryFn: ({ signal }) => api.describe(query, { signal }),
179
+ // A previous description is never a safe stand-in for this approval target.
180
+ placeholderData: () => undefined,
181
+ retry: false,
182
+ refetchOnWindowFocus: false,
93
183
  });
94
184
  const approval = useMutation({
95
- mutationFn: (choice: Choice) => api.approve(query, choice),
96
- onSuccess: (answer) => {
97
- if (answer.ok) window.location.assign(answer.redirectTo);
185
+ mutationFn: async (pressed: {
186
+ api: ConsentApi<Choice, Extra>;
187
+ query: string;
188
+ choice: Choice;
189
+ operation: OAuthOperation;
190
+ }) => {
191
+ const { api, query, choice, operation } = pressed;
192
+ try {
193
+ assertCurrent(operation);
194
+ const answer = await api.approve(query, choice, { signal: operation.signal });
195
+ // Before returning: stale success data must never enter the mutation cache.
196
+ assertCurrent(operation);
197
+ return answer;
198
+ } finally {
199
+ operation.release();
200
+ }
98
201
  },
202
+ onSettled: (answer, error, { operation }) => {
203
+ // Global success and settlement callbacks may await an identity change. Check last.
204
+ if (error !== null) return;
205
+ assertCurrent(operation);
206
+ if (answer?.ok) window.location.assign(answer.redirectTo);
207
+ },
208
+ retry: false,
209
+ networkMode: "always",
99
210
  });
211
+ const currentApproval =
212
+ approval.variables?.operation.identity === lifetime.identity &&
213
+ approval.variables.operation.isCurrent();
100
214
 
101
215
  // A refusal from approve (the request went stale between the two calls) outranks the view.
102
216
  const answer = request.data;
103
- const refusal =
104
- approval.data?.ok === false ? approval.data : answer?.ok === false ? answer : null;
217
+ let refusal = answer?.ok === false ? answer : null;
218
+ if (currentApproval && approval.data?.ok === false) refusal = approval.data;
105
219
  let state: ConsentState<Extra>;
106
220
  if (refusal) state = { status: "refused", refusal };
107
221
  else if (answer?.ok === true) state = { status: "ready", view: answer };
@@ -111,20 +225,21 @@ export function useConsentRequest<Choice = void, Extra extends object = object>(
111
225
 
112
226
  return {
113
227
  state,
114
- approve: (choice) => approval.mutate(choice),
115
- leaving: approval.isPending || approval.data?.ok === true,
116
- approveError: approval.error,
228
+ approve: (choice) => approval.mutate({ api, query, choice, operation: capture() }),
229
+ leaving: currentApproval && (approval.isPending || approval.data?.ok === true),
230
+ approveError: currentApproval ? approval.error : null,
117
231
  };
118
232
  }
119
233
 
120
234
  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>;
235
+ /** Forward `options`, including its signal, to your HTTP client. */
236
+ describe(code: string, options?: RequestOptions): Promise<DeviceView<Extra> | OAuthRefusal>;
123
237
  /** Your API's call to `decideDevice`. `choice` comes with a yes only, as on the consent page. */
124
238
  decide(
125
239
  code: string,
126
240
  decision: "approve" | "deny",
127
241
  choice?: Choice,
242
+ options?: RequestOptions,
128
243
  ): Promise<{ ok: true } | OAuthRefusal>;
129
244
  }
130
245
 
@@ -165,6 +280,9 @@ export interface DeviceApproval<Choice = void, Extra extends object = object> {
165
280
  const ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
166
281
  const LENGTH = 8;
167
282
 
283
+ // Private wiring between the approval and field hooks, without adding to the public page API.
284
+ const deviceAddressWrites = new WeakMap<object, (code: string) => void>();
285
+
168
286
  /** Upper case, letters only, at most 8, with the dash after the fourth: what the field shows. */
169
287
  export function formatUserCode(input: string): string {
170
288
  const letters = input
@@ -175,7 +293,7 @@ export function formatUserCode(input: string): string {
175
293
  }
176
294
 
177
295
  /**
178
- * const device = useDeviceApproval(api.device, useLocation().search);
296
+ * const device = useDeviceApproval(api.device, useLocation().search, scope);
179
297
  * <input value={device.code} onChange={(e) => device.setCode(e.target.value)} />
180
298
  *
181
299
  * The link the script prints carries `?code=`, so the field usually starts full.
@@ -183,28 +301,69 @@ export function formatUserCode(input: string): string {
183
301
  export function useDeviceApproval<Choice = void, Extra extends object = object>(
184
302
  api: DeviceApi<Choice, Extra>,
185
303
  search: string,
304
+ scope: OAuthScope,
186
305
  ): DeviceApproval<Choice, Extra> {
187
- const [code, setTyped] = useState(() =>
188
- formatUserCode(new URLSearchParams(search).get("code") ?? ""),
189
- );
306
+ const fromLink = formatUserCode(new URLSearchParams(search).get("code") ?? "");
307
+ const [input, setInput] = useState<{
308
+ fromLink: string;
309
+ code: string;
310
+ ownAddress?: string;
311
+ }>({ fromLink, code: fromLink });
312
+ const recordAddressWrite = useCallback((ownAddress: string) => {
313
+ setInput((current) => ({ ...current, ownAddress }));
314
+ }, []);
315
+ // Only external links replace input. The field also writes the address while typing and
316
+ // clears it after a decision; neither write should replace input or invalidate the decision.
317
+ const code = input.fromLink === fromLink || input.ownAddress === fromLink ? input.code : fromLink;
318
+ if (input.fromLink !== fromLink) setInput({ fromLink, code });
190
319
  const letters = code.replace("-", "");
191
320
  const invalidLetter = [...letters].some((letter) => !ALPHABET.includes(letter));
192
321
  const complete = letters.length === LENGTH && !invalidLetter;
322
+ const { key, lifetime, capture } = useOAuthOperation(scope, "device", letters);
193
323
 
194
324
  const decision = useMutation({
195
- mutationFn: (pressed: { decision: "approve" | "deny"; choice?: Choice }) =>
196
- api.decide(letters, pressed.decision, pressed.choice),
325
+ mutationFn: async (pressed: {
326
+ api: DeviceApi<Choice, Extra>;
327
+ code: string;
328
+ decision: "approve" | "deny";
329
+ choice?: Choice;
330
+ operation: OAuthOperation;
331
+ }) => {
332
+ const { api, code, decision, choice, operation } = pressed;
333
+ try {
334
+ assertCurrent(operation);
335
+ const answer = await api.decide(code, decision, choice, { signal: operation.signal });
336
+ assertCurrent(operation);
337
+ return answer;
338
+ } finally {
339
+ operation.release();
340
+ }
341
+ },
342
+ onSettled: (_answer, error, { operation }) => {
343
+ if (error === null) assertCurrent(operation);
344
+ },
345
+ retry: false,
346
+ networkMode: "always",
197
347
  });
198
- const decided = decision.isSuccess && decision.data.ok ? decision.variables.decision : undefined;
348
+ const currentDecision =
349
+ decision.variables?.operation.identity === lifetime.identity &&
350
+ decision.variables.operation.isCurrent();
351
+ const decided =
352
+ currentDecision && decision.isSuccess && decision.data.ok
353
+ ? decision.variables.decision
354
+ : undefined;
199
355
  const request = useQuery({
200
- queryKey: ["@gusnips/react/oauth", "device", letters],
201
- queryFn: () => api.describe(letters),
356
+ queryKey: key,
357
+ queryFn: ({ signal }) => api.describe(letters, { signal }),
202
358
  enabled: complete && decided === undefined,
359
+ placeholderData: () => undefined,
360
+ retry: false,
361
+ refetchOnWindowFocus: false,
203
362
  });
204
363
 
205
364
  const answer = request.data;
206
- const refusal =
207
- decision.data?.ok === false ? decision.data : answer?.ok === false ? answer : null;
365
+ let refusal = answer?.ok === false ? answer : null;
366
+ if (currentDecision && decision.data?.ok === false) refusal = decision.data;
208
367
  let state: DeviceState<Extra>;
209
368
  if (decided !== undefined) state = { status: decided === "approve" ? "approved" : "denied" };
210
369
  else if (!complete) state = { status: "typing" };
@@ -214,20 +373,26 @@ export function useDeviceApproval<Choice = void, Extra extends object = object>(
214
373
  state = { status: "failed", error: request.error, retry: () => void request.refetch() };
215
374
  else state = { status: "loading" };
216
375
 
217
- return {
376
+ const device: DeviceApproval<Choice, Extra> = {
218
377
  state,
219
378
  code,
220
- setCode: (input) => {
379
+ setCode: (value) => {
380
+ const next = formatUserCode(value);
381
+ if (next === code) return;
382
+ lifetime.invalidate();
221
383
  decision.reset();
222
- setTyped(formatUserCode(input));
384
+ setInput((current) => ({ ...current, code: next }));
223
385
  },
224
386
  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,
387
+ approve: (choice) =>
388
+ decision.mutate({ api, code: letters, decision: "approve", choice, operation: capture() }),
389
+ deny: () => decision.mutate({ api, code: letters, decision: "deny", operation: capture() }),
390
+ deciding: currentDecision && decision.isPending,
391
+ pending: currentDecision && decision.isPending ? decision.variables.decision : null,
392
+ decideError: currentDecision ? decision.error : null,
230
393
  };
394
+ deviceAddressWrites.set(device, recordAddressWrite);
395
+ return device;
231
396
  }
232
397
 
233
398
  export interface DeviceCodeField {
@@ -268,13 +433,16 @@ export function useDeviceCodeField<Choice, Extra extends object>(
268
433
  const statusId = `${id}-status`;
269
434
  const { status } = device.state;
270
435
  const inAddress = status === "approved" || status === "denied" ? "" : device.code;
436
+ const recordAddressWrite = deviceAddressWrites.get(device);
271
437
  useEffect(() => {
272
438
  const url = new URL(window.location.href);
273
439
  if (inAddress) url.searchParams.set("code", inAddress);
274
440
  else url.searchParams.delete("code");
275
- if (url.href !== window.location.href)
441
+ if (url.href !== window.location.href) {
442
+ recordAddressWrite?.(inAddress);
276
443
  window.history.replaceState(window.history.state, "", url);
277
- }, [inAddress]);
444
+ }
445
+ }, [inAddress, recordAddressWrite]);
278
446
 
279
447
  return {
280
448
  inputProps: {
@@ -299,8 +467,8 @@ export type OAuthConsoleHttp = Pick<ApiClient, "get" | "post">;
299
467
 
300
468
  /**
301
469
  * export const oauthApi = createOAuthConsoleApi(api, "/auth/oauth");
302
- * useConsentRequest(oauthApi.consent, search);
303
- * useDeviceApproval(oauthApi.device, search);
470
+ * useConsentRequest(oauthApi.consent, search, scope);
471
+ * useDeviceApproval(oauthApi.device, search, scope);
304
472
  *
305
473
  * Both pages' calls to the server's `oauthConsoleRoutes`, mounted at `basePath` behind your
306
474
  * sign-in. A choice travels as `choice`, which is where those routes hand it to your approver.
@@ -316,13 +484,15 @@ export function createOAuthConsoleApi<
316
484
  ): { consent: ConsentApi<Choice, ConsentExtra>; device: DeviceApi<Choice, DeviceExtra> } {
317
485
  return {
318
486
  consent: {
319
- describe: (query) => http.get(`${basePath}/request?${query}`),
320
- approve: (query, choice) => http.post(`${basePath}/approve`, { query, choice }),
487
+ describe: (query, options) => http.get(`${basePath}/request?${query}`, options),
488
+ approve: (query, choice, options) =>
489
+ http.post(`${basePath}/approve`, { query, choice }, options),
321
490
  },
322
491
  device: {
323
- describe: (code) => http.get(`${basePath}/device?code=${encodeURIComponent(code)}`),
324
- decide: (code, decision, choice) =>
325
- http.post(`${basePath}/device`, { code, decision, choice }),
492
+ describe: (code, options) =>
493
+ http.get(`${basePath}/device?code=${encodeURIComponent(code)}`, options),
494
+ decide: (code, decision, choice, options) =>
495
+ http.post(`${basePath}/device`, { code, decision, choice }, options),
326
496
  },
327
497
  };
328
498
  }
@@ -17,6 +17,11 @@ export interface SupabaseSessionAuth {
17
17
  getSession(): Promise<SupabaseSessionResult>;
18
18
  refreshSession(): Promise<SupabaseSessionResult & { error: unknown }>;
19
19
  signOut(options?: SignOut): Promise<{ error: unknown }>;
20
+ /** supabase-js always has it. {@link signOutEvenOffline} listens with it so it never sends a
21
+ * second `SIGNED_OUT`; without it, that cleanup runs every time. */
22
+ onAuthStateChange?(callback: (event: string) => void): {
23
+ data: { subscription: { unsubscribe(): void } };
24
+ };
20
25
  }
21
26
 
22
27
  /**
@@ -185,18 +190,38 @@ function localStorageOrMemory(): SupportedStorage {
185
190
  * of waiting for the event.
186
191
  */
187
192
  export async function signOutEvenOffline(
188
- auth: Pick<SupabaseSessionAuth, "signOut">,
193
+ auth: Pick<SupabaseSessionAuth, "signOut" | "onAuthStateChange">,
189
194
  { storage, storageKey }: SupabaseSessionStorage,
190
195
  ): Promise<{ error: unknown }> {
191
- const { error } = await withinSignOutDeadline(auth.signOut({ scope: "local" }));
192
- if (error == null) return { error: null };
196
+ // From 2.119.0 on, auth-js signs this device out by itself when a local sign-out meets a refresh
197
+ // that could not reach auth, and sends SIGNED_OUT when its backoff ends. The cleanup below would
198
+ // send it a second time, so it runs only if auth-js finished without sending one.
199
+ let announced = false;
200
+ const listening = auth.onAuthStateChange?.((event) => {
201
+ if (event === "SIGNED_OUT") announced = true;
202
+ });
203
+ const done = () => listening?.data.subscription.unsubscribe();
204
+ const first = auth.signOut({ scope: "local" }).catch((thrown: unknown) => ({ error: thrown }));
205
+ const { error } = await withinSignOutDeadline(first);
206
+ if (error == null) {
207
+ done();
208
+ return { error: null };
209
+ }
193
210
  // From 2.110.2 on, auth-js has already removed the session when only `/logout` failed, and has
194
211
  // already sent SIGNED_OUT. Going on would send it a second time.
195
- if ((await storage.getItem(storageKey)) === null) return { error };
212
+ if ((await storage.getItem(storageKey)) === null) {
213
+ done();
214
+ return { error };
215
+ }
196
216
  await storage.removeItem(storageKey);
197
- // Not awaited: inside auth-js it can wait behind the first call, which may still be in its
198
- // refresh backoff. The session is already out of storage, and storage is what a reload reads.
199
- void auth.signOut({ scope: "local" }).catch(() => {});
217
+ // Not awaited: the first call may still be in auth-js's refresh backoff, and a second one would
218
+ // wait behind it anyway. The session is already out of storage, and storage is what a reload reads.
219
+ void first
220
+ .then(async () => {
221
+ if (!announced) await auth.signOut({ scope: "local" });
222
+ })
223
+ .catch(() => {})
224
+ .finally(done);
200
225
  return { error };
201
226
  }
202
227