@gusnips/react 0.9.27 → 0.9.29

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/README.md CHANGED
@@ -108,7 +108,22 @@ Two rules come with it:
108
108
  has finished the first call: a moment later, or up to 22 seconds on auth-js 2.106.
109
109
 
110
110
  `error` is `null` when auth ended the session on the server too. Otherwise the person is signed
111
- out on this device and may still be signed in on others.
111
+ out on this device, and that session may still be live on the server.
112
+
113
+ It signs out this device only. auth-js's own default ends every session the person has, so signing
114
+ out of a laptop also signed them out of their phone, and a tab left open there failed its next
115
+ refresh with "Invalid Refresh Token". To end the others on purpose, give account settings a button
116
+ that calls `signOutOtherDevices`. It keeps this device signed in, so it can be pressed again when
117
+ it fails:
118
+
119
+ ```ts
120
+ import { signOutOtherDevices } from "@gusnips/react/supabase";
121
+
122
+ const { error } = await signOutOtherDevices(supabase.auth);
123
+ ```
124
+
125
+ `error` is `null` once auth has ended the other sessions. Like the sign-out, it waits on auth for
126
+ 3 seconds at most.
112
127
 
113
128
  Pass the same object to `createSupabaseSessionAdapter(supabase.auth, authStorage)` and the API
114
129
  client's sign-out goes through the helper too.
@@ -674,6 +689,60 @@ silently become model messages. It returns `false` for ordinary text. The hook n
674
689
  message, clears a draft or changes attached files; those stay with the app. It also has no timer
675
690
  or scheduler: offer a repeat command only when your app has real scheduling behind it.
676
691
 
692
+ ## Signing an agent in
693
+
694
+ When Claude or ChatGPT connects to your API, it sends the person to your consent page. When one of
695
+ your scripts signs in on a machine with no browser, it prints a short code, and the person types
696
+ it on your device page. `@gusnips/server/oauth` is the server half. These two hooks are the
697
+ logic for the two pages. The pages themselves are yours.
698
+
699
+ ```tsx
700
+ import { useConsentRequest } from "@gusnips/react/oauth";
701
+
702
+ const consent = useConsentRequest(api.oauth, useLocation().search);
703
+
704
+ if (consent.state.status === "ready")
705
+ return (
706
+ <Button onClick={() => consent.approve(workspaceId)} loading={consent.leaving}>
707
+ Allow {consent.state.view.clientName}
708
+ </Button>
709
+ );
710
+ ```
711
+
712
+ `api.oauth` is two calls to your own API: `describe(query)` and `approve(query, choice)`. They
713
+ reach the server's `describe` and `approve` through routes behind your sign-in. `state.status` is
714
+ `loading`, `refused`, `failed` or `ready`:
715
+
716
+ - **`refused`** means the request is not one to answer: an unknown app, or a return address it
717
+ never registered. Show `refusal.message` (or your own words for `refusal.reason`) and a link
718
+ home. Never a link back to the app, because sending the browser anywhere on a bad request is
719
+ how an open redirect is built.
720
+ - **`failed`** means the request didn't arrive or answer. Show what failed and `state.retry`.
721
+ - **`ready`** has the app's name and `redirectHost`, where the browser goes after the yes. Show
722
+ both: anyone can register any name, and the host is the part a stranger can't pick.
723
+
724
+ `leaving` stays `true` from the press until the browser has left, so the button never comes back
725
+ for a second press. Approve sends the browser on with `window.location.assign`, which also opens
726
+ an app's own scheme such as `cursor://`.
727
+
728
+ ```tsx
729
+ import { useDeviceApproval } from "@gusnips/react/oauth";
730
+
731
+ const device = useDeviceApproval(api.device, useLocation().search);
732
+
733
+ <input value={device.code} onChange={(event) => device.setCode(event.target.value)} />;
734
+ ```
735
+
736
+ The link the script prints carries `?code=`, so the field usually starts full. The field shows
737
+ what was typed as `WDJB-MJHT`, however it was typed. `invalidLetter` turns on as soon as a letter
738
+ no code uses (a vowel) is typed, so you can say so before all eight are in. `state.status` adds
739
+ three to the consent page's four: `typing` until the code is complete, then `approved` or
740
+ `denied`. When it is `ready`, show `view.userCode` and say: go on only if you started this
741
+ sign-in yourself, just now, and this is the code on your screen. Somebody can run the real script
742
+ and send a stranger the link, and that sentence is the only guard against it. Once decided, the
743
+ page stays decided: it never asks the server again, because the server no longer knows the code,
744
+ and asking on the next focus would tell someone who just said yes that their code is wrong.
745
+
677
746
  ## Also here
678
747
 
679
748
  `ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
@@ -755,6 +824,7 @@ lives behind a subpath, so you install a dependency only if you import the thing
755
824
  | `@gusnips/react/contract` | two prerender names | nothing |
756
825
  | `@gusnips/react/theme` | `createTheme`, `startTheme` | react |
757
826
  | `@gusnips/react/commands` | slash matching and `useSlashCommands` | react |
827
+ | `@gusnips/react/oauth` | `useConsentRequest`, `useDeviceApproval` | @tanstack/react-query |
758
828
 
759
829
  The rule behind that table: **a peer marked optional must not be reachable from the main entry
760
830
  point.** An optional peer the barrel imports anyway is not optional: it is a required one whose
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgCA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,EACX,KAAK,oBAAoB,EACzB,KAAK,SAAS,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,2BAA2B,EAChC,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAiCA,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,WAAW,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,EACf,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAClB,KAAK,cAAc,EACnB,KAAK,cAAc,GACpB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,EACX,KAAK,oBAAoB,EACzB,KAAK,SAAS,GACf,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,EACb,KAAK,0BAA0B,EAC/B,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,qBAAqB,EAC1B,KAAK,2BAA2B,EAChC,KAAK,YAAY,EACjB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAElF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@
15
15
  // @gusnips/react/supabase the session adapter → @supabase/supabase-js
16
16
  // @gusnips/react/theme the theme controller → react only, but `@gusnips/vite/theme`
17
17
  // serializes it and must not load the rest
18
+ // @gusnips/react/oauth the sign-in pages' logic → @tanstack/react-query, at runtime
18
19
  //
19
20
  // `react-dom` is the one the second migration added, and it is the sharpest case yet: that
20
21
  // adopter ships a REACT NATIVE app beside its two web apps, and React Native has no react-dom to
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAoC,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,GAGZ,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GASb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2CAA2C;AAC3C,EAAE;AACF,+FAA+F;AAC/F,gGAAgG;AAChG,+FAA+F;AAC/F,iGAAiG;AACjG,0BAA0B;AAC1B,EAAE;AACF,qEAAqE;AACrE,8DAA8D;AAC9D,uEAAuE;AACvE,gEAAgE;AAChE,4EAA4E;AAC5E,4FAA4F;AAC5F,8FAA8F;AAC9F,0FAA0F;AAC1F,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,+FAA+F;AAC/F,gGAAgG;AAChG,4EAA4E;AAC5E,EAAE;AACF,2FAA2F;AAC3F,iGAAiG;AACjG,qFAAqF;AACrF,6FAA6F;AAC7F,iGAAiG;AACjG,yFAAyF;AACzF,uBAAuB;AAEvB,OAAO,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAC7B,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAoC,MAAM,WAAW,CAAC;AAChF,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAiB,CAAC;AAC3C,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACnD,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAkB,MAAM,oBAAoB,CAAC;AAE9F,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEtF,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EACL,eAAe,GAMhB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,eAAe,EAAE,aAAa,EAAiB,MAAM,UAAU,CAAC;AAEzE,OAAO,EACL,aAAa,EACb,SAAS,EACT,YAAY,EACZ,WAAW,GAGZ,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,aAAa,GAGd,MAAM,qBAAsB,CAAC;AAC9B,OAAO,EACL,oBAAoB,EACpB,YAAY,GASb,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,0BAA0B,EAC1B,gBAAgB,EAChB,oBAAoB,EACpB,YAAY,EACZ,gBAAgB,EAChB,UAAU,GACX,MAAM,sBAAsB,CAAC;AAE9B,iGAAiG;AACjG,kGAAkG;AAClG,6FAA6F;AAC7F,OAAO,EAAE,sBAAsB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE9E,OAAO,EAAE,cAAc,EAAE,eAAe,EAAwB,MAAM,WAAW,CAAC"}
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The two pages behind an agent's sign-in: the consent page, where Claude or ChatGPT sends a
3
+ * person to connect, and the device page, where a script's short code is typed and approved.
4
+ * `@gusnips/server/oauth` is the server half. Logic only: the pages are the product's.
5
+ *
6
+ * Each hook takes your two API calls, so your client, your session and your envelope stay yours,
7
+ * and returns one state to draw. What they settle once:
8
+ *
9
+ * - A refusal (an unknown app, a return address it never registered) is a STATE, never a
10
+ * redirect: sending the browser anywhere on a bad request is how an open redirect is built.
11
+ * - Approve stays busy from the press until the browser has left. The page is going away, so the
12
+ * button must never come back and invite a second press.
13
+ * - A device code is read however it is typed (`wdjb mjht`, `WDJB-MJHT`), and once decided the
14
+ * page stays decided. Asked again, the server no longer knows the code, and a page that asked on
15
+ * the next focus would tell someone who just approved that their code is wrong.
16
+ */
17
+ export type OAuthRefusalReason = "unknown-client" | "redirect-mismatch" | "invalid-request" | "wrong-resource" | "unknown-code";
18
+ /** What the server sends instead of a view. Word your own page for `reason`. */
19
+ export interface OAuthRefusal {
20
+ ok: false;
21
+ reason: OAuthRefusalReason;
22
+ /** An English fallback a person can read. */
23
+ message: string;
24
+ }
25
+ export interface ConsentView {
26
+ ok: true;
27
+ /** What the app calls itself. Anyone may pick any name, so show `redirectHost` beside it. */
28
+ clientName: string;
29
+ /** Where the browser goes after the yes: a host, or an app's own scheme (`cursor:`). */
30
+ redirectHost: string;
31
+ /** The key's name on your keys page. */
32
+ keyName: string;
33
+ /** Where "Cancel" goes: back to the app, which is told the person said no. */
34
+ cancelUrl: string;
35
+ }
36
+ export interface DeviceView {
37
+ ok: true;
38
+ clientName: string;
39
+ /** As the script printed it: `WDJB-MJHT`. */
40
+ userCode: string;
41
+ keyName: string;
42
+ /** ISO time: JSON carries the server's `Date` as a string. */
43
+ expiresAt: string;
44
+ }
45
+ export interface ConsentApi<Choice> {
46
+ /** Your API's call to the server's `describe`, with the query string as it came. */
47
+ describe(query: string): Promise<ConsentView | OAuthRefusal>;
48
+ /** Your API's call to `approve`. `choice` is anything your page adds, such as a workspace. */
49
+ approve(query: string, choice: Choice): Promise<{
50
+ ok: true;
51
+ redirectTo: string;
52
+ } | OAuthRefusal>;
53
+ }
54
+ export type ConsentState = {
55
+ status: "loading";
56
+ }
57
+ /** Show `refusal` and a way home. Never a link back to the app. */
58
+ | {
59
+ status: "refused";
60
+ refusal: OAuthRefusal;
61
+ }
62
+ /** The request failed to arrive or answer. Show what failed, and `retry`. */
63
+ | {
64
+ status: "failed";
65
+ error: unknown;
66
+ retry: () => void;
67
+ } | {
68
+ status: "ready";
69
+ view: ConsentView;
70
+ };
71
+ export interface ConsentRequest<Choice> {
72
+ state: ConsentState;
73
+ approve(choice: Choice): void;
74
+ /** From the press until the browser has left. Keep the button busy the whole time. */
75
+ leaving: boolean;
76
+ /** Approve did not answer. Show it beside the button; pressing again retries. */
77
+ approveError: unknown;
78
+ }
79
+ /**
80
+ * const consent = useConsentRequest(api.oauth, useLocation().search);
81
+ * if (consent.state.status === "ready") ... consent.approve(workspaceId)
82
+ */
83
+ export declare function useConsentRequest<Choice = void>(api: ConsentApi<Choice>, search: string): ConsentRequest<Choice>;
84
+ export interface DeviceApi {
85
+ /** Your API's call to the server's `describeDevice`. */
86
+ describe(code: string): Promise<DeviceView | OAuthRefusal>;
87
+ /** Your API's call to `decideDevice`. */
88
+ decide(code: string, decision: "approve" | "deny"): Promise<{
89
+ ok: true;
90
+ } | OAuthRefusal>;
91
+ }
92
+ export type DeviceState =
93
+ /** Fewer than 8 letters so far, or one a code never uses (see `invalidLetter`). */
94
+ {
95
+ status: "typing";
96
+ } | {
97
+ status: "loading";
98
+ }
99
+ /** Unknown, expired or already decided: ask them to start again from the app. */
100
+ | {
101
+ status: "refused";
102
+ refusal: OAuthRefusal;
103
+ } | {
104
+ status: "failed";
105
+ error: unknown;
106
+ retry: () => void;
107
+ }
108
+ /**
109
+ * Show `view.userCode` and say: go on only if you started this sign-in yourself, just now, and
110
+ * this is the code on your screen. Somebody can run the real script and send a stranger the
111
+ * link, and that sentence is the only guard against it (RFC 8628 §5.4).
112
+ */
113
+ | {
114
+ status: "ready";
115
+ view: DeviceView;
116
+ }
117
+ /** Done. Tell them to go back to the app, which carries on by itself. */
118
+ | {
119
+ status: "approved";
120
+ } | {
121
+ status: "denied";
122
+ };
123
+ export interface DeviceApproval {
124
+ state: DeviceState;
125
+ /** What the field shows, formatted as they type: `WDJB-MJ`. */
126
+ code: string;
127
+ setCode(input: string): void;
128
+ /** A typed letter no code uses: a vowel. Say so now, not after eight letters. */
129
+ invalidLetter: boolean;
130
+ approve(): void;
131
+ deny(): void;
132
+ deciding: boolean;
133
+ /** The decision did not answer. Show it beside the buttons; pressing again retries. */
134
+ decideError: unknown;
135
+ }
136
+ /** Upper case, letters only, at most 8, with the dash after the fourth: what the field shows. */
137
+ export declare function formatUserCode(input: string): string;
138
+ /**
139
+ * const device = useDeviceApproval(api.device, useLocation().search);
140
+ * <input value={device.code} onChange={(e) => device.setCode(e.target.value)} />
141
+ *
142
+ * The link the script prints carries `?code=`, so the field usually starts full.
143
+ */
144
+ export declare function useDeviceApproval(api: DeviceApi, search: string): DeviceApproval;
145
+ //# sourceMappingURL=oauth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth.d.ts","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
package/dist/oauth.js ADDED
@@ -0,0 +1,99 @@
1
+ import { useMutation, useQuery } from "@tanstack/react-query";
2
+ import { useState } from "react";
3
+ /**
4
+ * const consent = useConsentRequest(api.oauth, useLocation().search);
5
+ * if (consent.state.status === "ready") ... consent.approve(workspaceId)
6
+ */
7
+ export function useConsentRequest(api, search) {
8
+ const query = search.replace(/^\?/, "");
9
+ const request = useQuery({
10
+ queryKey: ["@gusnips/react/oauth", "consent", query],
11
+ queryFn: () => api.describe(query),
12
+ });
13
+ const approval = useMutation({
14
+ mutationFn: (choice) => api.approve(query, choice),
15
+ onSuccess: (answer) => {
16
+ if (answer.ok)
17
+ window.location.assign(answer.redirectTo);
18
+ },
19
+ });
20
+ // A refusal from approve (the request went stale between the two calls) outranks the view.
21
+ const answer = request.data;
22
+ const refusal = approval.data?.ok === false ? approval.data : answer?.ok === false ? answer : null;
23
+ let state;
24
+ if (refusal)
25
+ state = { status: "refused", refusal };
26
+ else if (answer?.ok === true)
27
+ state = { status: "ready", view: answer };
28
+ else if (request.isError)
29
+ state = { status: "failed", error: request.error, retry: () => void request.refetch() };
30
+ else
31
+ state = { status: "loading" };
32
+ return {
33
+ state,
34
+ approve: (choice) => approval.mutate(choice),
35
+ leaving: approval.isPending || approval.data?.ok === true,
36
+ approveError: approval.error,
37
+ };
38
+ }
39
+ /** The server's alphabet: no vowels, so a code never spells a word. */
40
+ const ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
41
+ const LENGTH = 8;
42
+ /** Upper case, letters only, at most 8, with the dash after the fourth: what the field shows. */
43
+ export function formatUserCode(input) {
44
+ const letters = input
45
+ .toUpperCase()
46
+ .replace(/[^A-Z]/g, "")
47
+ .slice(0, LENGTH);
48
+ return letters.length > 4 ? `${letters.slice(0, 4)}-${letters.slice(4)}` : letters;
49
+ }
50
+ /**
51
+ * const device = useDeviceApproval(api.device, useLocation().search);
52
+ * <input value={device.code} onChange={(e) => device.setCode(e.target.value)} />
53
+ *
54
+ * The link the script prints carries `?code=`, so the field usually starts full.
55
+ */
56
+ export function useDeviceApproval(api, search) {
57
+ const [code, setTyped] = useState(() => formatUserCode(new URLSearchParams(search).get("code") ?? ""));
58
+ const letters = code.replace("-", "");
59
+ const invalidLetter = [...letters].some((letter) => !ALPHABET.includes(letter));
60
+ const complete = letters.length === LENGTH && !invalidLetter;
61
+ const decision = useMutation({
62
+ mutationFn: (choice) => api.decide(letters, choice),
63
+ });
64
+ const decided = decision.data?.ok === true ? decision.variables : undefined;
65
+ const request = useQuery({
66
+ queryKey: ["@gusnips/react/oauth", "device", letters],
67
+ queryFn: () => api.describe(letters),
68
+ enabled: complete && decided === undefined,
69
+ });
70
+ const answer = request.data;
71
+ const refusal = decision.data?.ok === false ? decision.data : answer?.ok === false ? answer : null;
72
+ let state;
73
+ if (decided !== undefined)
74
+ state = { status: decided === "approve" ? "approved" : "denied" };
75
+ else if (!complete)
76
+ state = { status: "typing" };
77
+ else if (refusal)
78
+ state = { status: "refused", refusal };
79
+ else if (answer?.ok === true)
80
+ state = { status: "ready", view: answer };
81
+ else if (request.isError)
82
+ state = { status: "failed", error: request.error, retry: () => void request.refetch() };
83
+ else
84
+ state = { status: "loading" };
85
+ return {
86
+ state,
87
+ code,
88
+ setCode: (input) => {
89
+ decision.reset();
90
+ setTyped(formatUserCode(input));
91
+ },
92
+ invalidLetter,
93
+ approve: () => decision.mutate("approve"),
94
+ deny: () => decision.mutate("deny"),
95
+ deciding: decision.isPending,
96
+ decideError: decision.error,
97
+ };
98
+ }
99
+ //# sourceMappingURL=oauth.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oauth.js","sourceRoot":"","sources":["../src/oauth.ts"],"names":[],"mappings":"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"}
@@ -93,11 +93,17 @@ export declare function supabaseAuthStorage(supabaseUrl: string): SupabaseSessio
93
93
  * it touches storage. This happens at every version, and it is the common case: a laptop that
94
94
  * wakes up offline with a tab still open.
95
95
  * - Before 2.110.2, the token is still good but `/logout` gets no answer. `signOut()` returns
96
- * before it removes anything. `scope: "local"` does not help, because it calls `/logout` too.
96
+ * before it removes anything.
97
97
  *
98
98
  * Either way a reload reads the session and signs the person back in. On a shared computer, that
99
99
  * person is whoever sits down next. A screen that says "signed out" at that point is wrong.
100
100
  *
101
+ * It ends this device's session and no other: `signOut({ scope: "local" })`, which fails in both
102
+ * cases above too, because it calls `/logout` as well. auth-js's default is `"global"`, which ends
103
+ * every session the person has. Signing out of a laptop then signed them out of their phone, and a
104
+ * tab left open anywhere failed its next refresh with "Invalid Refresh Token: Refresh Token Not
105
+ * Found". To end the other sessions on purpose, use {@link signOutOtherDevices}.
106
+ *
101
107
  * Removing the key yourself is not enough on its own: nothing tells your `onAuthStateChange`
102
108
  * listeners or your other tabs, and auth-js's PKCE verifiers stay behind. So after removing it,
103
109
  * this calls `signOut({ scope: "local" })`. That call finds no session, skips `/logout`, and runs
@@ -110,7 +116,7 @@ export declare function supabaseAuthStorage(supabaseUrl: string): SupabaseSessio
110
116
  *
111
117
  * It resolves once the stored session is gone. `error` is `null` when auth ended the session on the
112
118
  * server too. Otherwise it is the reason auth did not: its error, a thrown value or the deadline.
113
- * The session may still be live on the server and on other devices. `SIGNED_OUT` arrives once
119
+ * The session may still be live on the server. `SIGNED_OUT` arrives once
114
120
  * auth-js has finished with the first call: about 100 ms later from 2.108.2 on, and up to 22 s
115
121
  * later at 2.106.2. If your screen does not reload, clear your own store when this resolves instead
116
122
  * of waiting for the event.
@@ -118,6 +124,21 @@ export declare function supabaseAuthStorage(supabaseUrl: string): SupabaseSessio
118
124
  export declare function signOutEvenOffline(auth: Pick<SupabaseSessionAuth, "signOut">, { storage, storageKey }: SupabaseSessionStorage): Promise<{
119
125
  error: unknown;
120
126
  }>;
127
+ /**
128
+ * Ends the person's sessions on every other device and keeps this one signed in. For a "sign out
129
+ * of other devices" button in account settings: a phone that was lost, a computer left signed in.
130
+ *
131
+ * It resolves `{ error: null }` once auth has ended the other sessions. Otherwise `error` says why
132
+ * it did not: auth's error, a thrown value, or the deadline. This device is untouched either way,
133
+ * so the person is still signed in and can press the button again.
134
+ *
135
+ * Like {@link signOutEvenOffline}, it waits on auth for {@link SIGN_OUT_TIMEOUT_MS} at most, because
136
+ * offline with an expired token auth-js first sits in its refresh backoff. A deadline does not
137
+ * prove the other sessions are still live: auth may end them after this gave up.
138
+ */
139
+ export declare function signOutOtherDevices(auth: Pick<SupabaseSessionAuth, "signOut">): Promise<{
140
+ error: unknown;
141
+ }>;
121
142
  /**
122
143
  * Connects a Supabase auth client to {@link SessionAdapter}.
123
144
  *
@@ -130,7 +151,8 @@ export declare function signOutEvenOffline(auth: Pick<SupabaseSessionAuth, "sign
130
151
  * rejection means. It still rejects when auth did not end the session, but by then the session
131
152
  * has already left this device. And a sign-out auth has not answered within
132
153
  * {@link SIGN_OUT_TIMEOUT_MS} now rejects instead of hanging. Without `stored`, it is
133
- * `auth.signOut()` and rejects on that call's error, as before.
154
+ * `auth.signOut({ scope: "local" })` and rejects on that call's error. Either way it ends this
155
+ * device's session only: a token one device's API refused says nothing about the others.
134
156
  *
135
157
  * `stored` is optional here but not at a sign-out button. The API client signs out only after
136
158
  * auth has answered: a refresh auth refused, or a new token the API refused. So the network was up
@@ -1 +1 @@
1
- {"version":3,"file":"supabase-session.d.ts","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,OAAO,EAGZ,KAAK,gBAAgB,EACtB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAuB,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAE3E,UAAU,qBAAqB;IAC7B,IAAI,EAAE;QAAE,OAAO,EAAE;YAAE,YAAY,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAA;KAAE,CAAC;CACpD;AAED,8FAA8F;AAC9F,MAAM,WAAW,mBAAmB;IAClC,UAAU,IAAI,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC7C,cAAc,IAAI,OAAO,CAAC,qBAAqB,GAAG;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACtE,OAAO,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CACzD;AAED;;;;;;GAMG;AACH,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,gBAAgB,CAAC;IAC1B,UAAU,EAAE,MAAM,CAAC;CACpB;AAUD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CASpD;AAaD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,sBAAsB,CAE/E;AA2BD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,IAAI,CAAC,mBAAmB,EAAE,SAAS,CAAC,EAC1C,EAAE,OAAO,EAAE,UAAU,EAAE,EAAE,sBAAsB,GAC9C,OAAO,CAAC;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,CAAC,CAyB7B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,4BAA4B,CAC1C,IAAI,EAAE,mBAAmB,EACzB,MAAM,CAAC,EAAE,sBAAsB,GAC9B,cAAc,CAsBhB;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,aAAa,GACvB,QAAQ,GAAG,QAAQ,GAAG,WAAW,GAAG,UAAU,GAAG,cAAc,GAAG,OAAO,CAAC;AAiB5E,qFAAqF;AACrF,MAAM,MAAM,YAAY;AACtB,0FAA0F;AACxF;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,aAAa,CAAA;CAAE;AAChE;;;;;;GAMG;GACD;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,IAAI,EAAE,aAAa,GAAG,IAAI,CAAC;IAC3B,UAAU,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG;QAAE,YAAY,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;CAChF;AACH;;;;GAIG;GACD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE;AAClF,qFAAqF;GACnF;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAExB;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,eAAe,EAAE,IAAI,SAAK,GAAG,YAAY,CA6B3F"}
1
+ {"version":3,"file":"supabase-session.d.ts","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"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"}
@@ -116,11 +116,17 @@ function localStorageOrMemory() {
116
116
  * it touches storage. This happens at every version, and it is the common case: a laptop that
117
117
  * wakes up offline with a tab still open.
118
118
  * - Before 2.110.2, the token is still good but `/logout` gets no answer. `signOut()` returns
119
- * before it removes anything. `scope: "local"` does not help, because it calls `/logout` too.
119
+ * before it removes anything.
120
120
  *
121
121
  * Either way a reload reads the session and signs the person back in. On a shared computer, that
122
122
  * person is whoever sits down next. A screen that says "signed out" at that point is wrong.
123
123
  *
124
+ * It ends this device's session and no other: `signOut({ scope: "local" })`, which fails in both
125
+ * cases above too, because it calls `/logout` as well. auth-js's default is `"global"`, which ends
126
+ * every session the person has. Signing out of a laptop then signed them out of their phone, and a
127
+ * tab left open anywhere failed its next refresh with "Invalid Refresh Token: Refresh Token Not
128
+ * Found". To end the other sessions on purpose, use {@link signOutOtherDevices}.
129
+ *
124
130
  * Removing the key yourself is not enough on its own: nothing tells your `onAuthStateChange`
125
131
  * listeners or your other tabs, and auth-js's PKCE verifiers stay behind. So after removing it,
126
132
  * this calls `signOut({ scope: "local" })`. That call finds no session, skips `/logout`, and runs
@@ -133,23 +139,13 @@ function localStorageOrMemory() {
133
139
  *
134
140
  * It resolves once the stored session is gone. `error` is `null` when auth ended the session on the
135
141
  * server too. Otherwise it is the reason auth did not: its error, a thrown value or the deadline.
136
- * The session may still be live on the server and on other devices. `SIGNED_OUT` arrives once
142
+ * The session may still be live on the server. `SIGNED_OUT` arrives once
137
143
  * auth-js has finished with the first call: about 100 ms later from 2.108.2 on, and up to 22 s
138
144
  * later at 2.106.2. If your screen does not reload, clear your own store when this resolves instead
139
145
  * of waiting for the event.
140
146
  */
141
147
  export async function signOutEvenOffline(auth, { storage, storageKey }) {
142
- let timer;
143
- const deadline = new Promise((resolve) => {
144
- timer = setTimeout(() => resolve({
145
- error: new Error(`Auth did not answer the sign-out within ${SIGN_OUT_TIMEOUT_MS} ms`),
146
- }), SIGN_OUT_TIMEOUT_MS);
147
- });
148
- const { error } = await Promise.race([
149
- auth.signOut().catch((thrown) => ({ error: thrown })),
150
- deadline,
151
- ]);
152
- clearTimeout(timer);
148
+ const { error } = await withinSignOutDeadline(auth.signOut({ scope: "local" }));
153
149
  if (error == null)
154
150
  return { error: null };
155
151
  // From 2.110.2 on, auth-js has already removed the session when only `/logout` failed, and has
@@ -162,6 +158,36 @@ export async function signOutEvenOffline(auth, { storage, storageKey }) {
162
158
  void auth.signOut({ scope: "local" }).catch(() => { });
163
159
  return { error };
164
160
  }
161
+ /**
162
+ * Ends the person's sessions on every other device and keeps this one signed in. For a "sign out
163
+ * of other devices" button in account settings: a phone that was lost, a computer left signed in.
164
+ *
165
+ * It resolves `{ error: null }` once auth has ended the other sessions. Otherwise `error` says why
166
+ * it did not: auth's error, a thrown value, or the deadline. This device is untouched either way,
167
+ * so the person is still signed in and can press the button again.
168
+ *
169
+ * Like {@link signOutEvenOffline}, it waits on auth for {@link SIGN_OUT_TIMEOUT_MS} at most, because
170
+ * offline with an expired token auth-js first sits in its refresh backoff. A deadline does not
171
+ * prove the other sessions are still live: auth may end them after this gave up.
172
+ */
173
+ export function signOutOtherDevices(auth) {
174
+ return withinSignOutDeadline(auth.signOut({ scope: "others" }));
175
+ }
176
+ /** `call`'s result, or the deadline as its error once {@link SIGN_OUT_TIMEOUT_MS} has passed. */
177
+ async function withinSignOutDeadline(call) {
178
+ let timer;
179
+ const deadline = new Promise((resolve) => {
180
+ timer = setTimeout(() => resolve({
181
+ error: new Error(`Auth did not answer the sign-out within ${SIGN_OUT_TIMEOUT_MS} ms`),
182
+ }), SIGN_OUT_TIMEOUT_MS);
183
+ });
184
+ const result = await Promise.race([
185
+ call.catch((thrown) => ({ error: thrown })),
186
+ deadline,
187
+ ]);
188
+ clearTimeout(timer);
189
+ return result;
190
+ }
165
191
  /**
166
192
  * Connects a Supabase auth client to {@link SessionAdapter}.
167
193
  *
@@ -174,7 +200,8 @@ export async function signOutEvenOffline(auth, { storage, storageKey }) {
174
200
  * rejection means. It still rejects when auth did not end the session, but by then the session
175
201
  * has already left this device. And a sign-out auth has not answered within
176
202
  * {@link SIGN_OUT_TIMEOUT_MS} now rejects instead of hanging. Without `stored`, it is
177
- * `auth.signOut()` and rejects on that call's error, as before.
203
+ * `auth.signOut({ scope: "local" })` and rejects on that call's error. Either way it ends this
204
+ * device's session only: a token one device's API refused says nothing about the others.
178
205
  *
179
206
  * `stored` is optional here but not at a sign-out button. The API client signs out only after
180
207
  * auth has answered: a refresh auth refused, or a new token the API refused. So the network was up
@@ -199,7 +226,9 @@ export function createSupabaseSessionAdapter(auth, stored) {
199
226
  }
200
227
  },
201
228
  signOut: async () => {
202
- const { error } = stored ? await signOutEvenOffline(auth, stored) : await auth.signOut();
229
+ const { error } = stored
230
+ ? await signOutEvenOffline(auth, stored)
231
+ : await auth.signOut({ scope: "local" });
203
232
  if (error)
204
233
  throw error;
205
234
  },
@@ -1 +1 @@
1
- {"version":3,"file":"supabase-session.js","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"supabase-session.js","sourceRoot":"","sources":["../src/supabase-session.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,WAAW,EACX,yBAAyB,GAK1B,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,mBAAmB,EAAuB,MAAM,iBAAiB,CAAC;AAiC3E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,UAAU,YAAY,CAAC,KAAc;IACzC,4FAA4F;IAC5F,6EAA6E;IAC7E,8FAA8F;IAC9F,8FAA8F;IAC9F,gFAAgF;IAChF,IAAI,KAAK,IAAI,IAAI;QAAE,OAAO,KAAK,CAAC;IAChC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACrC,OAAO,yBAAyB,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,IAAI,CAAC,CAAC,IAAI,GAAG,CAAC;AACxE,CAAC;AAED,SAAS,WAAW,CAAC,KAAc;IACjC,0FAA0F;IAC1F,uFAAuF;IACvF,kFAAkF;IAClF,8EAA8E;IAC9E,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,IAAI,YAAY,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAC7D,8FAA8F;IAC9F,8FAA8F;IAC9F,OAAO,KAAK,CAAC,IAAI,KAAK,2BAA2B,CAAC;AACpD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,WAAmB;IACpD,OAAO,MAAM,IAAI,GAAG,CAAC,WAAW,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC;AACxE,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,mBAAmB,CAAC,WAAmB;IACrD,OAAO,EAAE,OAAO,EAAE,oBAAoB,EAAE,EAAE,UAAU,EAAE,kBAAkB,CAAC,WAAW,CAAC,EAAE,CAAC;AAC1F,CAAC;AAED,SAAS,oBAAoB;IAC3B,0FAA0F;IAC1F,4FAA4F;IAC5F,IAAI,OAAO,MAAM,KAAK,WAAW,IAAI,OAAO,QAAQ,KAAK,WAAW,EAAE,CAAC;QACrE,IAAI,CAAC;YACH,MAAM,EAAE,YAAY,EAAE,GAAG,UAAU,CAAC;YACpC,YAAY,CAAC,OAAO,CAAC,kBAAkB,EAAE,GAAG,CAAC,CAAC;YAC9C,YAAY,CAAC,UAAU,CAAC,kBAAkB,CAAC,CAAC;YAC5C,OAAO,YAAY,CAAC;QACtB,CAAC;QAAC,MAAM,CAAC;YACP,0BAA0B;QAC5B,CAAC;IACH,CAAC;IACD,MAAM,KAAK,GAAG,IAAI,GAAG,EAAkB,CAAC;IACxC,OAAO;QACL,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI;QACxC,OAAO,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE;YACtB,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QACxB,CAAC;QACD,UAAU,EAAE,CAAC,GAAG,EAAE,EAAE;YAClB,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACpB,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB,CACtC,IAA0C,EAC1C,EAAE,OAAO,EAAE,UAAU,EAA0B;IAE/C,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,qBAAqB,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;IAChF,IAAI,KAAK,IAAI,IAAI;QAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IAC1C,+FAA+F;IAC/F,iEAAiE;IACjE,IAAI,CAAC,MAAM,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,KAAK,IAAI;QAAE,OAAO,EAAE,KAAK,EAAE,CAAC;IACnE,MAAM,OAAO,CAAC,UAAU,CAAC,UAAU,CAAC,CAAC;IACrC,2FAA2F;IAC3F,8FAA8F;IAC9F,KAAK,IAAI,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACtD,OAAO,EAAE,KAAK,EAAE,CAAC;AACnB,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,mBAAmB,CACjC,IAA0C;IAE1C,OAAO,qBAAqB,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC;AAClE,CAAC;AAED,iGAAiG;AACjG,KAAK,UAAU,qBAAqB,CAClC,IAAiC;IAEjC,IAAI,KAAgD,CAAC;IACrD,MAAM,QAAQ,GAAG,IAAI,OAAO,CAAqB,CAAC,OAAO,EAAE,EAAE;QAC3D,KAAK,GAAG,UAAU,CAChB,GAAG,EAAE,CACH,OAAO,CAAC;YACN,KAAK,EAAE,IAAI,KAAK,CAAC,2CAA2C,mBAAmB,KAAK,CAAC;SACtF,CAAC,EACJ,mBAAmB,CACpB,CAAC;IACJ,CAAC,CAAC,CAAC;IACH,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC;QAChC,IAAI,CAAC,KAAK,CAAC,CAAC,MAAe,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;QACpD,QAAQ;KACT,CAAC,CAAC;IACH,YAAY,CAAC,KAAK,CAAC,CAAC;IACpB,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,UAAU,4BAA4B,CAC1C,IAAyB,EACzB,MAA+B;IAE/B,OAAO;QACL,QAAQ,EAAE,KAAK,IAAI,EAAE;YACnB,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE,CAAC;YACzC,OAAO,IAAI,CAAC,OAAO,EAAE,YAAY,IAAI,IAAI,CAAC;QAC5C,CAAC;QACD,OAAO,EAAE,KAAK,IAAI,EAAE;YAClB,IAAI,CAAC;gBACH,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC,cAAc,EAAE,CAAC;gBACpD,OAAO;oBACL,KAAK,EAAE,IAAI,CAAC,OAAO,EAAE,YAAY,IAAI,IAAI;oBACzC,WAAW,EAAE,KAAK,KAAK,IAAI,IAAI,WAAW,CAAC,KAAK,CAAC;iBAClD,CAAC;YACJ,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,WAAW,EAAE,WAAW,CAAC,KAAK,CAAC,EAAE,CAAC;YAC1D,CAAC;QACH,CAAC;QACD,OAAO,EAAE,KAAK,IAAI,EAAE;YAClB,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM;gBACtB,CAAC,CAAC,MAAM,kBAAkB,CAAC,IAAI,EAAE,MAAM,CAAC;gBACxC,CAAC,CAAC,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAC;YAC3C,IAAI,KAAK;gBAAE,MAAM,KAAK,CAAC;QACzB,CAAC;KACF,CAAC;AACJ,CAAC;AAkBD,MAAM,gBAAgB,GAAgC;IACpD,MAAM,EAAE,IAAI;IACZ,MAAM,EAAE,IAAI;IACZ,SAAS,EAAE,IAAI;IACf,QAAQ,EAAE,IAAI;IACd,YAAY,EAAE,IAAI;IAClB,KAAK,EAAE,IAAI;CACZ,CAAC;AAEF,SAAS,eAAe,CAAC,KAAa;IACpC,6FAA6F;IAC7F,YAAY;IACZ,OAAO,MAAM,CAAC,MAAM,CAAC,gBAAgB,EAAE,KAAK,CAAC,CAAC;AAChD,CAAC;AA2BD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,iBAAiB,CAAC,MAAgC,EAAE,IAAI,GAAG,EAAE;IAC3E,MAAM,KAAK,GAAG,IAAI,eAAe,CAAC,MAAM,CAAC,CAAC;IAC1C,MAAM,QAAQ,GAAG,IAAI,eAAe,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC;IAC7D,MAAM,IAAI,GAAG,CAAC,IAAY,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC;IAE7E,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC;IAC5B,MAAM,IAAI,GAAG,IAAI,CAAC,YAAY,CAAC,CAAC;IAChC,IAAI,KAAK,IAAI,IAAI,IAAI,IAAI,CAAC,mBAAmB,CAAC;QAC5C,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,KAAK,eAAe,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;IAE/F,MAAM,OAAO,GAAG,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;IACnC,MAAM,IAAI,GAAG,eAAe,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;IAEvD,MAAM,SAAS,GAAG,KAAK,CAAC,GAAG,CAAC,YAAY,CAAC,EAAE,IAAI,EAAE,CAAC;IAClD,IAAI,SAAS;QAAE,OAAO,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;IAE3F,MAAM,QAAQ,GAAG,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IACnC,IAAI,QAAQ;QAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,UAAU,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,CAAC;IAE/E,MAAM,WAAW,GAAG,QAAQ,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IACjD,MAAM,YAAY,GAAG,QAAQ,CAAC,GAAG,CAAC,eAAe,CAAC,CAAC;IACnD,IAAI,WAAW,IAAI,YAAY;QAC7B,OAAO;YACL,IAAI,EAAE,SAAS;YACf,IAAI;YACJ,UAAU,EAAE,EAAE,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE;SACvE,CAAC;IAEJ,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;AAC7B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/react",
3
- "version": "0.9.27",
3
+ "version": "0.9.29",
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",
@@ -45,6 +45,10 @@
45
45
  "./commands": {
46
46
  "types": "./dist/commands.d.ts",
47
47
  "default": "./dist/commands.js"
48
+ },
49
+ "./oauth": {
50
+ "types": "./dist/oauth.d.ts",
51
+ "default": "./dist/oauth.js"
48
52
  }
49
53
  },
50
54
  "types": "./dist/index.d.ts",
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  // @gusnips/react/supabase the session adapter → @supabase/supabase-js
16
16
  // @gusnips/react/theme the theme controller → react only, but `@gusnips/vite/theme`
17
17
  // serializes it and must not load the rest
18
+ // @gusnips/react/oauth the sign-in pages' logic → @tanstack/react-query, at runtime
18
19
  //
19
20
  // `react-dom` is the one the second migration added, and it is the sharpest case yet: that
20
21
  // adopter ships a REACT NATIVE app beside its two web apps, and React Native has no react-dom to
@@ -0,0 +1,257 @@
1
+ // @vitest-environment happy-dom
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+ import { act, createElement } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import {
7
+ formatUserCode,
8
+ useConsentRequest,
9
+ useDeviceApproval,
10
+ type ConsentApi,
11
+ type ConsentView,
12
+ type DeviceApi,
13
+ type DeviceView,
14
+ type OAuthRefusal,
15
+ } from "./oauth.ts";
16
+
17
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
18
+
19
+ const roots = new Set<Root>();
20
+ afterEach(() => {
21
+ for (const root of roots) act(() => root.unmount());
22
+ roots.clear();
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ /** Renders `hook` under a fresh query client and keeps its latest answer in `current`. */
27
+ function mount<T>(hook: () => T) {
28
+ const seen: T[] = [];
29
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
30
+ function Probe() {
31
+ seen.push(hook());
32
+ return null;
33
+ }
34
+ const root = createRoot(document.createElement("div"));
35
+ roots.add(root);
36
+ act(() => root.render(createElement(QueryClientProvider, { client }, createElement(Probe))));
37
+ return {
38
+ client,
39
+ get current(): T {
40
+ const last = seen.at(-1);
41
+ if (last === undefined) throw new Error("The hook never rendered");
42
+ return last;
43
+ },
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Lets pending promises and the renders they cause finish. react-query hands its updates to a
49
+ * scheduler that runs on a later task, so one tick is not always enough.
50
+ */
51
+ const settle = () =>
52
+ act(async () => {
53
+ for (let tick = 0; tick < 5; tick++) await new Promise((resolve) => setTimeout(resolve, 0));
54
+ });
55
+
56
+ const VIEW: ConsentView = {
57
+ ok: true,
58
+ clientName: "Claude",
59
+ redirectHost: "claude.ai",
60
+ keyName: "Claude (claude.ai)",
61
+ cancelUrl: "https://claude.ai/cb?error=access_denied",
62
+ };
63
+ const REFUSAL: OAuthRefusal = {
64
+ ok: false,
65
+ reason: "redirect-mismatch",
66
+ message: "This app's sign-in link is not valid.",
67
+ };
68
+
69
+ describe("useConsentRequest", () => {
70
+ it("loads the request, approves with the page's choice, and leaves for the redirect", async () => {
71
+ const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
72
+ let release!: () => void;
73
+ const api: ConsentApi<string> = {
74
+ describe: vi.fn(async () => VIEW),
75
+ approve: vi.fn(
76
+ () =>
77
+ new Promise<{ ok: true; redirectTo: string }>((resolve) => {
78
+ release = () => resolve({ ok: true, redirectTo: "https://claude.ai/cb?code=c1" });
79
+ }),
80
+ ),
81
+ };
82
+ const page = mount(() => useConsentRequest(api, "?client_id=x&state=s"));
83
+ expect(page.current.state).toEqual({ status: "loading" });
84
+ await settle();
85
+ expect(page.current.state).toEqual({ status: "ready", view: VIEW });
86
+ expect(api.describe).toHaveBeenCalledWith("client_id=x&state=s");
87
+
88
+ act(() => page.current.approve("ws-1"));
89
+ await settle();
90
+ expect(api.approve).toHaveBeenCalledWith("client_id=x&state=s", "ws-1");
91
+ expect(page.current.leaving).toBe(true);
92
+
93
+ release();
94
+ await settle();
95
+ expect(assign).toHaveBeenCalledWith("https://claude.ai/cb?code=c1");
96
+ // The page is going away: the button must not come back for a second press.
97
+ expect(page.current.leaving).toBe(true);
98
+ });
99
+
100
+ it("shows a refusal as a state and never redirects on it", async () => {
101
+ const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
102
+ const page = mount(() =>
103
+ useConsentRequest({ describe: async () => REFUSAL, approve: vi.fn() }, "?client_id=x"),
104
+ );
105
+ await settle();
106
+ expect(page.current.state).toEqual({ status: "refused", refusal: REFUSAL });
107
+ expect(assign).not.toHaveBeenCalled();
108
+ });
109
+
110
+ it("turns a refusal from approve into the refused state, and stays put", async () => {
111
+ const assign = vi.spyOn(window.location, "assign").mockImplementation(() => {});
112
+ const page = mount(() =>
113
+ useConsentRequest({ describe: async () => VIEW, approve: async () => REFUSAL }, ""),
114
+ );
115
+ await settle();
116
+ act(() => page.current.approve());
117
+ await settle();
118
+ expect(page.current.state).toEqual({ status: "refused", refusal: REFUSAL });
119
+ expect(page.current.leaving).toBe(false);
120
+ expect(assign).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it("offers a retry when the request failed to answer, and recovers", async () => {
124
+ let calls = 0;
125
+ const page = mount(() =>
126
+ useConsentRequest(
127
+ {
128
+ describe: async () => {
129
+ calls += 1;
130
+ if (calls === 1) throw new TypeError("Failed to fetch");
131
+ return VIEW;
132
+ },
133
+ approve: vi.fn(),
134
+ },
135
+ "?a=1",
136
+ ),
137
+ );
138
+ await settle();
139
+ const failed = page.current.state;
140
+ if (failed.status !== "failed") throw new Error(`expected failed, got ${failed.status}`);
141
+ expect(failed.error).toBeInstanceOf(TypeError);
142
+ act(() => failed.retry());
143
+ await settle();
144
+ expect(page.current.state).toEqual({ status: "ready", view: VIEW });
145
+ });
146
+
147
+ it("keeps an approve that did not answer beside the button, ready to press again", async () => {
148
+ const page = mount(() =>
149
+ useConsentRequest(
150
+ {
151
+ describe: async () => VIEW,
152
+ approve: async () => {
153
+ throw new TypeError("Failed to fetch");
154
+ },
155
+ },
156
+ "",
157
+ ),
158
+ );
159
+ await settle();
160
+ act(() => page.current.approve());
161
+ await settle();
162
+ expect(page.current.approveError).toBeInstanceOf(TypeError);
163
+ expect(page.current.leaving).toBe(false);
164
+ expect(page.current.state.status).toBe("ready");
165
+ });
166
+ });
167
+
168
+ const DEVICE: DeviceView = {
169
+ ok: true,
170
+ clientName: "Acme CLI",
171
+ userCode: "WDJB-MJHT",
172
+ keyName: "Acme CLI (device sign-in)",
173
+ expiresAt: "2026-10-16T12:10:00.000Z",
174
+ };
175
+
176
+ describe("useDeviceApproval", () => {
177
+ function deviceApi(overrides: Partial<DeviceApi> = {}) {
178
+ return {
179
+ describe: vi.fn<DeviceApi["describe"]>(overrides.describe ?? (async () => DEVICE)),
180
+ decide: vi.fn<DeviceApi["decide"]>(overrides.decide ?? (async () => ({ ok: true }))),
181
+ };
182
+ }
183
+
184
+ it("starts from the link's code, asks the server, and stays approved after the yes", async () => {
185
+ const api = deviceApi();
186
+ const page = mount(() => useDeviceApproval(api, "?code=wdjb-mjht"));
187
+ expect(page.current.code).toBe("WDJB-MJHT");
188
+ await settle();
189
+ expect(page.current.state).toEqual({ status: "ready", view: DEVICE });
190
+ expect(api.describe).toHaveBeenCalledWith("WDJBMJHT");
191
+
192
+ act(() => page.current.approve());
193
+ await settle();
194
+ expect(api.decide).toHaveBeenCalledWith("WDJBMJHT", "approve");
195
+ expect(page.current.state).toEqual({ status: "approved" });
196
+
197
+ // A refetch on focus would now hear "unknown code". It must not be asked.
198
+ api.describe.mockResolvedValue({ ok: false, reason: "unknown-code", message: "gone" });
199
+ await act(async () => page.client.invalidateQueries());
200
+ await settle();
201
+ expect(page.current.state).toEqual({ status: "approved" });
202
+ expect(api.describe).toHaveBeenCalledTimes(1);
203
+ });
204
+
205
+ it("waits for all eight letters before asking, and flags a vowel at once", async () => {
206
+ const api = deviceApi();
207
+ const page = mount(() => useDeviceApproval(api, ""));
208
+ expect(page.current.state).toEqual({ status: "typing" });
209
+ act(() => page.current.setCode("wdjb m"));
210
+ expect(page.current.code).toBe("WDJB-M");
211
+ act(() => page.current.setCode("WDJA"));
212
+ expect(page.current.invalidLetter).toBe(true);
213
+ act(() => page.current.setCode("WDJB MJHTX"));
214
+ expect(page.current.code).toBe("WDJB-MJHT");
215
+ await settle();
216
+ expect(api.describe).toHaveBeenCalledTimes(1);
217
+ expect(page.current.state.status).toBe("ready");
218
+ });
219
+
220
+ it("says no, and shows a code the server does not know as refused", async () => {
221
+ const said = mount(() => useDeviceApproval(deviceApi(), "?code=WDJBMJHT"));
222
+ await settle();
223
+ act(() => said.current.deny());
224
+ await settle();
225
+ expect(said.current.state).toEqual({ status: "denied" });
226
+
227
+ const unknown: OAuthRefusal = { ok: false, reason: "unknown-code", message: "expired" };
228
+ const gone = mount(() =>
229
+ useDeviceApproval(deviceApi({ describe: async () => unknown }), "?code=WDJBMJHT"),
230
+ );
231
+ await settle();
232
+ expect(gone.current.state).toEqual({ status: "refused", refusal: unknown });
233
+ });
234
+
235
+ it("shows a decision the server refused, and lets a new code start over", async () => {
236
+ const unknown: OAuthRefusal = { ok: false, reason: "unknown-code", message: "expired" };
237
+ const api = deviceApi({ decide: async () => unknown });
238
+ const page = mount(() => useDeviceApproval(api, "?code=WDJBMJHT"));
239
+ await settle();
240
+ act(() => page.current.approve());
241
+ await settle();
242
+ expect(page.current.state).toEqual({ status: "refused", refusal: unknown });
243
+ act(() => page.current.setCode("BCDF GHJK"));
244
+ await settle();
245
+ expect(page.current.state.status).toBe("ready");
246
+ });
247
+ });
248
+
249
+ describe("formatUserCode", () => {
250
+ it("writes what the field shows", () => {
251
+ expect(formatUserCode("")).toBe("");
252
+ expect(formatUserCode("wdj")).toBe("WDJ");
253
+ expect(formatUserCode("wdjb")).toBe("WDJB");
254
+ expect(formatUserCode("wdjbm")).toBe("WDJB-M");
255
+ expect(formatUserCode(" w-d j b m j h t z ")).toBe("WDJB-MJHT");
256
+ });
257
+ });
package/src/oauth.ts ADDED
@@ -0,0 +1,217 @@
1
+ import { useMutation, useQuery } from "@tanstack/react-query";
2
+ import { useState } from "react";
3
+
4
+ /**
5
+ * The two pages behind an agent's sign-in: the consent page, where Claude or ChatGPT sends a
6
+ * person to connect, and the device page, where a script's short code is typed and approved.
7
+ * `@gusnips/server/oauth` is the server half. Logic only: the pages are the product's.
8
+ *
9
+ * Each hook takes your two API calls, so your client, your session and your envelope stay yours,
10
+ * and returns one state to draw. What they settle once:
11
+ *
12
+ * - A refusal (an unknown app, a return address it never registered) is a STATE, never a
13
+ * redirect: sending the browser anywhere on a bad request is how an open redirect is built.
14
+ * - Approve stays busy from the press until the browser has left. The page is going away, so the
15
+ * button must never come back and invite a second press.
16
+ * - A device code is read however it is typed (`wdjb mjht`, `WDJB-MJHT`), and once decided the
17
+ * page stays decided. Asked again, the server no longer knows the code, and a page that asked on
18
+ * the next focus would tell someone who just approved that their code is wrong.
19
+ */
20
+
21
+ export type OAuthRefusalReason =
22
+ "unknown-client" | "redirect-mismatch" | "invalid-request" | "wrong-resource" | "unknown-code";
23
+
24
+ /** What the server sends instead of a view. Word your own page for `reason`. */
25
+ export interface OAuthRefusal {
26
+ ok: false;
27
+ reason: OAuthRefusalReason;
28
+ /** An English fallback a person can read. */
29
+ message: string;
30
+ }
31
+
32
+ export interface ConsentView {
33
+ ok: true;
34
+ /** What the app calls itself. Anyone may pick any name, so show `redirectHost` beside it. */
35
+ clientName: string;
36
+ /** Where the browser goes after the yes: a host, or an app's own scheme (`cursor:`). */
37
+ redirectHost: string;
38
+ /** The key's name on your keys page. */
39
+ keyName: string;
40
+ /** Where "Cancel" goes: back to the app, which is told the person said no. */
41
+ cancelUrl: string;
42
+ }
43
+
44
+ export interface DeviceView {
45
+ ok: true;
46
+ clientName: string;
47
+ /** As the script printed it: `WDJB-MJHT`. */
48
+ userCode: string;
49
+ keyName: string;
50
+ /** ISO time: JSON carries the server's `Date` as a string. */
51
+ expiresAt: string;
52
+ }
53
+
54
+ export interface ConsentApi<Choice> {
55
+ /** Your API's call to the server's `describe`, with the query string as it came. */
56
+ describe(query: string): Promise<ConsentView | OAuthRefusal>;
57
+ /** Your API's call to `approve`. `choice` is anything your page adds, such as a workspace. */
58
+ approve(query: string, choice: Choice): Promise<{ ok: true; redirectTo: string } | OAuthRefusal>;
59
+ }
60
+
61
+ export type ConsentState =
62
+ | { status: "loading" }
63
+ /** Show `refusal` and a way home. Never a link back to the app. */
64
+ | { status: "refused"; refusal: OAuthRefusal }
65
+ /** The request failed to arrive or answer. Show what failed, and `retry`. */
66
+ | { status: "failed"; error: unknown; retry: () => void }
67
+ | { status: "ready"; view: ConsentView };
68
+
69
+ export interface ConsentRequest<Choice> {
70
+ state: ConsentState;
71
+ approve(choice: Choice): void;
72
+ /** From the press until the browser has left. Keep the button busy the whole time. */
73
+ leaving: boolean;
74
+ /** Approve did not answer. Show it beside the button; pressing again retries. */
75
+ approveError: unknown;
76
+ }
77
+
78
+ /**
79
+ * const consent = useConsentRequest(api.oauth, useLocation().search);
80
+ * if (consent.state.status === "ready") ... consent.approve(workspaceId)
81
+ */
82
+ export function useConsentRequest<Choice = void>(
83
+ api: ConsentApi<Choice>,
84
+ search: string,
85
+ ): ConsentRequest<Choice> {
86
+ const query = search.replace(/^\?/, "");
87
+ const request = useQuery({
88
+ queryKey: ["@gusnips/react/oauth", "consent", query],
89
+ queryFn: () => api.describe(query),
90
+ });
91
+ const approval = useMutation({
92
+ mutationFn: (choice: Choice) => api.approve(query, choice),
93
+ onSuccess: (answer) => {
94
+ if (answer.ok) window.location.assign(answer.redirectTo);
95
+ },
96
+ });
97
+
98
+ // A refusal from approve (the request went stale between the two calls) outranks the view.
99
+ const answer = request.data;
100
+ const refusal =
101
+ approval.data?.ok === false ? approval.data : answer?.ok === false ? answer : null;
102
+ let state: ConsentState;
103
+ if (refusal) state = { status: "refused", refusal };
104
+ else if (answer?.ok === true) state = { status: "ready", view: answer };
105
+ else if (request.isError)
106
+ state = { status: "failed", error: request.error, retry: () => void request.refetch() };
107
+ else state = { status: "loading" };
108
+
109
+ return {
110
+ state,
111
+ approve: (choice) => approval.mutate(choice),
112
+ leaving: approval.isPending || approval.data?.ok === true,
113
+ approveError: approval.error,
114
+ };
115
+ }
116
+
117
+ export interface DeviceApi {
118
+ /** Your API's call to the server's `describeDevice`. */
119
+ describe(code: string): Promise<DeviceView | OAuthRefusal>;
120
+ /** Your API's call to `decideDevice`. */
121
+ decide(code: string, decision: "approve" | "deny"): Promise<{ ok: true } | OAuthRefusal>;
122
+ }
123
+
124
+ export type DeviceState =
125
+ /** Fewer than 8 letters so far, or one a code never uses (see `invalidLetter`). */
126
+ | { status: "typing" }
127
+ | { status: "loading" }
128
+ /** Unknown, expired or already decided: ask them to start again from the app. */
129
+ | { status: "refused"; refusal: OAuthRefusal }
130
+ | { status: "failed"; error: unknown; retry: () => void }
131
+ /**
132
+ * Show `view.userCode` and say: go on only if you started this sign-in yourself, just now, and
133
+ * this is the code on your screen. Somebody can run the real script and send a stranger the
134
+ * link, and that sentence is the only guard against it (RFC 8628 §5.4).
135
+ */
136
+ | { status: "ready"; view: DeviceView }
137
+ /** Done. Tell them to go back to the app, which carries on by itself. */
138
+ | { status: "approved" }
139
+ | { status: "denied" };
140
+
141
+ export interface DeviceApproval {
142
+ state: DeviceState;
143
+ /** What the field shows, formatted as they type: `WDJB-MJ`. */
144
+ code: string;
145
+ setCode(input: string): void;
146
+ /** A typed letter no code uses: a vowel. Say so now, not after eight letters. */
147
+ invalidLetter: boolean;
148
+ approve(): void;
149
+ deny(): void;
150
+ deciding: boolean;
151
+ /** The decision did not answer. Show it beside the buttons; pressing again retries. */
152
+ decideError: unknown;
153
+ }
154
+
155
+ /** The server's alphabet: no vowels, so a code never spells a word. */
156
+ const ALPHABET = "BCDFGHJKLMNPQRSTVWXZ";
157
+ const LENGTH = 8;
158
+
159
+ /** Upper case, letters only, at most 8, with the dash after the fourth: what the field shows. */
160
+ export function formatUserCode(input: string): string {
161
+ const letters = input
162
+ .toUpperCase()
163
+ .replace(/[^A-Z]/g, "")
164
+ .slice(0, LENGTH);
165
+ return letters.length > 4 ? `${letters.slice(0, 4)}-${letters.slice(4)}` : letters;
166
+ }
167
+
168
+ /**
169
+ * const device = useDeviceApproval(api.device, useLocation().search);
170
+ * <input value={device.code} onChange={(e) => device.setCode(e.target.value)} />
171
+ *
172
+ * The link the script prints carries `?code=`, so the field usually starts full.
173
+ */
174
+ export function useDeviceApproval(api: DeviceApi, search: string): DeviceApproval {
175
+ const [code, setTyped] = useState(() =>
176
+ formatUserCode(new URLSearchParams(search).get("code") ?? ""),
177
+ );
178
+ const letters = code.replace("-", "");
179
+ const invalidLetter = [...letters].some((letter) => !ALPHABET.includes(letter));
180
+ const complete = letters.length === LENGTH && !invalidLetter;
181
+
182
+ const decision = useMutation({
183
+ mutationFn: (choice: "approve" | "deny") => api.decide(letters, choice),
184
+ });
185
+ const decided = decision.data?.ok === true ? decision.variables : undefined;
186
+ const request = useQuery({
187
+ queryKey: ["@gusnips/react/oauth", "device", letters],
188
+ queryFn: () => api.describe(letters),
189
+ enabled: complete && decided === undefined,
190
+ });
191
+
192
+ const answer = request.data;
193
+ const refusal =
194
+ decision.data?.ok === false ? decision.data : answer?.ok === false ? answer : null;
195
+ let state: DeviceState;
196
+ if (decided !== undefined) state = { status: decided === "approve" ? "approved" : "denied" };
197
+ else if (!complete) state = { status: "typing" };
198
+ else if (refusal) state = { status: "refused", refusal };
199
+ else if (answer?.ok === true) state = { status: "ready", view: answer };
200
+ else if (request.isError)
201
+ state = { status: "failed", error: request.error, retry: () => void request.refetch() };
202
+ else state = { status: "loading" };
203
+
204
+ return {
205
+ state,
206
+ code,
207
+ setCode: (input) => {
208
+ decision.reset();
209
+ setTyped(formatUserCode(input));
210
+ },
211
+ invalidLetter,
212
+ approve: () => decision.mutate("approve"),
213
+ deny: () => decision.mutate("deny"),
214
+ deciding: decision.isPending,
215
+ decideError: decision.error,
216
+ };
217
+ }
@@ -12,6 +12,7 @@ import {
12
12
  isAuthOutage,
13
13
  parseAuthCallback,
14
14
  signOutEvenOffline,
15
+ signOutOtherDevices,
15
16
  supabaseAuthStorage,
16
17
  supabaseStorageKey,
17
18
  type SupabaseSessionAuth,
@@ -75,10 +76,11 @@ const unreachable: typeof fetch = async () => {
75
76
  throw new TypeError("fetch failed");
76
77
  };
77
78
 
79
+ /** `paths` collects each request's path and query, so a test can read the scope `/logout` got. */
78
80
  function reachable(paths: string[] = []): typeof fetch {
79
81
  return async (input) => {
80
- const { pathname } = new URL(input instanceof Request ? input.url : input);
81
- paths.push(pathname);
82
+ const { pathname, search } = new URL(input instanceof Request ? input.url : input);
83
+ paths.push(pathname + search);
82
84
  if (pathname.endsWith("/token")) return Response.json(storedSession(now() + 3600));
83
85
  if (pathname.endsWith("/logout")) return new Response(null, { status: 204 });
84
86
  return Response.json({ msg: "not part of this test" }, { status: 404 });
@@ -204,7 +206,8 @@ describe("createSupabaseSessionAdapter", () => {
204
206
  const adapter = createSupabaseSessionAdapter(auth({ signOut }));
205
207
 
206
208
  await expect(adapter.signOut()).rejects.toBe(refusal);
207
- expect(signOut).toHaveBeenCalledOnce();
209
+ // This device only: a token one device's API refused says nothing about the others.
210
+ expect(signOut).toHaveBeenCalledExactlyOnceWith({ scope: "local" });
208
211
  });
209
212
 
210
213
  it("with `stored`, rejects when auth never heard it, but the session has left", async () => {
@@ -273,19 +276,57 @@ describe("signOutEvenOffline", () => {
273
276
  expect(await sessionAfterReload(stored)).toBeNull();
274
277
  });
275
278
 
276
- it("ends the session on the server too when auth answers", async () => {
279
+ it("ends this device's session on the server too when auth answers, and no other", async () => {
277
280
  const paths: string[] = [];
278
281
  const { stored, auth, signedOut } = await signedIn(now() + 3600, reachable(paths));
279
282
 
280
283
  const { error } = await signOutEvenOffline(auth, stored);
281
284
 
282
285
  expect(error).toBeNull();
283
- expect(paths).toContain("/auth/v1/logout");
286
+ // auth-js's default, scope=global, would end the person's sessions on every device.
287
+ expect(paths).toContain("/auth/v1/logout?scope=local");
284
288
  expect(signedOut()).toBe(1);
285
289
  expect(await sessionAfterReload(stored)).toBeNull();
286
290
  });
287
291
  });
288
292
 
293
+ describe("signOutOtherDevices", () => {
294
+ it("ends the other sessions and keeps this one", async () => {
295
+ const paths: string[] = [];
296
+ const { stored, auth, signedOut } = await signedIn(now() + 3600, reachable(paths));
297
+
298
+ const { error } = await signOutOtherDevices(auth);
299
+
300
+ expect(error).toBeNull();
301
+ expect(paths).toContain("/auth/v1/logout?scope=others");
302
+ expect(signedOut()).toBe(0);
303
+ expect(await sessionAfterReload(stored)).not.toBeNull();
304
+ });
305
+
306
+ it("keeps this device signed in and says why when auth cannot be reached", async () => {
307
+ const { stored, auth, signedOut } = await signedIn(now() + 3600);
308
+
309
+ const { error } = await signOutOtherDevices(auth);
310
+
311
+ expect(error).toBeInstanceOf(AuthRetryableFetchError);
312
+ expect(signedOut()).toBe(0);
313
+ expect(await sessionAfterReload(stored)).not.toBeNull();
314
+ });
315
+
316
+ it("gives up at the deadline instead of waiting out the refresh backoff", async () => {
317
+ vi.useFakeTimers();
318
+ const { stored, auth } = await signedIn(now() - 60);
319
+
320
+ const signingOut = signOutOtherDevices(auth);
321
+ await vi.advanceTimersByTimeAsync(SIGN_OUT_TIMEOUT_MS);
322
+ const { error } = await signingOut;
323
+
324
+ expect(error).toMatchObject({ message: expect.stringContaining(`${SIGN_OUT_TIMEOUT_MS} ms`) });
325
+ expect(await stored.storage.getItem(stored.storageKey)).not.toBeNull();
326
+ await vi.advanceTimersByTimeAsync(60_000);
327
+ });
328
+ });
329
+
289
330
  describe("supabaseStorageKey", () => {
290
331
  it("names the key supabase-js picks when you set none", async () => {
291
332
  const storage = memoryStorage();
@@ -156,11 +156,17 @@ function localStorageOrMemory(): SupportedStorage {
156
156
  * it touches storage. This happens at every version, and it is the common case: a laptop that
157
157
  * wakes up offline with a tab still open.
158
158
  * - Before 2.110.2, the token is still good but `/logout` gets no answer. `signOut()` returns
159
- * before it removes anything. `scope: "local"` does not help, because it calls `/logout` too.
159
+ * before it removes anything.
160
160
  *
161
161
  * Either way a reload reads the session and signs the person back in. On a shared computer, that
162
162
  * person is whoever sits down next. A screen that says "signed out" at that point is wrong.
163
163
  *
164
+ * It ends this device's session and no other: `signOut({ scope: "local" })`, which fails in both
165
+ * cases above too, because it calls `/logout` as well. auth-js's default is `"global"`, which ends
166
+ * every session the person has. Signing out of a laptop then signed them out of their phone, and a
167
+ * tab left open anywhere failed its next refresh with "Invalid Refresh Token: Refresh Token Not
168
+ * Found". To end the other sessions on purpose, use {@link signOutOtherDevices}.
169
+ *
164
170
  * Removing the key yourself is not enough on its own: nothing tells your `onAuthStateChange`
165
171
  * listeners or your other tabs, and auth-js's PKCE verifiers stay behind. So after removing it,
166
172
  * this calls `signOut({ scope: "local" })`. That call finds no session, skips `/logout`, and runs
@@ -173,7 +179,7 @@ function localStorageOrMemory(): SupportedStorage {
173
179
  *
174
180
  * It resolves once the stored session is gone. `error` is `null` when auth ended the session on the
175
181
  * server too. Otherwise it is the reason auth did not: its error, a thrown value or the deadline.
176
- * The session may still be live on the server and on other devices. `SIGNED_OUT` arrives once
182
+ * The session may still be live on the server. `SIGNED_OUT` arrives once
177
183
  * auth-js has finished with the first call: about 100 ms later from 2.108.2 on, and up to 22 s
178
184
  * later at 2.106.2. If your screen does not reload, clear your own store when this resolves instead
179
185
  * of waiting for the event.
@@ -181,6 +187,40 @@ function localStorageOrMemory(): SupportedStorage {
181
187
  export async function signOutEvenOffline(
182
188
  auth: Pick<SupabaseSessionAuth, "signOut">,
183
189
  { storage, storageKey }: SupabaseSessionStorage,
190
+ ): Promise<{ error: unknown }> {
191
+ const { error } = await withinSignOutDeadline(auth.signOut({ scope: "local" }));
192
+ if (error == null) return { error: null };
193
+ // From 2.110.2 on, auth-js has already removed the session when only `/logout` failed, and has
194
+ // already sent SIGNED_OUT. Going on would send it a second time.
195
+ if ((await storage.getItem(storageKey)) === null) return { error };
196
+ 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(() => {});
200
+ return { error };
201
+ }
202
+
203
+ /**
204
+ * Ends the person's sessions on every other device and keeps this one signed in. For a "sign out
205
+ * of other devices" button in account settings: a phone that was lost, a computer left signed in.
206
+ *
207
+ * It resolves `{ error: null }` once auth has ended the other sessions. Otherwise `error` says why
208
+ * it did not: auth's error, a thrown value, or the deadline. This device is untouched either way,
209
+ * so the person is still signed in and can press the button again.
210
+ *
211
+ * Like {@link signOutEvenOffline}, it waits on auth for {@link SIGN_OUT_TIMEOUT_MS} at most, because
212
+ * offline with an expired token auth-js first sits in its refresh backoff. A deadline does not
213
+ * prove the other sessions are still live: auth may end them after this gave up.
214
+ */
215
+ export function signOutOtherDevices(
216
+ auth: Pick<SupabaseSessionAuth, "signOut">,
217
+ ): Promise<{ error: unknown }> {
218
+ return withinSignOutDeadline(auth.signOut({ scope: "others" }));
219
+ }
220
+
221
+ /** `call`'s result, or the deadline as its error once {@link SIGN_OUT_TIMEOUT_MS} has passed. */
222
+ async function withinSignOutDeadline(
223
+ call: Promise<{ error: unknown }>,
184
224
  ): Promise<{ error: unknown }> {
185
225
  let timer: ReturnType<typeof setTimeout> | undefined;
186
226
  const deadline = new Promise<{ error: unknown }>((resolve) => {
@@ -192,20 +232,12 @@ export async function signOutEvenOffline(
192
232
  SIGN_OUT_TIMEOUT_MS,
193
233
  );
194
234
  });
195
- const { error } = await Promise.race([
196
- auth.signOut().catch((thrown: unknown) => ({ error: thrown })),
235
+ const result = await Promise.race([
236
+ call.catch((thrown: unknown) => ({ error: thrown })),
197
237
  deadline,
198
238
  ]);
199
239
  clearTimeout(timer);
200
- if (error == null) return { error: null };
201
- // From 2.110.2 on, auth-js has already removed the session when only `/logout` failed, and has
202
- // already sent SIGNED_OUT. Going on would send it a second time.
203
- if ((await storage.getItem(storageKey)) === null) return { error };
204
- await storage.removeItem(storageKey);
205
- // Not awaited: inside auth-js it can wait behind the first call, which may still be in its
206
- // refresh backoff. The session is already out of storage, and storage is what a reload reads.
207
- void auth.signOut({ scope: "local" }).catch(() => {});
208
- return { error };
240
+ return result;
209
241
  }
210
242
 
211
243
  /**
@@ -220,7 +252,8 @@ export async function signOutEvenOffline(
220
252
  * rejection means. It still rejects when auth did not end the session, but by then the session
221
253
  * has already left this device. And a sign-out auth has not answered within
222
254
  * {@link SIGN_OUT_TIMEOUT_MS} now rejects instead of hanging. Without `stored`, it is
223
- * `auth.signOut()` and rejects on that call's error, as before.
255
+ * `auth.signOut({ scope: "local" })` and rejects on that call's error. Either way it ends this
256
+ * device's session only: a token one device's API refused says nothing about the others.
224
257
  *
225
258
  * `stored` is optional here but not at a sign-out button. The API client signs out only after
226
259
  * auth has answered: a refresh auth refused, or a new token the API refused. So the network was up
@@ -247,7 +280,9 @@ export function createSupabaseSessionAdapter(
247
280
  }
248
281
  },
249
282
  signOut: async () => {
250
- const { error } = stored ? await signOutEvenOffline(auth, stored) : await auth.signOut();
283
+ const { error } = stored
284
+ ? await signOutEvenOffline(auth, stored)
285
+ : await auth.signOut({ scope: "local" });
251
286
  if (error) throw error;
252
287
  },
253
288
  };