@nominalso/vibe-auth 0.2.2 → 0.2.4

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,28 @@ 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
+ `wireHostAuth` / `seedLastUserId` are the pre-0.2.3 hand-wiring. They are
61
+ still exported for compatibility, but new code should not call them — four of
62
+ seven migrated apps got that sequence wrong, each differently, which is why
63
+ `wireVibeApp` exists.
47
64
 
48
65
  ## Root wiring (SSR — React #418)
49
66
 
package/README.md CHANGED
@@ -62,17 +62,22 @@ 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)
73
72
  ```
74
73
 
75
- See [`AGENTS.md`](./AGENTS.md) for SSR root wiring and host `seedLastUserId` rules.
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
+ See [`AGENTS.md`](./AGENTS.md) for SSR root wiring and the full ordering rules.
76
81
  Timeouts live on `VibeAuthTimeouts` in the package `.d.ts`.
77
82
 
78
83
  ## Why a client you own, not one this package creates
@@ -87,11 +92,13 @@ control**, not a preference (the `.d.ts` on `VibeAuthConfig` explains why).
87
92
 
88
93
  - `ensureSession()` / `rebindSession({ userId, tenant })` — the cross-document-safe core (Web Lock
89
94
  serialised, terminal-capped, sibling-adopting). Most apps never call these directly; the
90
- gate and `wireHostAuth` do.
95
+ gate and `wireVibeApp` do.
91
96
  - `AuthGate` / `DefaultSignInScreen` / `SilentCallback` — the three React pieces. Override
92
97
  `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()`.
98
+ - `wireVibeApp(bridge, options?)` — the Nominal-host integration in one call (handshake,
99
+ host context store, logout and identity/tenant switch). Call it at module scope from a
100
+ parent of `AuthGate`. (`wireHostAuth` / `seedLastUserId` are the pre-0.2.3 hand-wiring,
101
+ still exported for compatibility; new code should not use them.)
95
102
  - Fully configurable timeouts (`VibeAuthTimeouts` in the `.d.ts`) — every value defaults to
96
103
  what shipped after this flow's production incidents.
97
104
 
package/dist/index.cjs CHANGED
@@ -278,6 +278,7 @@ function createSilentAuth(config, getHostPrincipal, onBound) {
278
278
  ensureSession,
279
279
  rebindSession,
280
280
  isBoundTo,
281
+ stampBoundMarker: writeBoundMarker,
281
282
  clearBoundUser,
282
283
  completeWithResult
283
284
  };
@@ -552,6 +553,14 @@ function createAuthGate(config, silentAuth, interactiveAuth, hostAuth) {
552
553
  let applyGen = 0;
553
554
  let pendingSessionRead;
554
555
  let acceptPrincipalUpdates = !hostAuth?.isHostWired();
556
+ let redeemedHere = false;
557
+ const sessionBelongsTo = (principal, sessionOk) => {
558
+ if (!sessionOk) return false;
559
+ if (silentAuth.isBoundTo(principal)) return true;
560
+ if (!redeemedHere) return false;
561
+ silentAuth.stampBoundMarker(principal);
562
+ return true;
563
+ };
555
564
  const applyPrincipal = async (principal) => {
556
565
  hostPrincipal = principal;
557
566
  const gen = ++applyGen;
@@ -564,10 +573,11 @@ function createAuthGate(config, silentAuth, interactiveAuth, hostAuth) {
564
573
  sessionOk = await silentAuth.hasValidSession().catch(() => false);
565
574
  }
566
575
  if (cancelled || gen !== applyGen) return;
567
- if (sessionOk && silentAuth.isBoundTo(principal)) {
576
+ if (sessionBelongsTo(principal, sessionOk)) {
568
577
  setStatus("authenticated" /* Authenticated */);
569
578
  return;
570
579
  }
580
+ if (RETURNING_FROM_OAUTH) return;
571
581
  const ok = await silentAuth.rebindSession(principal).catch(() => false);
572
582
  if (cancelled || gen !== applyGen) return;
573
583
  if (!ok) void supabase.auth.signOut().catch(() => {
@@ -582,12 +592,13 @@ function createAuthGate(config, silentAuth, interactiveAuth, hostAuth) {
582
592
  if (principal) {
583
593
  const sessionOk = await silentAuth.hasValidSession().catch(() => false);
584
594
  if (cancelled || gen !== applyGen) return;
585
- if (!(sessionOk && silentAuth.isBoundTo(principal))) return;
595
+ if (!sessionBelongsTo(principal, sessionOk)) return;
586
596
  }
587
597
  setStatus("authenticated" /* Authenticated */);
588
598
  };
589
599
  async function resolve() {
590
600
  if (RETURNING_FROM_OAUTH) {
601
+ acceptPrincipalUpdates = true;
591
602
  oauthTimer = window.setTimeout(() => {
592
603
  if (!cancelled)
593
604
  setStatus((s) => s === "checking" /* Checking */ ? "unauthenticated" /* Unauthenticated */ : s);
@@ -632,8 +643,10 @@ function createAuthGate(config, silentAuth, interactiveAuth, hostAuth) {
632
643
  window.addEventListener("storage", onStorage);
633
644
  const { data: sub } = supabase.auth.onAuthStateChange((event, session) => {
634
645
  if (cancelled || event === "INITIAL_SESSION") return;
635
- if (session) void openIfBound();
636
- else {
646
+ if (session) {
647
+ if (RETURNING_FROM_OAUTH && event === "SIGNED_IN") redeemedHere = true;
648
+ void openIfBound();
649
+ } else {
637
650
  pendingSessionRead = void 0;
638
651
  setStatus((s) => s === "authenticated" /* Authenticated */ ? "unauthenticated" /* Unauthenticated */ : s);
639
652
  }
@@ -719,6 +732,48 @@ function createHostAuth(config, silentAuth) {
719
732
  };
720
733
  }
721
734
 
735
+ // src/wireVibeApp.ts
736
+ function createWireVibeApp(config, hostAuth) {
737
+ return function wireVibeApp(bridge, options = {}) {
738
+ const listeners = /* @__PURE__ */ new Set();
739
+ let current = null;
740
+ function publish(next) {
741
+ current = next;
742
+ for (const listener of [...listeners]) listener(next);
743
+ }
744
+ bridge.onContextChange((next) => {
745
+ publish(next);
746
+ options.onContextChange?.(next);
747
+ });
748
+ if (options.onSubrouteRequest) bridge.onSubrouteRequest?.(options.onSubrouteRequest);
749
+ hostAuth.wireHostAuth(bridge);
750
+ const noHost = typeof window === "undefined" || window.location.pathname === config.callbackPath;
751
+ const hostContext = noHost ? Promise.resolve(null) : bridge.connect().then((ctx) => {
752
+ const next = ctx;
753
+ hostAuth.seedLastUserId(next.user.id, next.tenant);
754
+ if (next.enableDataReset && options.onDataReset) {
755
+ bridge.onDataReset?.(options.onDataReset);
756
+ }
757
+ publish(next);
758
+ return next;
759
+ }).catch((error) => {
760
+ console.warn("[vibe-auth] host connect failed", error);
761
+ return null;
762
+ });
763
+ return {
764
+ bridge,
765
+ hostContext,
766
+ getHostContext: () => current,
767
+ subscribeHostContext: (listener) => {
768
+ listeners.add(listener);
769
+ return () => {
770
+ listeners.delete(listener);
771
+ };
772
+ }
773
+ };
774
+ };
775
+ }
776
+
722
777
  // src/createVibeAuth.ts
723
778
  function createVibeAuth(userConfig) {
724
779
  const config = resolveConfig(userConfig);
@@ -749,6 +804,7 @@ function createVibeAuth(userConfig) {
749
804
  AuthGate,
750
805
  DefaultSignInScreen,
751
806
  SilentCallback,
807
+ wireVibeApp: createWireVibeApp(config, hostAuth),
752
808
  wireHostAuth,
753
809
  seedLastUserId,
754
810
  RESULT_MESSAGE_TYPE,