@nominalso/vibe-auth 0.2.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.
@@ -0,0 +1,338 @@
1
+ import { SupabaseClient } from '@supabase/supabase-js';
2
+ import { ReactNode } from 'react';
3
+
4
+ /**
5
+ * Timeout knobs for the silent-SSO flow. All optional — sane defaults (below)
6
+ * are the same values the silent-supabase-oidc-login skill shipped after its
7
+ * production incidents, so only override these if you've measured a reason to.
8
+ */
9
+ interface VibeAuthTimeouts {
10
+ /**
11
+ * Bounds the hidden-iframe wait for a `prompt=none` result. Does NOT bound
12
+ * the whole silent attempt — see `silentFlowCapMs`.
13
+ * @default 10_000
14
+ */
15
+ silentIframeMs?: number;
16
+ /**
17
+ * Hard cap on ONE WHOLE silent attempt (the `signInWithOAuth` fetch, the
18
+ * hidden-iframe wait, and the code exchange). Without this, a slow attempt
19
+ * could hold the cross-document lock past every waiter's patience.
20
+ * @default 15_000
21
+ */
22
+ silentFlowCapMs?: number;
23
+ /**
24
+ * How long a document queues for the cross-document Web Lock before giving
25
+ * up. Sized off `silentFlowCapMs` by default (two full holder turns plus
26
+ * margin) — pass this explicitly only if you also override `silentFlowCapMs`
27
+ * and want the derived relationship preserved.
28
+ * @default 2 * silentFlowCapMs + 5_000
29
+ */
30
+ lockWaitMs?: number;
31
+ /**
32
+ * Bounds the wait for `detectSessionInUrl` to finish a top-level OAuth
33
+ * return before the gate falls through to the sign-in screen.
34
+ * @default 15_000
35
+ */
36
+ oauthCompletionMs?: number;
37
+ /** Bounds the interactive popup flow (user-facing — generous on purpose). @default 120_000 */
38
+ popupMs?: number;
39
+ /** Bounds the Lovable-preview "prime the popup" navigation. @default 8_000 */
40
+ previewPrimingMs?: number;
41
+ /** Poll interval while waiting for the preview-priming navigation to settle. @default 250 */
42
+ previewPrimingPollMs?: number;
43
+ }
44
+ interface ResolvedVibeAuthTimeouts {
45
+ silentIframeMs: number;
46
+ silentFlowCapMs: number;
47
+ lockWaitMs: number;
48
+ oauthCompletionMs: number;
49
+ popupMs: number;
50
+ previewPrimingMs: number;
51
+ previewPrimingPollMs: number;
52
+ }
53
+ /** Config passed to `createVibeAuth`. */
54
+ interface VibeAuthConfig {
55
+ /**
56
+ * The app's own Supabase client — injected, never created by this package.
57
+ * MUST have `auth.flowType: 'pkce'` (checked at init; see `assertPkce`). Own
58
+ * this client for the same reason a generated app owns its `client.ts`: it
59
+ * carries app-specific types, storage adapters, and Lovable Cloud wiring.
60
+ */
61
+ supabase: SupabaseClient;
62
+ /**
63
+ * The IdP-federation provider name for `signInWithOAuth`, e.g.
64
+ * `'custom:supabase-fedapp'`. Never guess this — it comes from the app's own
65
+ * Supabase Auth → Providers configuration.
66
+ */
67
+ provider: string;
68
+ /**
69
+ * Path of the same-origin OAuth callback route. Must match the app's actual
70
+ * route AND the `redirect_to` the app's Supabase project allowlists.
71
+ * @default '/silent-callback'
72
+ */
73
+ callbackPath?: string;
74
+ timeouts?: VibeAuthTimeouts;
75
+ /**
76
+ * Prefix for the cross-document Web Lock name and the identity-switch
77
+ * bound-user localStorage key. Only change this if multiple vibe apps with
78
+ * DIFFERENT Supabase projects share one origin (rare) and you need their
79
+ * locks/markers to not collide — same-project apps on different origins
80
+ * never collide regardless, since Web Locks and localStorage are
81
+ * origin-scoped.
82
+ * @default 'nominal'
83
+ */
84
+ storageKeyPrefix?: string;
85
+ }
86
+ interface ResolvedVibeAuthConfig {
87
+ supabase: SupabaseClient;
88
+ provider: string;
89
+ callbackPath: string;
90
+ timeouts: ResolvedVibeAuthTimeouts;
91
+ lockName: string;
92
+ boundUserKey: string;
93
+ }
94
+
95
+ /**
96
+ * Snapshot the callback URL and return a `SilentCallback` component closed
97
+ * over that snapshot.
98
+ *
99
+ * MUST be called synchronously, at the top of the app's own auth-setup
100
+ * module (i.e. as part of `createVibeAuth`, itself called at module scope —
101
+ * never lazily or inside a hook). Module bodies run synchronously at import;
102
+ * supabase-js's `detectSessionInUrl` strips the `#access_token` hash
103
+ * asynchronously shortly after a client boots ANYWHERE in the page. Reading
104
+ * `window.location` later (inside a React effect, or after some other
105
+ * module's async work) races that strip — it wins on a fast machine with a
106
+ * warm module cache and loses on a cold one, which presents as "works on my
107
+ * laptop, fails on my colleague's".
108
+ *
109
+ * The `posted` once-latch is scoped to THIS call, not a bare module `let` —
110
+ * unlike the skill's single-instance-per-app assumption, a factory-based
111
+ * package must not leak state across multiple `createVibeAuth` calls (e.g.
112
+ * in tests).
113
+ */
114
+ declare function createCallbackHandler(): {
115
+ SilentCallback: () => null;
116
+ };
117
+
118
+ /**
119
+ * Who the Nominal host says this document is acting as. Tenant is part of
120
+ * identity, not metadata: the same user in another tenant is a different
121
+ * principal and must re-bind before the gate opens.
122
+ *
123
+ * Its own module because `silentAuth`, `hostAuth`, and `AuthGate` all speak it
124
+ * — putting it in any one of them would make the other two import a peer.
125
+ */
126
+ type HostPrincipal = {
127
+ userId: string;
128
+ tenant: string;
129
+ };
130
+
131
+ declare enum AuthResultKind {
132
+ Code = "code"
133
+ }
134
+ type AuthResult = {
135
+ kind: AuthResultKind.Code;
136
+ code: string;
137
+ };
138
+ interface SilentAuth {
139
+ hasValidSession(): Promise<boolean>;
140
+ trySilentSignIn(): Promise<boolean>;
141
+ ensureSession(): Promise<boolean>;
142
+ rebindSession(principal: HostPrincipal): Promise<boolean>;
143
+ /**
144
+ * True when the bound-user marker is this host principal. No TTL — used by
145
+ * `AuthGate` on load so a same-principal refresh can open without running
146
+ * silent SSO. Live switch waiters still use a young-marker check inside
147
+ * `rebindSession`.
148
+ */
149
+ isBoundTo(principal: HostPrincipal): boolean;
150
+ clearBoundUser(): void;
151
+ /** @internal exposed for interactive.ts, which shares completeWithResult/parseCallbackMessage's private helpers */
152
+ completeWithResult(result: AuthResult): Promise<boolean>;
153
+ }
154
+
155
+ declare enum SignInKind {
156
+ Authenticated = "authenticated",
157
+ Redirecting = "redirecting",
158
+ Failed = "failed"
159
+ }
160
+ declare enum SignInFailureReason {
161
+ /** window.open returned null → the "allow popups" hint */
162
+ PopupBlocked = "popup-blocked",
163
+ /** signInWithOAuth errored (or returned no URL) */
164
+ OauthError = "oauth-error",
165
+ /** popup closed / timed out before a result arrived */
166
+ PopupClosed = "popup-closed",
167
+ /** a code arrived but redeeming it produced no session */
168
+ ExchangeFailed = "exchange-failed",
169
+ /** no window: SSR or a non-browser context */
170
+ Unsupported = "unsupported",
171
+ /** signInInteractive threw; the error is logged to the console */
172
+ Unexpected = "unexpected"
173
+ }
174
+ /** A session exists; the gate can open. */
175
+ interface SignInAuthenticated {
176
+ kind: SignInKind.Authenticated;
177
+ }
178
+ /**
179
+ * Top-level (non-iframe) flow: this page is navigating to the IdP. Not a
180
+ * failure — keep any "opening sign-in…" state; the document is about to unload.
181
+ */
182
+ interface SignInRedirecting {
183
+ kind: SignInKind.Redirecting;
184
+ }
185
+ /** The attempt is over; `reason` picks the user-facing message. */
186
+ interface SignInFailed {
187
+ kind: SignInKind.Failed;
188
+ reason: SignInFailureReason;
189
+ }
190
+ type SignInResult = SignInAuthenticated | SignInRedirecting | SignInFailed;
191
+
192
+ interface InteractiveAuth {
193
+ signInInteractive(): Promise<SignInResult>;
194
+ }
195
+
196
+ /** The subset of `VibeAppBridge` this module needs — structural, not a hard dependency. */
197
+ interface AuthBridgeLike {
198
+ onAuthChange(cb: (auth: {
199
+ authenticated: boolean;
200
+ userId?: string;
201
+ tenant?: string;
202
+ }) => void): () => void;
203
+ }
204
+ interface HostAuth {
205
+ isHostWired(): boolean;
206
+ waitForSeededPrincipal(timeoutMs: number): Promise<HostPrincipal | null>;
207
+ onSeededPrincipal(cb: (principal: HostPrincipal) => void): () => void;
208
+ /**
209
+ * Wire BEFORE `bridge.connect()`, at module scope so `AuthGate` sees
210
+ * `isHostWired()` on first resolve.
211
+ */
212
+ wireHostAuth(bridge: AuthBridgeLike): () => void;
213
+ /**
214
+ * Seed `{userId, tenant}` from `connect()`, from a **parent** of `AuthGate`.
215
+ */
216
+ seedLastUserId(userId: string, tenant: string): void;
217
+ /** Current seeded host `{userId, tenant}`, if any. */
218
+ getPrincipal(): HostPrincipal | undefined;
219
+ }
220
+
221
+ interface AuthGateProps {
222
+ children: ReactNode;
223
+ /** Override the default sign-in screen (branding). Defaults to `DefaultSignInScreen`. */
224
+ signInScreen?: ReactNode;
225
+ /** Override the default full-screen loader. Defaults to a bare "Signing you in…" spinner-less div. */
226
+ loader?: ReactNode;
227
+ }
228
+ interface AuthGateBundle {
229
+ AuthGate: (props: AuthGateProps) => ReactNode;
230
+ DefaultSignInScreen: () => ReactNode;
231
+ }
232
+ /**
233
+ * MUST be called synchronously, at the top of the app's own auth-setup
234
+ * module (as part of `createVibeAuth`) — same timing constraint as
235
+ * `createCallbackHandler`: `RETURNING_FROM_OAUTH`/`IS_CALLBACK_PATH` must be
236
+ * captured before any effect runs, and before supabase-js's
237
+ * `detectSessionInUrl` can strip the URL.
238
+ */
239
+ declare function createAuthGate(config: ResolvedVibeAuthConfig, silentAuth: SilentAuth, interactiveAuth: InteractiveAuth, hostAuth?: HostAuth): AuthGateBundle;
240
+
241
+ interface VibeAuth {
242
+ /** True when a Supabase session already exists locally. */
243
+ hasValidSession(): Promise<boolean>;
244
+ /**
245
+ * Fresh sign-in as a NEW host identity, coordinated across documents. Call
246
+ * this from your host-auth handler on an identity switch — NOT `ensureSession`.
247
+ * Prefer `wireHostAuth`, which already calls this correctly.
248
+ */
249
+ rebindSession(principal: HostPrincipal): Promise<boolean>;
250
+ /**
251
+ * Runs the `prompt=none` silent flow once, deduped within this document.
252
+ * Most apps want `ensureSession` (the gate calls it); this is exposed for
253
+ * advanced cases like a post-handshake retry.
254
+ */
255
+ trySilentSignIn(): Promise<boolean>;
256
+ /** Gets a session, coordinated across every document and tab on this origin. */
257
+ ensureSession(): Promise<boolean>;
258
+ /**
259
+ * The interactive (popup / top-level redirect) fallback. `AuthGate`'s
260
+ * default sign-in screen calls this. Note `redirecting` is not a failure:
261
+ * the page is navigating to the IdP and is about to unload.
262
+ */
263
+ signInInteractive(): Promise<SignInResult>;
264
+ /** Remove the identity-switch bound-user marker. Call on host logout, next to signOut(). */
265
+ clearBoundUser(): void;
266
+ /** Wrap the ENTIRE app: `<AuthGate><App /></AuthGate>`. The app never mounts until authenticated. */
267
+ AuthGate: ReturnType<typeof createAuthGate>['AuthGate'];
268
+ /** The default sign-in screen `AuthGate` renders unless you pass `signInScreen`. */
269
+ DefaultSignInScreen: ReturnType<typeof createAuthGate>['DefaultSignInScreen'];
270
+ /** Mount this, and ONLY this, at `callbackPath` — ungated, outside `AuthGate`. */
271
+ SilentCallback: ReturnType<typeof createCallbackHandler>['SilentCallback'];
272
+ /**
273
+ * Wire the host's auth signal to this Supabase client. Call at module
274
+ * scope, BEFORE `bridge.connect()`, so `AuthGate` will not open on a
275
+ * leftover Supabase session before it knows the current Nominal user.
276
+ * Returns the unsubscribe function.
277
+ */
278
+ wireHostAuth(bridge: AuthBridgeLike): () => void;
279
+ /**
280
+ * Seed `{userId, tenant}` from `connect()`. Call from a **parent** of
281
+ * `AuthGate` after `connect()` resolves. The gate waits for this, then
282
+ * opens without rebind when the bound-user marker already matches.
283
+ */
284
+ seedLastUserId(userId: string, tenant: string): void;
285
+ /** The postMessage type relayed by `SilentCallback` — do not change if migrating an existing app. */
286
+ RESULT_MESSAGE_TYPE: string;
287
+ /** The resolved callback path (default `/silent-callback`). */
288
+ callbackPath: string;
289
+ }
290
+ /**
291
+ * Creates a configured, self-contained silent-SSO auth instance for a Nominal
292
+ * Vibe App — the packaged, fixed replacement for a hand-copied
293
+ * `silentAuth.ts`/`AuthGate.tsx` generated from the silent-supabase-oidc-login
294
+ * skill.
295
+ *
296
+ * MUST be called synchronously, at the top of the app's own auth-setup
297
+ * module (e.g. `src/lib/auth.ts`), and that module must be imported eagerly
298
+ * (never lazily) — `AuthGate`/`SilentCallback` capture the callback URL at
299
+ * this call, before any React effect or async work can let supabase-js's
300
+ * `detectSessionInUrl` strip it first.
301
+ *
302
+ * @example
303
+ * ```ts
304
+ * // src/lib/auth.ts
305
+ * import { createVibeAuth } from '@nominalso/vibe-auth'
306
+ * import { supabase } from './supabaseClient'
307
+ *
308
+ * export const auth = createVibeAuth({
309
+ * supabase,
310
+ * provider: 'custom:supabase-fedapp', // from your Supabase Auth → Providers config
311
+ * })
312
+ * ```
313
+ *
314
+ * ```tsx
315
+ * // src/routes/silent-callback.tsx (TanStack Start; ssr: false on this route)
316
+ * import { auth } from '@/lib/auth'
317
+ * export default auth.SilentCallback
318
+ * ```
319
+ *
320
+ * ```tsx
321
+ * // wrap the app root — see AGENTS.md for the SSR-safe recipe
322
+ * <auth.AuthGate>
323
+ * <App />
324
+ * </auth.AuthGate>
325
+ * ```
326
+ *
327
+ * ```ts
328
+ * // wire the host bridge before connect()
329
+ * const unsub = auth.wireHostAuth(bridge)
330
+ * const ctx = await bridge.connect()
331
+ * auth.seedLastUserId(ctx.user.id, ctx.tenant)
332
+ * ```
333
+ */
334
+ declare function createVibeAuth(userConfig: VibeAuthConfig): VibeAuth;
335
+
336
+ declare const RESULT_MESSAGE_TYPE = "silent-auth-result";
337
+
338
+ 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 };