@nominalso/vibe-auth 0.2.2 → 0.2.3

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/dist/index.d.cts CHANGED
@@ -147,6 +147,15 @@ interface SilentAuth {
147
147
  * `rebindSession`.
148
148
  */
149
149
  isBoundTo(principal: HostPrincipal): boolean;
150
+ /**
151
+ * Record that the live session belongs to this principal.
152
+ *
153
+ * Normally `completeWithResult` does this after redeeming a code. The
154
+ * top-level redirect flow never routes through that function — the session
155
+ * arrives via supabase-js's `detectSessionInUrl` — so `AuthGate` stamps it
156
+ * there instead, and only for a `SIGNED_IN` it observed in that document.
157
+ */
158
+ stampBoundMarker(principal: HostPrincipal): void;
150
159
  clearBoundUser(): void;
151
160
  /** @internal exposed for interactive.ts, which shares completeWithResult/parseCallbackMessage's private helpers */
152
161
  completeWithResult(result: AuthResult): Promise<boolean>;
@@ -238,6 +247,97 @@ interface AuthGateBundle {
238
247
  */
239
248
  declare function createAuthGate(config: ResolvedVibeAuthConfig, silentAuth: SilentAuth, interactiveAuth: InteractiveAuth, hostAuth?: HostAuth): AuthGateBundle;
240
249
 
250
+ /**
251
+ * The contract between this package and `@nominalso/vibe-bridge`, plus the
252
+ * shape `wireVibeApp` hands back.
253
+ *
254
+ * Its own module for the same reason as `principal.ts`: `wireVibeApp` and
255
+ * `createVibeAuth` both speak these types, and consumers re-export them, so
256
+ * putting them in the implementation would make every reader import the
257
+ * implementation to name a type.
258
+ *
259
+ * Everything here is **structural**. `@nominalso/vibe-auth` must keep zero
260
+ * dependency on `@nominalso/vibe-bridge` — they publish separately and version
261
+ * independently, and a hard dependency would couple their release cycles.
262
+ */
263
+ /**
264
+ * The subset of `ContextPayload` this package reads. Apps get their own full
265
+ * `ContextPayload` back via the `Ctx` type parameter; structural assignability
266
+ * already allows the extra properties.
267
+ *
268
+ * Deliberately NO index signature. One here looks harmless but makes the
269
+ * constraint unsatisfiable by the very type it exists for: TypeScript does not
270
+ * give an `interface` an implicit index signature, so `ContextPayload` — an
271
+ * interface — failed `Ctx extends HostContextLike`.
272
+ */
273
+ interface HostContextLike {
274
+ tenant: string;
275
+ user: {
276
+ id: string;
277
+ };
278
+ /** Set by the host only when it lets a user clear the app's data. */
279
+ enableDataReset?: boolean;
280
+ }
281
+ /**
282
+ * The subset of `VibeAppBridge` `wireVibeApp` needs.
283
+ *
284
+ * Deliberately does NOT extend `AuthBridgeLike`, i.e. does not require
285
+ * `onAuthChange`. That method is `@internal` in `@nominalso/vibe-bridge` and
286
+ * stripped from its published `.d.ts`, so a real `VibeAppBridge` does not
287
+ * satisfy a constraint that demands it — requiring it here would make
288
+ * `wireVibeApp(bridge)` reject the only argument it is ever passed.
289
+ *
290
+ * It still exists at runtime, and `wireVibeApp` is the sanctioned caller, so
291
+ * that one call casts to `AuthBridgeLike` locally.
292
+ *
293
+ * `onDataReset` / `onSubrouteRequest` are optional so an app on an older bridge
294
+ * still type-checks; `wireVibeApp` feature-detects both before calling them.
295
+ */
296
+ interface VibeBridgeLike {
297
+ onContextChange(cb: (ctx: HostContextLike) => void): () => void;
298
+ connect(): Promise<HostContextLike>;
299
+ onDataReset?(cb: (payload: ResetPayloadLike) => void): () => void;
300
+ onSubrouteRequest?(cb: (subroute: string) => void): () => void;
301
+ }
302
+ /** Informational payload of a host `RESET_DATA` push. */
303
+ interface ResetPayloadLike {
304
+ reason?: string;
305
+ }
306
+ interface WireVibeAppOptions<Ctx extends HostContextLike> {
307
+ /**
308
+ * Extra work on each post-connect context push (subsidiary / period switch)
309
+ * — refetch scoped data here. The built-in store updates first, so
310
+ * `getHostContext()` already returns `next` by the time this runs.
311
+ */
312
+ onContextChange?: (next: Ctx) => void;
313
+ /**
314
+ * Reset the app to its initial state. Wired only when the host actually
315
+ * enabled it (`ctx.enableDataReset`), so passing it is always safe.
316
+ */
317
+ onDataReset?: (payload: ResetPayloadLike) => void;
318
+ /**
319
+ * Host-originated subroute pushes. Registering anything — even a no-op —
320
+ * suppresses the bridge's `history.pushState` fallback, which otherwise
321
+ * navigates a single-route iframe away and unmounts in-flight dialogs.
322
+ */
323
+ onSubrouteRequest?: (subroute: string) => void;
324
+ }
325
+ interface VibeAppWiring<Ctx extends HostContextLike> {
326
+ /** The bridge you passed in, returned so one import covers the whole module. */
327
+ bridge: VibeBridgeLike;
328
+ /**
329
+ * Resolves the host context, or `null` when there is no host to reach — a
330
+ * standalone preview, the OAuth callback document, or SSR. **Never rejects:**
331
+ * a failed handshake is expected outside Nominal, and `AuthGate` falls back
332
+ * to interactive sign-in on its own.
333
+ */
334
+ hostContext: Promise<Ctx | null>;
335
+ /** Current host context; `null` before connect resolves and when standalone. */
336
+ getHostContext(): Ctx | null;
337
+ /** Subscribe to context changes. Returns an unsubscribe function. */
338
+ subscribeHostContext(listener: (ctx: Ctx | null) => void): () => void;
339
+ }
340
+
241
341
  interface VibeAuth {
242
342
  /** True when a Supabase session already exists locally. */
243
343
  hasValidSession(): Promise<boolean>;
@@ -269,11 +369,24 @@ interface VibeAuth {
269
369
  DefaultSignInScreen: ReturnType<typeof createAuthGate>['DefaultSignInScreen'];
270
370
  /** Mount this, and ONLY this, at `callbackPath` — ungated, outside `AuthGate`. */
271
371
  SilentCallback: ReturnType<typeof createCallbackHandler>['SilentCallback'];
372
+ /**
373
+ * **The one call an embedded app needs.** Wires the bridge to this auth
374
+ * instance and starts the host handshake — `onContextChange`, `wireHostAuth`,
375
+ * the callback-document guard, `connect()`, and `seedLastUserId` — in the
376
+ * one correct order. Returns a context store plus the connect promise.
377
+ *
378
+ * Call it once, synchronously, at module scope of an eagerly imported module.
379
+ * Prefer this over calling `wireHostAuth` / `seedLastUserId` by hand.
380
+ */
381
+ wireVibeApp<Ctx extends HostContextLike>(bridge: VibeBridgeLike, options?: WireVibeAppOptions<Ctx>): VibeAppWiring<Ctx>;
272
382
  /**
273
383
  * Wire the host's auth signal to this Supabase client. Call at module
274
384
  * scope, BEFORE `bridge.connect()`, so `AuthGate` will not open on a
275
385
  * leftover Supabase session before it knows the current Nominal user.
276
386
  * Returns the unsubscribe function.
387
+ *
388
+ * Low-level: `wireVibeApp` already does this in the right order. Reach for
389
+ * this only when an app genuinely cannot hand its bridge over.
277
390
  */
278
391
  wireHostAuth(bridge: AuthBridgeLike): () => void;
279
392
  /**
@@ -339,4 +452,4 @@ declare function createVibeAuth(userConfig: VibeAuthConfig): VibeAuth;
339
452
 
340
453
  declare const RESULT_MESSAGE_TYPE = "silent-auth-result";
341
454
 
342
- export { type AuthBridgeLike, type AuthGateProps, type AuthResult, AuthResultKind, type HostPrincipal, RESULT_MESSAGE_TYPE, type ResolvedVibeAuthConfig, type ResolvedVibeAuthTimeouts, type SignInAuthenticated, type SignInFailed, SignInFailureReason, SignInKind, type SignInRedirecting, type SignInResult, type VibeAuth, type VibeAuthConfig, type VibeAuthTimeouts, createVibeAuth };
455
+ export { type AuthBridgeLike, type AuthGateProps, type AuthResult, AuthResultKind, type HostContextLike, type HostPrincipal, RESULT_MESSAGE_TYPE, type ResetPayloadLike, type ResolvedVibeAuthConfig, type ResolvedVibeAuthTimeouts, type SignInAuthenticated, type SignInFailed, SignInFailureReason, SignInKind, type SignInRedirecting, type SignInResult, type VibeAppWiring, type VibeAuth, type VibeAuthConfig, type VibeAuthTimeouts, type VibeBridgeLike, type WireVibeAppOptions, createVibeAuth };
package/dist/index.d.ts CHANGED
@@ -147,6 +147,15 @@ interface SilentAuth {
147
147
  * `rebindSession`.
148
148
  */
149
149
  isBoundTo(principal: HostPrincipal): boolean;
150
+ /**
151
+ * Record that the live session belongs to this principal.
152
+ *
153
+ * Normally `completeWithResult` does this after redeeming a code. The
154
+ * top-level redirect flow never routes through that function — the session
155
+ * arrives via supabase-js's `detectSessionInUrl` — so `AuthGate` stamps it
156
+ * there instead, and only for a `SIGNED_IN` it observed in that document.
157
+ */
158
+ stampBoundMarker(principal: HostPrincipal): void;
150
159
  clearBoundUser(): void;
151
160
  /** @internal exposed for interactive.ts, which shares completeWithResult/parseCallbackMessage's private helpers */
152
161
  completeWithResult(result: AuthResult): Promise<boolean>;
@@ -238,6 +247,97 @@ interface AuthGateBundle {
238
247
  */
239
248
  declare function createAuthGate(config: ResolvedVibeAuthConfig, silentAuth: SilentAuth, interactiveAuth: InteractiveAuth, hostAuth?: HostAuth): AuthGateBundle;
240
249
 
250
+ /**
251
+ * The contract between this package and `@nominalso/vibe-bridge`, plus the
252
+ * shape `wireVibeApp` hands back.
253
+ *
254
+ * Its own module for the same reason as `principal.ts`: `wireVibeApp` and
255
+ * `createVibeAuth` both speak these types, and consumers re-export them, so
256
+ * putting them in the implementation would make every reader import the
257
+ * implementation to name a type.
258
+ *
259
+ * Everything here is **structural**. `@nominalso/vibe-auth` must keep zero
260
+ * dependency on `@nominalso/vibe-bridge` — they publish separately and version
261
+ * independently, and a hard dependency would couple their release cycles.
262
+ */
263
+ /**
264
+ * The subset of `ContextPayload` this package reads. Apps get their own full
265
+ * `ContextPayload` back via the `Ctx` type parameter; structural assignability
266
+ * already allows the extra properties.
267
+ *
268
+ * Deliberately NO index signature. One here looks harmless but makes the
269
+ * constraint unsatisfiable by the very type it exists for: TypeScript does not
270
+ * give an `interface` an implicit index signature, so `ContextPayload` — an
271
+ * interface — failed `Ctx extends HostContextLike`.
272
+ */
273
+ interface HostContextLike {
274
+ tenant: string;
275
+ user: {
276
+ id: string;
277
+ };
278
+ /** Set by the host only when it lets a user clear the app's data. */
279
+ enableDataReset?: boolean;
280
+ }
281
+ /**
282
+ * The subset of `VibeAppBridge` `wireVibeApp` needs.
283
+ *
284
+ * Deliberately does NOT extend `AuthBridgeLike`, i.e. does not require
285
+ * `onAuthChange`. That method is `@internal` in `@nominalso/vibe-bridge` and
286
+ * stripped from its published `.d.ts`, so a real `VibeAppBridge` does not
287
+ * satisfy a constraint that demands it — requiring it here would make
288
+ * `wireVibeApp(bridge)` reject the only argument it is ever passed.
289
+ *
290
+ * It still exists at runtime, and `wireVibeApp` is the sanctioned caller, so
291
+ * that one call casts to `AuthBridgeLike` locally.
292
+ *
293
+ * `onDataReset` / `onSubrouteRequest` are optional so an app on an older bridge
294
+ * still type-checks; `wireVibeApp` feature-detects both before calling them.
295
+ */
296
+ interface VibeBridgeLike {
297
+ onContextChange(cb: (ctx: HostContextLike) => void): () => void;
298
+ connect(): Promise<HostContextLike>;
299
+ onDataReset?(cb: (payload: ResetPayloadLike) => void): () => void;
300
+ onSubrouteRequest?(cb: (subroute: string) => void): () => void;
301
+ }
302
+ /** Informational payload of a host `RESET_DATA` push. */
303
+ interface ResetPayloadLike {
304
+ reason?: string;
305
+ }
306
+ interface WireVibeAppOptions<Ctx extends HostContextLike> {
307
+ /**
308
+ * Extra work on each post-connect context push (subsidiary / period switch)
309
+ * — refetch scoped data here. The built-in store updates first, so
310
+ * `getHostContext()` already returns `next` by the time this runs.
311
+ */
312
+ onContextChange?: (next: Ctx) => void;
313
+ /**
314
+ * Reset the app to its initial state. Wired only when the host actually
315
+ * enabled it (`ctx.enableDataReset`), so passing it is always safe.
316
+ */
317
+ onDataReset?: (payload: ResetPayloadLike) => void;
318
+ /**
319
+ * Host-originated subroute pushes. Registering anything — even a no-op —
320
+ * suppresses the bridge's `history.pushState` fallback, which otherwise
321
+ * navigates a single-route iframe away and unmounts in-flight dialogs.
322
+ */
323
+ onSubrouteRequest?: (subroute: string) => void;
324
+ }
325
+ interface VibeAppWiring<Ctx extends HostContextLike> {
326
+ /** The bridge you passed in, returned so one import covers the whole module. */
327
+ bridge: VibeBridgeLike;
328
+ /**
329
+ * Resolves the host context, or `null` when there is no host to reach — a
330
+ * standalone preview, the OAuth callback document, or SSR. **Never rejects:**
331
+ * a failed handshake is expected outside Nominal, and `AuthGate` falls back
332
+ * to interactive sign-in on its own.
333
+ */
334
+ hostContext: Promise<Ctx | null>;
335
+ /** Current host context; `null` before connect resolves and when standalone. */
336
+ getHostContext(): Ctx | null;
337
+ /** Subscribe to context changes. Returns an unsubscribe function. */
338
+ subscribeHostContext(listener: (ctx: Ctx | null) => void): () => void;
339
+ }
340
+
241
341
  interface VibeAuth {
242
342
  /** True when a Supabase session already exists locally. */
243
343
  hasValidSession(): Promise<boolean>;
@@ -269,11 +369,24 @@ interface VibeAuth {
269
369
  DefaultSignInScreen: ReturnType<typeof createAuthGate>['DefaultSignInScreen'];
270
370
  /** Mount this, and ONLY this, at `callbackPath` — ungated, outside `AuthGate`. */
271
371
  SilentCallback: ReturnType<typeof createCallbackHandler>['SilentCallback'];
372
+ /**
373
+ * **The one call an embedded app needs.** Wires the bridge to this auth
374
+ * instance and starts the host handshake — `onContextChange`, `wireHostAuth`,
375
+ * the callback-document guard, `connect()`, and `seedLastUserId` — in the
376
+ * one correct order. Returns a context store plus the connect promise.
377
+ *
378
+ * Call it once, synchronously, at module scope of an eagerly imported module.
379
+ * Prefer this over calling `wireHostAuth` / `seedLastUserId` by hand.
380
+ */
381
+ wireVibeApp<Ctx extends HostContextLike>(bridge: VibeBridgeLike, options?: WireVibeAppOptions<Ctx>): VibeAppWiring<Ctx>;
272
382
  /**
273
383
  * Wire the host's auth signal to this Supabase client. Call at module
274
384
  * scope, BEFORE `bridge.connect()`, so `AuthGate` will not open on a
275
385
  * leftover Supabase session before it knows the current Nominal user.
276
386
  * Returns the unsubscribe function.
387
+ *
388
+ * Low-level: `wireVibeApp` already does this in the right order. Reach for
389
+ * this only when an app genuinely cannot hand its bridge over.
277
390
  */
278
391
  wireHostAuth(bridge: AuthBridgeLike): () => void;
279
392
  /**
@@ -339,4 +452,4 @@ declare function createVibeAuth(userConfig: VibeAuthConfig): VibeAuth;
339
452
 
340
453
  declare const RESULT_MESSAGE_TYPE = "silent-auth-result";
341
454
 
342
- export { type AuthBridgeLike, type AuthGateProps, type AuthResult, AuthResultKind, type HostPrincipal, RESULT_MESSAGE_TYPE, type ResolvedVibeAuthConfig, type ResolvedVibeAuthTimeouts, type SignInAuthenticated, type SignInFailed, SignInFailureReason, SignInKind, type SignInRedirecting, type SignInResult, type VibeAuth, type VibeAuthConfig, type VibeAuthTimeouts, createVibeAuth };
455
+ export { type AuthBridgeLike, type AuthGateProps, type AuthResult, AuthResultKind, type HostContextLike, type HostPrincipal, RESULT_MESSAGE_TYPE, type ResetPayloadLike, type ResolvedVibeAuthConfig, type ResolvedVibeAuthTimeouts, type SignInAuthenticated, type SignInFailed, SignInFailureReason, SignInKind, type SignInRedirecting, type SignInResult, type VibeAppWiring, type VibeAuth, type VibeAuthConfig, type VibeAuthTimeouts, type VibeBridgeLike, type WireVibeAppOptions, createVibeAuth };
package/dist/index.js CHANGED
@@ -249,6 +249,7 @@ function createSilentAuth(config, getHostPrincipal, onBound) {
249
249
  ensureSession,
250
250
  rebindSession,
251
251
  isBoundTo,
252
+ stampBoundMarker: writeBoundMarker,
252
253
  clearBoundUser,
253
254
  completeWithResult
254
255
  };
@@ -523,6 +524,14 @@ function createAuthGate(config, silentAuth, interactiveAuth, hostAuth) {
523
524
  let applyGen = 0;
524
525
  let pendingSessionRead;
525
526
  let acceptPrincipalUpdates = !hostAuth?.isHostWired();
527
+ let redeemedHere = false;
528
+ const sessionBelongsTo = (principal, sessionOk) => {
529
+ if (!sessionOk) return false;
530
+ if (silentAuth.isBoundTo(principal)) return true;
531
+ if (!redeemedHere) return false;
532
+ silentAuth.stampBoundMarker(principal);
533
+ return true;
534
+ };
526
535
  const applyPrincipal = async (principal) => {
527
536
  hostPrincipal = principal;
528
537
  const gen = ++applyGen;
@@ -535,10 +544,11 @@ function createAuthGate(config, silentAuth, interactiveAuth, hostAuth) {
535
544
  sessionOk = await silentAuth.hasValidSession().catch(() => false);
536
545
  }
537
546
  if (cancelled || gen !== applyGen) return;
538
- if (sessionOk && silentAuth.isBoundTo(principal)) {
547
+ if (sessionBelongsTo(principal, sessionOk)) {
539
548
  setStatus("authenticated" /* Authenticated */);
540
549
  return;
541
550
  }
551
+ if (RETURNING_FROM_OAUTH) return;
542
552
  const ok = await silentAuth.rebindSession(principal).catch(() => false);
543
553
  if (cancelled || gen !== applyGen) return;
544
554
  if (!ok) void supabase.auth.signOut().catch(() => {
@@ -553,12 +563,13 @@ function createAuthGate(config, silentAuth, interactiveAuth, hostAuth) {
553
563
  if (principal) {
554
564
  const sessionOk = await silentAuth.hasValidSession().catch(() => false);
555
565
  if (cancelled || gen !== applyGen) return;
556
- if (!(sessionOk && silentAuth.isBoundTo(principal))) return;
566
+ if (!sessionBelongsTo(principal, sessionOk)) return;
557
567
  }
558
568
  setStatus("authenticated" /* Authenticated */);
559
569
  };
560
570
  async function resolve() {
561
571
  if (RETURNING_FROM_OAUTH) {
572
+ acceptPrincipalUpdates = true;
562
573
  oauthTimer = window.setTimeout(() => {
563
574
  if (!cancelled)
564
575
  setStatus((s) => s === "checking" /* Checking */ ? "unauthenticated" /* Unauthenticated */ : s);
@@ -603,8 +614,10 @@ function createAuthGate(config, silentAuth, interactiveAuth, hostAuth) {
603
614
  window.addEventListener("storage", onStorage);
604
615
  const { data: sub } = supabase.auth.onAuthStateChange((event, session) => {
605
616
  if (cancelled || event === "INITIAL_SESSION") return;
606
- if (session) void openIfBound();
607
- else {
617
+ if (session) {
618
+ if (RETURNING_FROM_OAUTH && event === "SIGNED_IN") redeemedHere = true;
619
+ void openIfBound();
620
+ } else {
608
621
  pendingSessionRead = void 0;
609
622
  setStatus((s) => s === "authenticated" /* Authenticated */ ? "unauthenticated" /* Unauthenticated */ : s);
610
623
  }
@@ -690,6 +703,48 @@ function createHostAuth(config, silentAuth) {
690
703
  };
691
704
  }
692
705
 
706
+ // src/wireVibeApp.ts
707
+ function createWireVibeApp(config, hostAuth) {
708
+ return function wireVibeApp(bridge, options = {}) {
709
+ const listeners = /* @__PURE__ */ new Set();
710
+ let current = null;
711
+ function publish(next) {
712
+ current = next;
713
+ for (const listener of [...listeners]) listener(next);
714
+ }
715
+ bridge.onContextChange((next) => {
716
+ publish(next);
717
+ options.onContextChange?.(next);
718
+ });
719
+ if (options.onSubrouteRequest) bridge.onSubrouteRequest?.(options.onSubrouteRequest);
720
+ hostAuth.wireHostAuth(bridge);
721
+ const noHost = typeof window === "undefined" || window.location.pathname === config.callbackPath;
722
+ const hostContext = noHost ? Promise.resolve(null) : bridge.connect().then((ctx) => {
723
+ const next = ctx;
724
+ hostAuth.seedLastUserId(next.user.id, next.tenant);
725
+ if (next.enableDataReset && options.onDataReset) {
726
+ bridge.onDataReset?.(options.onDataReset);
727
+ }
728
+ publish(next);
729
+ return next;
730
+ }).catch((error) => {
731
+ console.warn("[vibe-auth] host connect failed", error);
732
+ return null;
733
+ });
734
+ return {
735
+ bridge,
736
+ hostContext,
737
+ getHostContext: () => current,
738
+ subscribeHostContext: (listener) => {
739
+ listeners.add(listener);
740
+ return () => {
741
+ listeners.delete(listener);
742
+ };
743
+ }
744
+ };
745
+ };
746
+ }
747
+
693
748
  // src/createVibeAuth.ts
694
749
  function createVibeAuth(userConfig) {
695
750
  const config = resolveConfig(userConfig);
@@ -720,6 +775,7 @@ function createVibeAuth(userConfig) {
720
775
  AuthGate,
721
776
  DefaultSignInScreen,
722
777
  SilentCallback,
778
+ wireVibeApp: createWireVibeApp(config, hostAuth),
723
779
  wireHostAuth,
724
780
  seedLastUserId,
725
781
  RESULT_MESSAGE_TYPE,