@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 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`. Call
43
- `auth.wireHostAuth(bridge)` at module scope, then start `bridge.connect()`
44
- immediately in that same module do not defer it to a React effect. Seed
45
- `auth.seedLastUserId(ctx.user.id, ctx.tenant)` once `connect()` resolves,
46
- from a **parent** of `AuthGate`, not inside gated children.
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 unsub = auth.wireHostAuth(bridge)
69
- export const hostContext = bridge.connect().then((ctx) => {
70
- auth.seedLastUserId(ctx.user.id, ctx.tenant)
71
- return ctx
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 host `seedLastUserId` rules.
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 `wireHostAuth` do.
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
- - `wireHostAuth(bridge)` / `seedLastUserId(userId, tenant)` — the Nominal-host integration (logout,
94
- identity/tenant switch). Seed from a parent of `AuthGate` after `connect()`.
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: "grid", placeItems: "center", height: "100vh", fontFamily: "sans-serif" },
509
- children: "Fetching your data\u2026"
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: { display: "grid", placeItems: "center", height: "100vh", fontFamily: "sans-serif" },
520
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { textAlign: "center" }, children: [
521
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
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: pending ? "Opening sign-in\u2026" : "Sign in"
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: 8 }, 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
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
- const hostContext = noHost ? Promise.resolve(null) : bridge.connect().then((ctx) => {
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
- console.warn("[vibe-auth] host connect failed", error);
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);