@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 ADDED
@@ -0,0 +1,101 @@
1
+ # AGENTS.md — @nominalso/vibe-auth
2
+
3
+ Instructions for AI coding agents (Claude Code, Cursor, Copilot, Lovable, etc.) integrating
4
+ this package. This is a **consumer usage guide**, not repo build config.
5
+
6
+ ## What this package is
7
+
8
+ `@nominalso/vibe-auth` is the silent-SSO login flow for a Nominal Vibe App that authenticates
9
+ through Supabase federated with the Nominal host's IdP (Descope). It replaces a hand-written
10
+ `silentAuth.ts` + `AuthGate.tsx` pair generated from the `silent-supabase-oidc-login` skill.
11
+ **If the app has such files, migrate them to this package — do not hand-write this flow.**
12
+ Follow the skill's Step 1b (delete the hand-written files; keep `provider`, callback path, and
13
+ the existing Supabase client).
14
+
15
+ ## The one rule
16
+
17
+ Call `createVibeAuth(...)` **once**, **synchronously**, at the top of its own module
18
+ (conventionally `src/lib/auth.ts`), and import that module **eagerly** — never lazily, never
19
+ inside a component or a conditional. `AuthGate`/`SilentCallback` capture the callback URL at
20
+ that call, before any React effect or async work lets supabase-js's `detectSessionInUrl`
21
+ strip it first.
22
+
23
+ ```ts
24
+ // src/lib/auth.ts
25
+ import { createVibeAuth } from '@nominalso/vibe-auth'
26
+ import { supabase } from './supabaseClient'
27
+
28
+ export const auth = createVibeAuth({
29
+ supabase,
30
+ provider: 'custom:supabase-fedapp', // ask the user for this — never guess it
31
+ })
32
+ ```
33
+
34
+ ## The three wiring points
35
+
36
+ 1. **The callback route.** Mount `auth.SilentCallback` at `auth.callbackPath` (default
37
+ `/silent-callback`), and ONLY that — it must render ungated, outside `AuthGate`. SSR
38
+ frameworks: `ssr: false` (or equivalent) on this route, and the root layout must not
39
+ render the app's provider tree there (see **Root wiring** below).
40
+ 2. **The app root.** Wrap everything in `<auth.AuthGate>`. The app renders nothing of itself
41
+ until authenticated.
42
+ 3. **The host bridge**, if embedded via `@nominalso/vibe-bridge`. Call
43
+ `auth.wireHostAuth(bridge)` at module scope BEFORE `bridge.connect()`, then
44
+ `auth.seedLastUserId(ctx.user.id, ctx.tenant)` once `connect()` resolves —
45
+ from a **parent** of `AuthGate`, not inside gated children.
46
+
47
+ ## Root wiring (SSR — React #418)
48
+
49
+ `AuthGate` / `SilentCallback` defer `window` reads to an effect. Your **root layout** must
50
+ not decide "is this the callback?" with `typeof window !== 'undefined' && window.location…`
51
+ at render time: the server always sees `window` missing and renders the gated app; the
52
+ client's first pass on `/silent-callback` wants a bare outlet. That subtree mismatch is
53
+ React #418, and it can fire `bridge.connect()` inside a popup/hidden iframe.
54
+
55
+ Ask the **router** for the path (identical on server and client):
56
+
57
+ ```tsx
58
+ // TanStack Start — Next.js: usePathname() from next/navigation in a Client Component
59
+ const pathname = useRouterState({ select: (s) => s.location.pathname })
60
+ if (pathname === auth.callbackPath) return <Outlet />
61
+ return (
62
+ <auth.AuthGate>
63
+ <App />
64
+ </auth.AuthGate>
65
+ )
66
+ ```
67
+
68
+ ```tsx
69
+ export const Route = createFileRoute('/silent-callback')({
70
+ ssr: false,
71
+ component: auth.SilentCallback,
72
+ })
73
+ ```
74
+
75
+ ## Do NOT
76
+
77
+ - Do not create your own `signInWithOAuth`/`exchangeCodeForSession` calls anywhere in the app
78
+ — every code path that can create a session goes through this package.
79
+ - Do not add a `signOut()` call on a failed `ensureSession()` — a failed silent attempt is not
80
+ proof the user is logged out (see README's security-boundary section).
81
+ - Do not read `window.location` at component render time to decide whether the current route
82
+ is the callback route — use **Root wiring** above.
83
+ - Do not branch on `typeof window !== 'undefined'` around this package's exports — they are
84
+ already SSR-safe.
85
+
86
+ ## Host identity
87
+
88
+ The gate waits for a seeded `{userId, tenant}` before opening. A leftover Supabase session
89
+ is not enough. Unmigrated sibling documents may still write `{userId, at}` markers (no
90
+ `tenant`); this app will not treat those as a completed rebind and will rerun silent SSO
91
+ under the shared Web Lock — safe, just not a skip.
92
+
93
+ Same-user **tenant** switches rebind. If the callback-document race loses and the leftover
94
+ session is the same Supabase user, adoption cannot tell "fresh" from "old" and fail-closes
95
+ (`signOut` + sign-in screen). That is intentional.
96
+
97
+ ## Configuration reference
98
+
99
+ See `VibeAuthConfig` in the package's `.d.ts` for every option (including timeouts). The two
100
+ REQUIRED fields are `supabase` (your own client, PKCE-configured) and `provider` (from the
101
+ app's Supabase Auth → Providers setup — ask the user, never guess).
package/LICENSE ADDED
@@ -0,0 +1,10 @@
1
+ Copyright © 2026 Nominal. All rights reserved.
2
+
3
+ This software and its source code are proprietary and confidential.
4
+ The packages published from this repository are marked "UNLICENSED": no
5
+ license or right to use, copy, modify, distribute, or create derivative
6
+ works is granted except under a separate written agreement with Nominal.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
9
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
10
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # @nominalso/vibe-auth
2
+
3
+ Silent Supabase OIDC login for **Nominal Vibe Apps** — a configurable, cross-document-safe
4
+ auth gate that replaces a hand-copied per-app implementation of the
5
+ [`silent-supabase-oidc-login`](../../standalone-agents/skills/silent-supabase-oidc-login/SKILL.md)
6
+ skill. If your app authenticates through Supabase federated with the Nominal host's IdP
7
+ (Descope), this package IS that flow — written once, fixed once, and configurable per app.
8
+
9
+ > **For AI agents / Lovable:** see [`AGENTS.md`](./AGENTS.md) for the condensed integration
10
+ > guide (including the SSR root-wiring recipe). To replace a hand-written
11
+ > `silentAuth.ts`/`AuthGate.tsx` pair, follow the `silent-supabase-oidc-login` skill Step 1b.
12
+
13
+ ## Why this exists
14
+
15
+ Six generated apps each carried a hand-copied version of this login flow. Five rounds of bug
16
+ fixes had to be re-applied per app, and the copies drifted: one app grew a React hydration
17
+ crash in its root route, three ended up mounting bridge providers inside the auth callback
18
+ document, and all of them still carried a production bug where a stale session's failed
19
+ boot-time refresh briefly flashed the sign-in screen. This package is that logic, fixed and
20
+ written once.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ npm install @nominalso/vibe-auth
26
+ ```
27
+
28
+ Peer dependencies: `react >=18`, `@supabase/supabase-js ^2`.
29
+
30
+ ## Quickstart
31
+
32
+ ```ts
33
+ // src/lib/auth.ts — called synchronously, at module scope, imported eagerly.
34
+ import { createVibeAuth } from '@nominalso/vibe-auth'
35
+ import { supabase } from './supabaseClient' // YOUR existing client — this package never creates one
36
+
37
+ export const auth = createVibeAuth({
38
+ supabase,
39
+ provider: 'custom:supabase-fedapp', // from your Supabase Auth → Providers config
40
+ })
41
+ ```
42
+
43
+ ```tsx
44
+ // the callback route — mount ONLY this, ungated, at the callback path
45
+ // (default '/silent-callback'; see AGENTS.md for the SSR-safe recipe)
46
+ import { auth } from '@/lib/auth'
47
+ export default auth.SilentCallback
48
+ ```
49
+
50
+ ```tsx
51
+ // wrap the whole app — it never renders until authenticated
52
+ import { auth } from '@/lib/auth'
53
+
54
+ function Root() {
55
+ return (
56
+ <auth.AuthGate>
57
+ <App />
58
+ </auth.AuthGate>
59
+ )
60
+ }
61
+ ```
62
+
63
+ ```ts
64
+ // wire the Nominal host bridge, before connect()
65
+ import { auth } from '@/lib/auth'
66
+ import { bridge } from './bridge'
67
+
68
+ const unsub = auth.wireHostAuth(bridge)
69
+ const ctx = await bridge.connect()
70
+ auth.seedLastUserId(ctx.user.id, ctx.tenant)
71
+ ```
72
+
73
+ See [`AGENTS.md`](./AGENTS.md) for SSR root wiring and host `seedLastUserId` rules.
74
+ Timeouts live on `VibeAuthTimeouts` in the package `.d.ts`.
75
+
76
+ ## Why a client you own, not one this package creates
77
+
78
+ Your `client.ts` is often Lovable-generated (marked "do not edit"), carries your generated
79
+ `Database` types, and may have its own storage adapter (e.g. for Lovable preview brokering).
80
+ This package only requires it: at `createVibeAuth()`, it best-effort-checks
81
+ `auth.flowType === 'pkce'` and logs a loud `console.error` if it isn't — PKCE is a **security
82
+ control**, not a preference (the `.d.ts` on `VibeAuthConfig` explains why).
83
+
84
+ ## What you get
85
+
86
+ - `ensureSession()` / `rebindSession({ userId, tenant })` — the cross-document-safe core (Web Lock
87
+ serialised, terminal-capped, sibling-adopting). Most apps never call these directly; the
88
+ gate and `wireHostAuth` do.
89
+ - `AuthGate` / `DefaultSignInScreen` / `SilentCallback` — the three React pieces. Override
90
+ `signInScreen`/`loader` props on `AuthGate` for branding.
91
+ - `wireHostAuth(bridge)` / `seedLastUserId(userId, tenant)` — the Nominal-host integration (logout,
92
+ identity/tenant switch). Seed from a parent of `AuthGate` after `connect()`.
93
+ - Fully configurable timeouts (`VibeAuthTimeouts` in the `.d.ts`) — every value defaults to
94
+ what shipped after this flow's production incidents.
95
+
96
+ ## Security boundary
97
+
98
+ `signOut()` calls made by this package (on host logout, on a failed identity-switch rebind)
99
+ are **client-side, best-effort UX**: they stop rendering the app and drop the local session,
100
+ but a persisted Supabase access token stays technically valid until it expires, and only this
101
+ browser is affected. **Enforced logout must happen server-side** — OIDC back-channel logout
102
+ revoking the Supabase refresh tokens (Supabase Admin API, per project). That back-channel
103
+ receiver is infrastructure outside this package.