@gusnips/react 0.9.28 → 0.9.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts +58 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +94 -0
- package/dist/keys.js.map +1 -0
- package/dist/oauth.d.ts +145 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +99 -0
- package/dist/oauth.js.map +1 -0
- package/package.json +9 -1
- package/src/index.ts +1 -0
- package/src/keys.test.ts +253 -0
- package/src/keys.ts +144 -0
- package/src/oauth.test.ts +257 -0
- package/src/oauth.ts +217 -0
- package/src/supabase-session.browser.test.ts +6 -1
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
|
+
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { createClient } from "@supabase/supabase-js";
|
|
3
|
-
import {
|
|
3
|
+
import { Window } from "happy-dom";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
5
|
import { supabaseAuthStorage } from "./supabase-session.ts";
|
|
5
6
|
|
|
6
7
|
// A real browser `localStorage` and a real auth-js, so what is measured is the choice supabase-js
|
|
7
8
|
// makes for itself, not this file's idea of it. The Node tests beside this one can only stub it.
|
|
9
|
+
// Node 26 has a `localStorage` of its own, `undefined` without `--localstorage-file`, and vitest
|
|
10
|
+
// leaves it in front of happy-dom's (`window` is the global, so it reads Node's too). A browser
|
|
11
|
+
// has one, so take a happy-dom window's and put it where a browser keeps it.
|
|
12
|
+
vi.stubGlobal("localStorage", new Window().localStorage);
|
|
8
13
|
const PROJECT_URL = "https://abcdefghijklmnop.supabase.co";
|
|
9
14
|
const USER_ID = "11111111-1111-4111-8111-111111111111";
|
|
10
15
|
|