@nominalso/vibe-auth 0.2.4 → 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/dist/index.d.ts CHANGED
@@ -202,9 +202,34 @@ interface InteractiveAuth {
202
202
  signInInteractive(): Promise<SignInResult>;
203
203
  }
204
204
 
205
- /** The subset of `VibeAppBridge` this module needs — structural, not a hard dependency. */
205
+ /**
206
+ * The subset of `VibeAppBridge` this module needs — structural, not a hard
207
+ * dependency.
208
+ *
209
+ * `onAuthChange` is **optional in the type and required at runtime**, which
210
+ * looks wrong until you see what forced it: the method is `@internal` in
211
+ * `@nominalso/vibe-bridge` and `stripInternal` removes it from that package's
212
+ * published `.d.ts`. A real `VibeAppBridge` therefore does not structurally
213
+ * satisfy a type that demands it, and `wireHostAuth(bridge)` stopped
214
+ * compiling for every app on bridge >= 0.12 — the exact combination the
215
+ * compatibility promise covers.
216
+ *
217
+ * Optional makes the call compile again; `wireHostAuth` then throws if the
218
+ * method really is absent, so the mistake is loud rather than a silent no-op
219
+ * that drops host-session lockstep (a user staying signed in after a Nominal
220
+ * logout, which is not a failure to discover in production).
221
+ */
206
222
  interface AuthBridgeLike {
207
- onAuthChange(cb: (auth: {
223
+ /**
224
+ * Not used here — it anchors the type. With `onAuthChange` optional this
225
+ * interface would have no required member, making it a *weak type*: TypeScript
226
+ * then demands the argument share at least one property, and a `VibeAppBridge`
227
+ * (whose `onAuthChange` is stripped) shares none, so the call still would not
228
+ * compile. `connect` is public on every bridge, so requiring it fixes that and
229
+ * says what we actually mean — "this is a bridge".
230
+ */
231
+ connect(): Promise<unknown>;
232
+ onAuthChange?(cb: (auth: {
208
233
  authenticated: boolean;
209
234
  userId?: string;
210
235
  tenant?: string;
@@ -322,6 +347,30 @@ interface WireVibeAppOptions<Ctx extends HostContextLike> {
322
347
  */
323
348
  onSubrouteRequest?: (subroute: string) => void;
324
349
  }
350
+ /**
351
+ * Why the handshake ended the way it did.
352
+ *
353
+ * `hostContext` resolving `null` is deliberately not an error — a standalone
354
+ * preview is the normal way to run a Vibe App outside Nominal. But it collapses
355
+ * two situations an app usually wants to treat differently:
356
+ *
357
+ * - **`standalone`** — nothing was ever going to answer (not embedded, or the
358
+ * OAuth callback document). Expected. Render the "open this from Nominal"
359
+ * state, quietly.
360
+ * - **`failed`** — the app *is* embedded and the host did not answer. A real
361
+ * fault worth surfacing, with `error` carrying the underlying `BridgeError`.
362
+ *
363
+ * Without this, an app that shows an error on a null context cries wolf on
364
+ * every dev load, and one that stays silent hides genuine breakage.
365
+ */
366
+ type HostOutcome = {
367
+ kind: 'connected';
368
+ } | {
369
+ kind: 'standalone';
370
+ } | {
371
+ kind: 'failed';
372
+ error: unknown;
373
+ };
325
374
  interface VibeAppWiring<Ctx extends HostContextLike> {
326
375
  /** The bridge you passed in, returned so one import covers the whole module. */
327
376
  bridge: VibeBridgeLike;
@@ -332,6 +381,19 @@ interface VibeAppWiring<Ctx extends HostContextLike> {
332
381
  * to interactive sign-in on its own.
333
382
  */
334
383
  hostContext: Promise<Ctx | null>;
384
+ /**
385
+ * Why the handshake ended as it did — the distinction `hostContext`'s `null`
386
+ * cannot carry. Settles at the same time as `hostContext`, and like it,
387
+ * **never rejects**.
388
+ *
389
+ * @example
390
+ * ```ts
391
+ * const outcome = await hostOutcome
392
+ * if (outcome.kind === 'failed') showError(outcome.error) // embedded, host silent
393
+ * else if (outcome.kind === 'standalone') showOpenFromNominal()
394
+ * ```
395
+ */
396
+ hostOutcome: Promise<HostOutcome>;
335
397
  /** Current host context; `null` before connect resolves and when standalone. */
336
398
  getHostContext(): Ctx | null;
337
399
  /** Subscribe to context changes. Returns an unsubscribe function. */
@@ -452,4 +514,4 @@ declare function createVibeAuth(userConfig: VibeAuthConfig): VibeAuth;
452
514
 
453
515
  declare const RESULT_MESSAGE_TYPE = "silent-auth-result";
454
516
 
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 };
517
+ export { type AuthBridgeLike, type AuthGateProps, type AuthResult, AuthResultKind, type HostContextLike, type HostOutcome, 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
@@ -472,28 +472,108 @@ function createAuthGate(config, silentAuth, interactiveAuth, hostAuth) {
472
472
  const { supabase, callbackPath, timeouts } = config;
473
473
  const RETURNING_FROM_OAUTH = typeof window !== "undefined" && (new URLSearchParams(window.location.search).has("code") || window.location.hash.includes("access_token"));
474
474
  const IS_CALLBACK_PATH = typeof window !== "undefined" && window.location.pathname === callbackPath;
475
+ const SPIN_CLASS = "nominal-vibe-auth-spin";
476
+ function Spinner({ size = 18 }) {
477
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
478
+ /* @__PURE__ */ jsx("style", { children: `
479
+ @keyframes ${SPIN_CLASS} { to { transform: rotate(360deg) } }
480
+ .${SPIN_CLASS} { animation: ${SPIN_CLASS} 700ms linear infinite; transform-origin: 50% 50% }
481
+ @media (prefers-reduced-motion: reduce) { .${SPIN_CLASS} { animation-duration: 2.4s } }
482
+ ` }),
483
+ /* @__PURE__ */ jsxs(
484
+ "svg",
485
+ {
486
+ className: SPIN_CLASS,
487
+ width: size,
488
+ height: size,
489
+ viewBox: "0 0 24 24",
490
+ fill: "none",
491
+ "aria-hidden": "true",
492
+ children: [
493
+ /* @__PURE__ */ jsx(
494
+ "circle",
495
+ {
496
+ cx: "12",
497
+ cy: "12",
498
+ r: "9",
499
+ stroke: "currentColor",
500
+ strokeOpacity: "0.18",
501
+ strokeWidth: "3"
502
+ }
503
+ ),
504
+ /* @__PURE__ */ jsx(
505
+ "path",
506
+ {
507
+ d: "M21 12a9 9 0 0 0-9-9",
508
+ stroke: "currentColor",
509
+ strokeWidth: "3",
510
+ strokeLinecap: "round"
511
+ }
512
+ )
513
+ ]
514
+ }
515
+ )
516
+ ] });
517
+ }
518
+ const SCREEN = {
519
+ display: "grid",
520
+ placeItems: "center",
521
+ height: "100vh",
522
+ fontFamily: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
523
+ color: "#3f3f46",
524
+ background: "#fafafa"
525
+ };
475
526
  function DefaultFullScreenLoader() {
476
- return /* @__PURE__ */ jsx(
527
+ return /* @__PURE__ */ jsx("div", { style: SCREEN, children: /* @__PURE__ */ jsxs(
477
528
  "div",
478
529
  {
479
- style: { display: "grid", placeItems: "center", height: "100vh", fontFamily: "sans-serif" },
480
- children: "Fetching your data\u2026"
530
+ style: { display: "flex", alignItems: "center", gap: 10, fontSize: 14 },
531
+ role: "status",
532
+ "aria-live": "polite",
533
+ children: [
534
+ /* @__PURE__ */ jsx(Spinner, {}),
535
+ /* @__PURE__ */ jsx("span", { children: "Fetching your data\u2026" })
536
+ ]
481
537
  }
482
- );
538
+ ) });
483
539
  }
484
540
  function DefaultSignInScreen() {
485
541
  const [pending, setPending] = useState(false);
486
542
  const [failure, setFailure] = useState(null);
487
- return /* @__PURE__ */ jsx(
543
+ return /* @__PURE__ */ jsx("div", { style: SCREEN, children: /* @__PURE__ */ jsxs(
488
544
  "div",
489
545
  {
490
- style: { display: "grid", placeItems: "center", height: "100vh", fontFamily: "sans-serif" },
491
- children: /* @__PURE__ */ jsxs("div", { style: { textAlign: "center" }, children: [
492
- /* @__PURE__ */ jsx(
546
+ style: {
547
+ textAlign: "center",
548
+ background: "#fff",
549
+ border: "1px solid #e4e4e7",
550
+ borderRadius: 12,
551
+ padding: "28px 32px",
552
+ boxShadow: "0 1px 2px rgba(0,0,0,0.04)",
553
+ maxWidth: 340
554
+ },
555
+ children: [
556
+ /* @__PURE__ */ jsxs(
493
557
  "button",
494
558
  {
495
559
  type: "button",
496
560
  disabled: pending,
561
+ style: {
562
+ display: "inline-flex",
563
+ alignItems: "center",
564
+ justifyContent: "center",
565
+ gap: 8,
566
+ minWidth: 168,
567
+ padding: "10px 20px",
568
+ fontFamily: "inherit",
569
+ fontSize: 14,
570
+ fontWeight: 500,
571
+ color: "#fff",
572
+ background: pending ? "#71717a" : "#18181b",
573
+ border: "none",
574
+ borderRadius: 8,
575
+ cursor: pending ? "default" : "pointer"
576
+ },
497
577
  onClick: () => {
498
578
  setPending(true);
499
579
  setFailure(null);
@@ -502,13 +582,16 @@ function createAuthGate(config, silentAuth, interactiveAuth, hostAuth) {
502
582
  if (result.kind !== "redirecting" /* Redirecting */) setPending(false);
503
583
  });
504
584
  },
505
- children: pending ? "Opening sign-in\u2026" : "Sign in"
585
+ children: [
586
+ pending ? /* @__PURE__ */ jsx(Spinner, { size: 15 }) : null,
587
+ pending ? "Opening sign-in\u2026" : "Sign in"
588
+ ]
506
589
  }
507
590
  ),
508
- failure ? /* @__PURE__ */ 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
509
- ] })
591
+ failure ? /* @__PURE__ */ 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
592
+ ]
510
593
  }
511
- );
594
+ ) });
512
595
  }
513
596
  function AuthGate({ children, signInScreen, loader }) {
514
597
  const [isCallback, setIsCallback] = useState(false);
@@ -674,6 +757,11 @@ function createHostAuth(config, silentAuth) {
674
757
  for (const listener of [...seedListeners]) listener(principal);
675
758
  }
676
759
  function wireHostAuth(bridge) {
760
+ if (typeof bridge?.onAuthChange !== "function") {
761
+ throw new TypeError(
762
+ "[vibe-auth] wireHostAuth(bridge) requires a VibeAppBridge \u2014 the object passed has no onAuthChange. Prefer wireVibeApp(bridge), which performs the whole handshake."
763
+ );
764
+ }
677
765
  hostWired = true;
678
766
  return bridge.onAuthChange((auth) => {
679
767
  if (!auth.authenticated) {
@@ -704,6 +792,15 @@ function createHostAuth(config, silentAuth) {
704
792
  }
705
793
 
706
794
  // src/wireVibeApp.ts
795
+ function classifyConnectFailure(error) {
796
+ const code = error?.code;
797
+ if (code === "PARENT_ORIGIN_UNRESOLVED") return { kind: "standalone" };
798
+ if (code === "TIMEOUT") {
799
+ const framed = typeof window !== "undefined" && window.parent !== window;
800
+ return framed ? { kind: "failed", error } : { kind: "standalone" };
801
+ }
802
+ return { kind: "failed", error };
803
+ }
707
804
  function createWireVibeApp(config, hostAuth) {
708
805
  return function wireVibeApp(bridge, options = {}) {
709
806
  const listeners = /* @__PURE__ */ new Set();
@@ -719,21 +816,31 @@ function createWireVibeApp(config, hostAuth) {
719
816
  if (options.onSubrouteRequest) bridge.onSubrouteRequest?.(options.onSubrouteRequest);
720
817
  hostAuth.wireHostAuth(bridge);
721
818
  const noHost = typeof window === "undefined" || window.location.pathname === config.callbackPath;
722
- const hostContext = noHost ? Promise.resolve(null) : bridge.connect().then((ctx) => {
819
+ let settleOutcome;
820
+ const hostOutcome = new Promise((resolve) => {
821
+ settleOutcome = resolve;
822
+ });
823
+ const hostContext = noHost ? (settleOutcome({ kind: "standalone" }), Promise.resolve(null)) : bridge.connect().then((ctx) => {
723
824
  const next = ctx;
724
825
  hostAuth.seedLastUserId(next.user.id, next.tenant);
725
826
  if (next.enableDataReset && options.onDataReset) {
726
827
  bridge.onDataReset?.(options.onDataReset);
727
828
  }
728
829
  publish(next);
830
+ settleOutcome({ kind: "connected" });
729
831
  return next;
730
832
  }).catch((error) => {
731
- console.warn("[vibe-auth] host connect failed", error);
833
+ const outcome = classifyConnectFailure(error);
834
+ if (outcome.kind === "failed") {
835
+ console.warn("[vibe-auth] host connect failed", error);
836
+ }
837
+ settleOutcome(outcome);
732
838
  return null;
733
839
  });
734
840
  return {
735
841
  bridge,
736
842
  hostContext,
843
+ hostOutcome,
737
844
  getHostContext: () => current,
738
845
  subscribeHostContext: (listener) => {
739
846
  listeners.add(listener);