@nominalso/vibe-auth 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +27 -5
- package/README.md +30 -10
- package/dist/index.cjs +121 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +65 -3
- package/dist/index.d.ts +65 -3
- package/dist/index.js +121 -14
- package/dist/index.js.map +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -39,11 +39,33 @@ export const auth = createVibeAuth({
|
|
|
39
39
|
render the app's provider tree there (see **Root wiring** below).
|
|
40
40
|
2. **The app root.** Wrap everything in `<auth.AuthGate>`. The app renders nothing of itself
|
|
41
41
|
until authenticated.
|
|
42
|
-
3. **The host bridge**, if embedded via `@nominalso/vibe-bridge`.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
3. **The host bridge**, if embedded via `@nominalso/vibe-bridge`. One call, at
|
|
43
|
+
module scope, from a **parent** of `AuthGate` — never a React effect, and
|
|
44
|
+
never a module reachable only through a `lazy()` chunk:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
export const bridge = new VibeAppBridge()
|
|
48
|
+
export const { hostContext, getHostContext, subscribeHostContext } =
|
|
49
|
+
auth.wireVibeApp<ContextPayload>(bridge)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`wireVibeApp` registers the context subscriber and host auth **before**
|
|
53
|
+
`connect()`, seeds the host principal `AuthGate` waits on **before**
|
|
54
|
+
publishing context, skips the handshake entirely in the callback document,
|
|
55
|
+
and resolves `null` rather than rejecting when there is no host. Pass
|
|
56
|
+
`onContextChange` / `onDataReset` / `onSubrouteRequest` as options in the
|
|
57
|
+
same call; each is a single subscriber, and routing them through here is what
|
|
58
|
+
guarantees exactly one of each.
|
|
59
|
+
|
|
60
|
+
`hostOutcome` (alongside `hostContext`) settles `{ kind: 'connected' }`,
|
|
61
|
+
`{ kind: 'standalone' }` or `{ kind: 'failed', error }`. `hostContext`
|
|
62
|
+
resolves `null` for the last two alike, so use `hostOutcome` when the app
|
|
63
|
+
needs an error state that does not fire on every standalone dev load.
|
|
64
|
+
|
|
65
|
+
`wireHostAuth` / `seedLastUserId` are the pre-0.2.3 hand-wiring. They are
|
|
66
|
+
still exported for compatibility, but new code should not call them — four of
|
|
67
|
+
seven migrated apps got that sequence wrong, each differently, which is why
|
|
68
|
+
`wireVibeApp` exists.
|
|
47
69
|
|
|
48
70
|
## Root wiring (SSR — React #418)
|
|
49
71
|
|
package/README.md
CHANGED
|
@@ -62,17 +62,35 @@ function Root() {
|
|
|
62
62
|
|
|
63
63
|
```ts
|
|
64
64
|
// Start the Nominal host handshake at module scope, before React mounts.
|
|
65
|
+
import { VibeAppBridge, type ContextPayload } from '@nominalso/vibe-bridge'
|
|
65
66
|
import { auth } from '@/lib/auth'
|
|
66
|
-
import { bridge } from './bridge'
|
|
67
67
|
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
export const bridge = new VibeAppBridge()
|
|
69
|
+
|
|
70
|
+
export const { hostContext, getHostContext, subscribeHostContext } =
|
|
71
|
+
auth.wireVibeApp<ContextPayload>(bridge)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
One call does the whole handshake, in the one correct order: context subscriber
|
|
75
|
+
and host auth registered before `connect()`, the host principal seeded before
|
|
76
|
+
context is published, the handshake skipped in the callback document, and `null`
|
|
77
|
+
rather than a rejection when there is no host. Pass `onContextChange`,
|
|
78
|
+
`onDataReset` and `onSubrouteRequest` as options in the same call.
|
|
79
|
+
|
|
80
|
+
**Telling "not embedded" from "the host broke".** `hostContext` resolves `null`
|
|
81
|
+
for both, which is why apps either showed an error banner on every standalone
|
|
82
|
+
dev load or showed none at all. `hostOutcome` carries the distinction:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
const { hostContext, hostOutcome } = auth.wireVibeApp<ContextPayload>(bridge)
|
|
86
|
+
|
|
87
|
+
const outcome = await hostOutcome // never rejects, settles with hostContext
|
|
88
|
+
// { kind: 'connected' }
|
|
89
|
+
// { kind: 'standalone' } — nothing was ever going to answer; expected
|
|
90
|
+
// { kind: 'failed', error } — embedded, and the host stayed silent
|
|
73
91
|
```
|
|
74
92
|
|
|
75
|
-
See [`AGENTS.md`](./AGENTS.md) for SSR root wiring and
|
|
93
|
+
See [`AGENTS.md`](./AGENTS.md) for SSR root wiring and the full ordering rules.
|
|
76
94
|
Timeouts live on `VibeAuthTimeouts` in the package `.d.ts`.
|
|
77
95
|
|
|
78
96
|
## Why a client you own, not one this package creates
|
|
@@ -87,11 +105,13 @@ control**, not a preference (the `.d.ts` on `VibeAuthConfig` explains why).
|
|
|
87
105
|
|
|
88
106
|
- `ensureSession()` / `rebindSession({ userId, tenant })` — the cross-document-safe core (Web Lock
|
|
89
107
|
serialised, terminal-capped, sibling-adopting). Most apps never call these directly; the
|
|
90
|
-
gate and `
|
|
108
|
+
gate and `wireVibeApp` do.
|
|
91
109
|
- `AuthGate` / `DefaultSignInScreen` / `SilentCallback` — the three React pieces. Override
|
|
92
110
|
`signInScreen`/`loader` props on `AuthGate` for branding.
|
|
93
|
-
- `
|
|
94
|
-
identity/tenant switch).
|
|
111
|
+
- `wireVibeApp(bridge, options?)` — the Nominal-host integration in one call (handshake,
|
|
112
|
+
host context store, logout and identity/tenant switch). Call it at module scope from a
|
|
113
|
+
parent of `AuthGate`. (`wireHostAuth` / `seedLastUserId` are the pre-0.2.3 hand-wiring,
|
|
114
|
+
still exported for compatibility; new code should not use them.)
|
|
95
115
|
- Fully configurable timeouts (`VibeAuthTimeouts` in the `.d.ts`) — every value defaults to
|
|
96
116
|
what shipped after this flow's production incidents.
|
|
97
117
|
|
package/dist/index.cjs
CHANGED
|
@@ -501,28 +501,108 @@ function createAuthGate(config, silentAuth, interactiveAuth, hostAuth) {
|
|
|
501
501
|
const { supabase, callbackPath, timeouts } = config;
|
|
502
502
|
const RETURNING_FROM_OAUTH = typeof window !== "undefined" && (new URLSearchParams(window.location.search).has("code") || window.location.hash.includes("access_token"));
|
|
503
503
|
const IS_CALLBACK_PATH = typeof window !== "undefined" && window.location.pathname === callbackPath;
|
|
504
|
+
const SPIN_CLASS = "nominal-vibe-auth-spin";
|
|
505
|
+
function Spinner({ size = 18 }) {
|
|
506
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
|
|
507
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("style", { children: `
|
|
508
|
+
@keyframes ${SPIN_CLASS} { to { transform: rotate(360deg) } }
|
|
509
|
+
.${SPIN_CLASS} { animation: ${SPIN_CLASS} 700ms linear infinite; transform-origin: 50% 50% }
|
|
510
|
+
@media (prefers-reduced-motion: reduce) { .${SPIN_CLASS} { animation-duration: 2.4s } }
|
|
511
|
+
` }),
|
|
512
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
513
|
+
"svg",
|
|
514
|
+
{
|
|
515
|
+
className: SPIN_CLASS,
|
|
516
|
+
width: size,
|
|
517
|
+
height: size,
|
|
518
|
+
viewBox: "0 0 24 24",
|
|
519
|
+
fill: "none",
|
|
520
|
+
"aria-hidden": "true",
|
|
521
|
+
children: [
|
|
522
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
523
|
+
"circle",
|
|
524
|
+
{
|
|
525
|
+
cx: "12",
|
|
526
|
+
cy: "12",
|
|
527
|
+
r: "9",
|
|
528
|
+
stroke: "currentColor",
|
|
529
|
+
strokeOpacity: "0.18",
|
|
530
|
+
strokeWidth: "3"
|
|
531
|
+
}
|
|
532
|
+
),
|
|
533
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
534
|
+
"path",
|
|
535
|
+
{
|
|
536
|
+
d: "M21 12a9 9 0 0 0-9-9",
|
|
537
|
+
stroke: "currentColor",
|
|
538
|
+
strokeWidth: "3",
|
|
539
|
+
strokeLinecap: "round"
|
|
540
|
+
}
|
|
541
|
+
)
|
|
542
|
+
]
|
|
543
|
+
}
|
|
544
|
+
)
|
|
545
|
+
] });
|
|
546
|
+
}
|
|
547
|
+
const SCREEN = {
|
|
548
|
+
display: "grid",
|
|
549
|
+
placeItems: "center",
|
|
550
|
+
height: "100vh",
|
|
551
|
+
fontFamily: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
|
552
|
+
color: "#3f3f46",
|
|
553
|
+
background: "#fafafa"
|
|
554
|
+
};
|
|
504
555
|
function DefaultFullScreenLoader() {
|
|
505
|
-
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
556
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: SCREEN, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
506
557
|
"div",
|
|
507
558
|
{
|
|
508
|
-
style: { display: "
|
|
509
|
-
|
|
559
|
+
style: { display: "flex", alignItems: "center", gap: 10, fontSize: 14 },
|
|
560
|
+
role: "status",
|
|
561
|
+
"aria-live": "polite",
|
|
562
|
+
children: [
|
|
563
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Spinner, {}),
|
|
564
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "Fetching your data\u2026" })
|
|
565
|
+
]
|
|
510
566
|
}
|
|
511
|
-
);
|
|
567
|
+
) });
|
|
512
568
|
}
|
|
513
569
|
function DefaultSignInScreen() {
|
|
514
570
|
const [pending, setPending] = (0, import_react2.useState)(false);
|
|
515
571
|
const [failure, setFailure] = (0, import_react2.useState)(null);
|
|
516
|
-
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
572
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: SCREEN, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
517
573
|
"div",
|
|
518
574
|
{
|
|
519
|
-
style: {
|
|
520
|
-
|
|
521
|
-
|
|
575
|
+
style: {
|
|
576
|
+
textAlign: "center",
|
|
577
|
+
background: "#fff",
|
|
578
|
+
border: "1px solid #e4e4e7",
|
|
579
|
+
borderRadius: 12,
|
|
580
|
+
padding: "28px 32px",
|
|
581
|
+
boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
|
|
582
|
+
maxWidth: 340
|
|
583
|
+
},
|
|
584
|
+
children: [
|
|
585
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
522
586
|
"button",
|
|
523
587
|
{
|
|
524
588
|
type: "button",
|
|
525
589
|
disabled: pending,
|
|
590
|
+
style: {
|
|
591
|
+
display: "inline-flex",
|
|
592
|
+
alignItems: "center",
|
|
593
|
+
justifyContent: "center",
|
|
594
|
+
gap: 8,
|
|
595
|
+
minWidth: 168,
|
|
596
|
+
padding: "10px 20px",
|
|
597
|
+
fontFamily: "inherit",
|
|
598
|
+
fontSize: 14,
|
|
599
|
+
fontWeight: 500,
|
|
600
|
+
color: "#fff",
|
|
601
|
+
background: pending ? "#71717a" : "#18181b",
|
|
602
|
+
border: "none",
|
|
603
|
+
borderRadius: 8,
|
|
604
|
+
cursor: pending ? "default" : "pointer"
|
|
605
|
+
},
|
|
526
606
|
onClick: () => {
|
|
527
607
|
setPending(true);
|
|
528
608
|
setFailure(null);
|
|
@@ -531,13 +611,16 @@ function createAuthGate(config, silentAuth, interactiveAuth, hostAuth) {
|
|
|
531
611
|
if (result.kind !== "redirecting" /* Redirecting */) setPending(false);
|
|
532
612
|
});
|
|
533
613
|
},
|
|
534
|
-
children:
|
|
614
|
+
children: [
|
|
615
|
+
pending ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Spinner, { size: 15 }) : null,
|
|
616
|
+
pending ? "Opening sign-in\u2026" : "Sign in"
|
|
617
|
+
]
|
|
535
618
|
}
|
|
536
619
|
),
|
|
537
|
-
failure ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { fontSize: 12, marginTop:
|
|
538
|
-
]
|
|
620
|
+
failure ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { fontSize: 12.5, lineHeight: 1.5, marginTop: 14, color: "#a1a1aa" }, children: failure === "popup-blocked" /* PopupBlocked */ ? "Your browser blocked the sign-in popup. Allow popups for this site and try again." : "Sign-in didn't complete. Please try again." }) : null
|
|
621
|
+
]
|
|
539
622
|
}
|
|
540
|
-
);
|
|
623
|
+
) });
|
|
541
624
|
}
|
|
542
625
|
function AuthGate({ children, signInScreen, loader }) {
|
|
543
626
|
const [isCallback, setIsCallback] = (0, import_react2.useState)(false);
|
|
@@ -703,6 +786,11 @@ function createHostAuth(config, silentAuth) {
|
|
|
703
786
|
for (const listener of [...seedListeners]) listener(principal);
|
|
704
787
|
}
|
|
705
788
|
function wireHostAuth(bridge) {
|
|
789
|
+
if (typeof bridge?.onAuthChange !== "function") {
|
|
790
|
+
throw new TypeError(
|
|
791
|
+
"[vibe-auth] wireHostAuth(bridge) requires a VibeAppBridge \u2014 the object passed has no onAuthChange. Prefer wireVibeApp(bridge), which performs the whole handshake."
|
|
792
|
+
);
|
|
793
|
+
}
|
|
706
794
|
hostWired = true;
|
|
707
795
|
return bridge.onAuthChange((auth) => {
|
|
708
796
|
if (!auth.authenticated) {
|
|
@@ -733,6 +821,15 @@ function createHostAuth(config, silentAuth) {
|
|
|
733
821
|
}
|
|
734
822
|
|
|
735
823
|
// src/wireVibeApp.ts
|
|
824
|
+
function classifyConnectFailure(error) {
|
|
825
|
+
const code = error?.code;
|
|
826
|
+
if (code === "PARENT_ORIGIN_UNRESOLVED") return { kind: "standalone" };
|
|
827
|
+
if (code === "TIMEOUT") {
|
|
828
|
+
const framed = typeof window !== "undefined" && window.parent !== window;
|
|
829
|
+
return framed ? { kind: "failed", error } : { kind: "standalone" };
|
|
830
|
+
}
|
|
831
|
+
return { kind: "failed", error };
|
|
832
|
+
}
|
|
736
833
|
function createWireVibeApp(config, hostAuth) {
|
|
737
834
|
return function wireVibeApp(bridge, options = {}) {
|
|
738
835
|
const listeners = /* @__PURE__ */ new Set();
|
|
@@ -748,21 +845,31 @@ function createWireVibeApp(config, hostAuth) {
|
|
|
748
845
|
if (options.onSubrouteRequest) bridge.onSubrouteRequest?.(options.onSubrouteRequest);
|
|
749
846
|
hostAuth.wireHostAuth(bridge);
|
|
750
847
|
const noHost = typeof window === "undefined" || window.location.pathname === config.callbackPath;
|
|
751
|
-
|
|
848
|
+
let settleOutcome;
|
|
849
|
+
const hostOutcome = new Promise((resolve) => {
|
|
850
|
+
settleOutcome = resolve;
|
|
851
|
+
});
|
|
852
|
+
const hostContext = noHost ? (settleOutcome({ kind: "standalone" }), Promise.resolve(null)) : bridge.connect().then((ctx) => {
|
|
752
853
|
const next = ctx;
|
|
753
854
|
hostAuth.seedLastUserId(next.user.id, next.tenant);
|
|
754
855
|
if (next.enableDataReset && options.onDataReset) {
|
|
755
856
|
bridge.onDataReset?.(options.onDataReset);
|
|
756
857
|
}
|
|
757
858
|
publish(next);
|
|
859
|
+
settleOutcome({ kind: "connected" });
|
|
758
860
|
return next;
|
|
759
861
|
}).catch((error) => {
|
|
760
|
-
|
|
862
|
+
const outcome = classifyConnectFailure(error);
|
|
863
|
+
if (outcome.kind === "failed") {
|
|
864
|
+
console.warn("[vibe-auth] host connect failed", error);
|
|
865
|
+
}
|
|
866
|
+
settleOutcome(outcome);
|
|
761
867
|
return null;
|
|
762
868
|
});
|
|
763
869
|
return {
|
|
764
870
|
bridge,
|
|
765
871
|
hostContext,
|
|
872
|
+
hostOutcome,
|
|
766
873
|
getHostContext: () => current,
|
|
767
874
|
subscribeHostContext: (listener) => {
|
|
768
875
|
listeners.add(listener);
|