@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/README.md
CHANGED
|
@@ -689,6 +689,127 @@ silently become model messages. It returns `false` for ordinary text. The hook n
|
|
|
689
689
|
message, clears a draft or changes attached files; those stay with the app. It also has no timer
|
|
690
690
|
or scheduler: offer a repeat command only when your app has real scheduling behind it.
|
|
691
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
|
+
|
|
746
|
+
## A new API key, shown once
|
|
747
|
+
|
|
748
|
+
```ts
|
|
749
|
+
import { createKeyLifecycle } from "@gusnips/react/keys";
|
|
750
|
+
|
|
751
|
+
export const apiKeys = createKeyLifecycle<ApiKey>();
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
Make it once, beside your query client, outside every route. It keeps each new key's secret in
|
|
755
|
+
memory until the person presses Done. A second key never replaces the first, and a trip to your
|
|
756
|
+
docs and back loses neither.
|
|
757
|
+
|
|
758
|
+
Tell it who is signed in, and where, from the code that already hears about sign-in and workspace
|
|
759
|
+
changes:
|
|
760
|
+
|
|
761
|
+
```ts
|
|
762
|
+
apiKeys.setScope(user ? `${user.id}:${workspaceId}` : null);
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
A new scope forgets every unread secret. A request started before the change rejects with
|
|
766
|
+
`KeyScopeChangedError` instead of showing its secret, even when the same account signs straight
|
|
767
|
+
back in. Build the scope from ids, never from an access token: a token refresh is not a new person.
|
|
768
|
+
|
|
769
|
+
Send your create request through it:
|
|
770
|
+
|
|
771
|
+
```ts
|
|
772
|
+
const create = useMutation({
|
|
773
|
+
mutationFn: (name: string) =>
|
|
774
|
+
apiKeys.create(async () => {
|
|
775
|
+
const { key, ...record } = await api.post<NewApiKey>("/keys", { name });
|
|
776
|
+
return { record, key };
|
|
777
|
+
}),
|
|
778
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["keys"] }),
|
|
779
|
+
});
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
`create` keeps the secret and resolves only the record. In three products we read, `mutation.data`
|
|
783
|
+
held the whole answer, so the secret stayed in react-query's cache for as long as the cache kept
|
|
784
|
+
the mutation. A record that still contains the secret is refused, so that mistake shows on the
|
|
785
|
+
first try. The reveal lands even when the page that asked has unmounted.
|
|
786
|
+
|
|
787
|
+
Show what is unread, and let the person say they saved it:
|
|
788
|
+
|
|
789
|
+
```tsx
|
|
790
|
+
const reveals = useKeyReveals(apiKeys);
|
|
791
|
+
|
|
792
|
+
return reveals.map(({ record, key }) => (
|
|
793
|
+
<KeyReveal key={record.id} secret={key} onDone={() => apiKeys.dismiss(record.id)} />
|
|
794
|
+
));
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
Copy with `useCopy`, and keep Done separate: a copy can fail, and the secret must stay on screen
|
|
798
|
+
when it does. Revoke goes through the same object:
|
|
799
|
+
|
|
800
|
+
```ts
|
|
801
|
+
mutationFn: (id: string) => apiKeys.revoke(id, () => api.delete(`/keys/${id}`)),
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
Only a successful revoke forgets that key's secret. A failed one leaves it on screen, because the
|
|
805
|
+
key still works.
|
|
806
|
+
|
|
807
|
+
Nothing is written to storage or the URL, so a reload loses an unread secret. While one is unread
|
|
808
|
+
the tab asks before it closes, and the listener goes away with the last one, because a page with
|
|
809
|
+
a `beforeunload` listener is kept out of some browsers' back-forward cache. Draw the list through
|
|
810
|
+
`queryView`, so a failed refresh keeps the rows instead of saying there are no keys. Forms,
|
|
811
|
+
permissions, plan limits and every word on the page stay yours.
|
|
812
|
+
|
|
692
813
|
## Also here
|
|
693
814
|
|
|
694
815
|
`ErrorBoundary`, an SSE reader split into a platform-free parser and a stream wrapper,
|
|
@@ -770,6 +891,8 @@ lives behind a subpath, so you install a dependency only if you import the thing
|
|
|
770
891
|
| `@gusnips/react/contract` | two prerender names | nothing |
|
|
771
892
|
| `@gusnips/react/theme` | `createTheme`, `startTheme` | react |
|
|
772
893
|
| `@gusnips/react/commands` | slash matching and `useSlashCommands` | react |
|
|
894
|
+
| `@gusnips/react/oauth` | `useConsentRequest`, `useDeviceApproval` | @tanstack/react-query |
|
|
895
|
+
| `@gusnips/react/keys` | `createKeyLifecycle`, `useKeyReveals` | react |
|
|
773
896
|
|
|
774
897
|
The rule behind that table: **a peer marked optional must not be reachable from the main entry
|
|
775
898
|
point.** An optional peer the barrel imports anyway is not optional: it is a required one whose
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/keys.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A new API key, the one time the server sends its secret. Three products wrote this flow, and
|
|
3
|
+
* each knew one part the others missed:
|
|
4
|
+
*
|
|
5
|
+
* - One kept unread secrets in a list outside the page, so a second key did not overwrite the
|
|
6
|
+
* first and a trip to the docs did not lose either. The tab asked before closing only while one
|
|
7
|
+
* was unread, and signing out forgot them all.
|
|
8
|
+
* - One kept the secret out of the list cache, folding only the record into it.
|
|
9
|
+
* - None kept it out of react-query's MUTATION cache. Each `mutationFn` resolved the whole answer,
|
|
10
|
+
* secret included, so `mutation.data` held it, one of them under a comment promising it did not.
|
|
11
|
+
*
|
|
12
|
+
* So `create` takes the secret here and resolves only the record, and the request is the caller's.
|
|
13
|
+
* Forms, permissions, list queries, invalidation and copy feedback stay in the product.
|
|
14
|
+
*/
|
|
15
|
+
/** What your create request resolves: the record your list shows, and the secret beside it. */
|
|
16
|
+
export interface RevealedKey<Record> {
|
|
17
|
+
readonly record: Record;
|
|
18
|
+
readonly key: string;
|
|
19
|
+
}
|
|
20
|
+
/** The account or workspace changed while a create or revoke was in flight. */
|
|
21
|
+
export declare class KeyScopeChangedError extends Error {
|
|
22
|
+
readonly operation: "create" | "revoke";
|
|
23
|
+
constructor(operation: "create" | "revoke");
|
|
24
|
+
}
|
|
25
|
+
export interface KeyLifecycle<Record> {
|
|
26
|
+
/**
|
|
27
|
+
* Who and where keys are made for: the signed-in account and the workspace, joined into one
|
|
28
|
+
* string, or `null` once signed out. Call it from your auth and workspace subscription, never
|
|
29
|
+
* from the keys page, which may not be mounted. A new scope forgets every unread secret and
|
|
30
|
+
* rejects requests started under the old one, including when the same account signs back in.
|
|
31
|
+
*/
|
|
32
|
+
setScope(scope: string | null): void;
|
|
33
|
+
/**
|
|
34
|
+
* Run your create request, keep its secret here, and resolve only the record. Use it as a
|
|
35
|
+
* `mutationFn`: neither the result nor the variables carry the secret, and the reveal still
|
|
36
|
+
* lands when the page that asked has unmounted.
|
|
37
|
+
*/
|
|
38
|
+
create(request: () => Promise<RevealedKey<Record>>): Promise<Record>;
|
|
39
|
+
/** Run your revoke request. Only a success, in the same scope, forgets that key's secret. */
|
|
40
|
+
revoke(id: string, request: () => Promise<unknown>): Promise<void>;
|
|
41
|
+
/** The person saved it. Wire this to "Done", never to a copy: a copy can fail. */
|
|
42
|
+
dismiss(id: string): void;
|
|
43
|
+
/** Unread secrets, newest first. */
|
|
44
|
+
getSnapshot(): readonly RevealedKey<Record>[];
|
|
45
|
+
subscribe(listener: () => void): () => void;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* One per app, created at module scope, outside every route. Memory only: never storage, the URL
|
|
49
|
+
* or a query cache, so a reload loses an unread secret, and the tab asks before closing while one
|
|
50
|
+
* is unread. The listener comes and goes with the secrets because a page with a `beforeunload`
|
|
51
|
+
* listener is kept out of some browsers' back-forward cache.
|
|
52
|
+
*/
|
|
53
|
+
export declare function createKeyLifecycle<Record extends {
|
|
54
|
+
readonly id: string;
|
|
55
|
+
}>(): KeyLifecycle<Record>;
|
|
56
|
+
/** Unread secrets, newest first. Unmounting the component forgets nothing. */
|
|
57
|
+
export declare function useKeyReveals<Record>(lifecycle: KeyLifecycle<Record>): readonly RevealedKey<Record>[];
|
|
58
|
+
//# sourceMappingURL=keys.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keys.d.ts","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;GAaG;AAEH,+FAA+F;AAC/F,MAAM,WAAW,WAAW,CAAC,MAAM;IACjC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED,+EAA+E;AAC/E,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,QAAQ,CAAC,SAAS,EAAE,QAAQ,GAAG,QAAQ,CAAC;gBAE5B,SAAS,EAAE,QAAQ,GAAG,QAAQ;CAS3C;AAED,MAAM,WAAW,YAAY,CAAC,MAAM;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAAC;IACrC;;;;OAIG;IACH,MAAM,CAAC,OAAO,EAAE,MAAM,OAAO,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACrE,6FAA6F;IAC7F,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnE,kFAAkF;IAClF,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,oCAAoC;IACpC,WAAW,IAAI,SAAS,WAAW,CAAC,MAAM,CAAC,EAAE,CAAC;IAC9C,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC;CAC7C;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,SAAS;IAAE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CAAE,KAAK,YAAY,CAAC,MAAM,CAAC,CAkEjG;AAKD,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,MAAM,EAClC,SAAS,EAAE,YAAY,CAAC,MAAM,CAAC,GAC9B,SAAS,WAAW,CAAC,MAAM,CAAC,EAAE,CAEhC"}
|
package/dist/keys.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
/** The account or workspace changed while a create or revoke was in flight. */
|
|
3
|
+
export class KeyScopeChangedError extends Error {
|
|
4
|
+
operation;
|
|
5
|
+
constructor(operation) {
|
|
6
|
+
super(operation === "create"
|
|
7
|
+
? "The account or workspace changed before the new key could be shown. If nobody saved it, revoke it from that workspace's keys."
|
|
8
|
+
: "The account or workspace changed while the key was being revoked. Check that workspace's keys to see whether it worked.");
|
|
9
|
+
this.name = "KeyScopeChangedError";
|
|
10
|
+
this.operation = operation;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* One per app, created at module scope, outside every route. Memory only: never storage, the URL
|
|
15
|
+
* or a query cache, so a reload loses an unread secret, and the tab asks before closing while one
|
|
16
|
+
* is unread. The listener comes and goes with the secrets because a page with a `beforeunload`
|
|
17
|
+
* listener is kept out of some browsers' back-forward cache.
|
|
18
|
+
*/
|
|
19
|
+
export function createKeyLifecycle() {
|
|
20
|
+
let revealed = [];
|
|
21
|
+
let scope = null;
|
|
22
|
+
let generation = 0;
|
|
23
|
+
const listeners = new Set();
|
|
24
|
+
// Per instance, so two lifecycles never remove each other's prompt.
|
|
25
|
+
function askBeforeLeaving(event) {
|
|
26
|
+
event.preventDefault();
|
|
27
|
+
// Older Chrome and Safari prompt only when this is set too. The browser picks the words.
|
|
28
|
+
event.returnValue = "";
|
|
29
|
+
}
|
|
30
|
+
function publish(next) {
|
|
31
|
+
const wasUnread = revealed.length > 0;
|
|
32
|
+
revealed = next;
|
|
33
|
+
if (wasUnread !== next.length > 0 && typeof window !== "undefined") {
|
|
34
|
+
if (next.length > 0)
|
|
35
|
+
window.addEventListener("beforeunload", askBeforeLeaving);
|
|
36
|
+
else
|
|
37
|
+
window.removeEventListener("beforeunload", askBeforeLeaving);
|
|
38
|
+
}
|
|
39
|
+
for (const listener of listeners)
|
|
40
|
+
listener();
|
|
41
|
+
}
|
|
42
|
+
/** Runs `request` under the current scope and refuses an answer that outlived it. */
|
|
43
|
+
async function scoped(operation, request) {
|
|
44
|
+
if (scope === null)
|
|
45
|
+
throw new Error("Call setScope with the signed-in account and workspace first.");
|
|
46
|
+
const started = generation;
|
|
47
|
+
const answer = await request();
|
|
48
|
+
if (started !== generation)
|
|
49
|
+
throw new KeyScopeChangedError(operation);
|
|
50
|
+
return answer;
|
|
51
|
+
}
|
|
52
|
+
function dismiss(id) {
|
|
53
|
+
const next = revealed.filter((one) => one.record.id !== id);
|
|
54
|
+
if (next.length !== revealed.length)
|
|
55
|
+
publish(next);
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
setScope(next) {
|
|
59
|
+
if (scope === next)
|
|
60
|
+
return;
|
|
61
|
+
scope = next;
|
|
62
|
+
generation++;
|
|
63
|
+
if (revealed.length > 0)
|
|
64
|
+
publish([]);
|
|
65
|
+
},
|
|
66
|
+
async create(request) {
|
|
67
|
+
const { record, key } = await scoped("create", request);
|
|
68
|
+
// The record goes on to react-query and often into the list cache. A request that passed
|
|
69
|
+
// the whole answer as the record would put the secret there, which is the bug this exists
|
|
70
|
+
// to end, so say so on the first try instead.
|
|
71
|
+
if (JSON.stringify(record).includes(key))
|
|
72
|
+
throw new Error("The record still holds the secret. Resolve it without the key field.");
|
|
73
|
+
publish([{ record, key }, ...revealed]);
|
|
74
|
+
return record;
|
|
75
|
+
},
|
|
76
|
+
async revoke(id, request) {
|
|
77
|
+
await scoped("revoke", request);
|
|
78
|
+
dismiss(id);
|
|
79
|
+
},
|
|
80
|
+
dismiss,
|
|
81
|
+
getSnapshot: () => revealed,
|
|
82
|
+
subscribe(listener) {
|
|
83
|
+
listeners.add(listener);
|
|
84
|
+
return () => listeners.delete(listener);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// The server has no unread secrets, and a prerender must never print one.
|
|
89
|
+
const none = [];
|
|
90
|
+
/** Unread secrets, newest first. Unmounting the component forgets nothing. */
|
|
91
|
+
export function useKeyReveals(lifecycle) {
|
|
92
|
+
return useSyncExternalStore(lifecycle.subscribe, lifecycle.getSnapshot, () => none);
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=keys.js.map
|
package/dist/keys.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keys.js","sourceRoot":"","sources":["../src/keys.ts"],"names":[],"mappings":"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"}
|
package/dist/oauth.d.ts
ADDED
|
@@ -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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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/react",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.30",
|
|
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,14 @@
|
|
|
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"
|
|
52
|
+
},
|
|
53
|
+
"./keys": {
|
|
54
|
+
"types": "./dist/keys.d.ts",
|
|
55
|
+
"default": "./dist/keys.js"
|
|
48
56
|
}
|
|
49
57
|
},
|
|
50
58
|
"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
|