@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.
- package/AGENTS.md +101 -0
- package/LICENSE +10 -0
- package/README.md +103 -0
- package/dist/index.cjs +725 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +338 -0
- package/dist/index.d.ts +338 -0
- package/dist/index.js +695 -0
- package/dist/index.js.map +1 -0
- package/llms.txt +18 -0
- package/package.json +56 -0
package/dist/index.d.ts
ADDED
|
@@ -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 };
|