@bffless/skills 1.8.0 → 1.8.2

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.
@@ -5,7 +5,7 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "BFFless platform skills",
8
- "version": "1.8.0"
8
+ "version": "1.8.2"
9
9
  },
10
10
  "plugins": [
11
11
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bffless/skills",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
4
4
  "description": "BFFless platform skills — usable with Claude Code or any agent via the `skills` CLI",
5
5
  "keywords": [
6
6
  "skills",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bffless",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
4
4
  "description": "BFFless platform skills for Claude Code — deployments, pipelines, proxy rules, chat, traffic splitting, and more",
5
5
  "author": {
6
6
  "name": "BFFless"
@@ -5,46 +5,57 @@ description: Cross-domain authentication using the admin login relay pattern, bu
5
5
 
6
6
  # Authentication
7
7
 
8
- BFFless uses a cross-domain authentication relay pattern. Users authenticate at the workspace's admin domain (`admin.<workspace>`) and are relayed back to the content domain with auth cookies. Auth endpoints on the content domain are accessed via the **built-in `/_bffless/auth/*` endpoints** — no proxy rules required.
8
+ BFFless authenticates users on a single admin host (`admin.<primary-domain>`) and reuses that session across every site it serves. For the common case — a primary domain plus its subdomains — the SuperTokens session cookie (`sAccessToken`) is shared on `.<primary-domain>` and works directly. For the edge case of additional cross-origin custom domains, BFFless relays the session into a per-domain `bffless_access` JWT via a one-time bounce through the admin. Both cases expose the same content-side surface at `/_bffless/auth/*` — built into BFFless nginx, no proxy rules required.
9
9
 
10
10
  ## How Authentication Works
11
11
 
12
- ### Workspace Subdomains
12
+ ### The Common Case: Primary Domain (+ Subdomains)
13
13
 
14
- For workspace subdomains (e.g., `myalias.sandbox.workspace.bffless.app`), SuperTokens session cookies (`sAccessToken`) work directly because they share the parent domain.
14
+ A self-hosted BFFless install has **one primary domain** (e.g., `foo.com` — the root you pick during setup). The admin lives at `admin.foo.com` and content can be served from `foo.com` itself or any subdomain (`bar.foo.com`, `app.foo.com`, etc.). Almost every install runs entirely in this mode.
15
15
 
16
- When a user visits a private deployment and isn't authenticated:
16
+ All of these share `.foo.com` as a parent, so the SuperTokens session cookie (`sAccessToken`) reaches every one of them automatically. There is no `bffless_access` cookie in this mode — the session is always validated against `sAccessToken`. (BFFless multi-tenant hosting at `*.workspace.bffless.app` is mechanically the same setup, with `workspace.bffless.app` playing the role of the primary domain.)
17
17
 
18
- 1. Backend redirects to `https://admin.<workspace>/login?redirect=<original-path>&tryRefresh=true`
19
- 2. The login page attempts a session refresh first (the `tryRefresh` param)
18
+ When a user hits a private deployment on the primary domain and isn't authenticated:
19
+
20
+ 1. Backend redirects to `https://admin.foo.com/login?redirect=<original-path>&tryRefresh=true`
21
+ 2. The login page tries a session refresh first (the `tryRefresh` param), in case the cookie is just expired
20
22
  3. If refresh fails, the user logs in normally
21
23
  4. After login, the user is redirected back to the original path
22
- 5. The `sAccessToken` cookie is valid across all subdomains of the workspace
24
+ 5. The `sAccessToken` cookie now travels with every request to either the content or admin host
25
+
26
+ ### Edge Case: Additional Cross-Origin Custom Domains (`customDomainRelay`)
27
+
28
+ A single BFFless install can also serve content from **additional registered domains** that aren't under the primary — e.g., attaching `bat.com` to a `foo.com` install. This is uncommon for OSS users; reach for it only when you genuinely need to serve content from a separately-owned root domain.
23
29
 
24
- ### Custom Domains (customDomainRelay)
30
+ Because `bat.com` and `foo.com` are different origins, the SuperTokens cookie can't reach `bat.com`. SuperTokens itself has no multi-domain cookie support, so BFFless mints its own short-lived JWT and sets it as `bffless_access` on `bat.com` via a one-time relay through `admin.foo.com`. The `bffless_access` cookie **only exists on these cross-origin custom domains** — it is never set on the primary or any of its subdomains.
25
31
 
26
- For custom domains (e.g., `www.bffless.com`), SuperTokens cookies don't work because they're on a completely different domain. BFFless uses a **domain relay** flow:
32
+ The relay flow:
27
33
 
28
- 1. User visits a private page on `www.bffless.com/portal/`
29
- 2. Frontend detects the user is not authenticated (via `/_bffless/auth/session`)
34
+ 1. User visits a private page on `bat.com/portal/`
35
+ 2. Frontend detects no auth (via `/_bffless/auth/session`)
30
36
  3. Frontend redirects to the admin login with relay params:
31
37
  ```
32
- https://admin.console.bffless.app/login?customDomainRelay=true&targetDomain=www.bffless.com&redirect=%2Fportal%2F
38
+ https://admin.foo.com/login?customDomainRelay=true&targetDomain=bat.com&redirect=%2Fportal%2F
33
39
  ```
34
40
  4. User logs in on the admin domain (or is already logged in via SuperTokens session)
35
41
  5. After login, the frontend calls `POST /api/auth/domain-token` with:
36
42
  ```json
37
- { "targetDomain": "www.bffless.com", "redirectPath": "/portal/" }
43
+ { "targetDomain": "bat.com", "redirectPath": "/portal/" }
38
44
  ```
39
- 6. Backend validates that `targetDomain` is a registered domain for this workspace, then creates a short-lived JWT (the "domain token")
40
- 7. Backend returns a `redirectUrl` pointing to the callback on the custom domain: `https://www.bffless.com/_bffless/auth/callback?token=...&redirect=/portal/`
45
+ 6. Backend validates that `targetDomain` is a registered domain for this workspace, then mints a short-lived JWT (the "domain token")
46
+ 7. Backend returns a `redirectUrl` pointing to the callback on the content domain: `https://bat.com/_bffless/auth/callback?token=...&redirect=/portal/`
41
47
  8. The callback endpoint validates the token, sets `bffless_access` and `bffless_refresh` HttpOnly cookies, and redirects to the original path
42
48
 
43
- ### Important: Use `/_bffless/auth/*`, NOT `/api/auth/*`
49
+ ### Default: Use `/_bffless/auth/*`
50
+
51
+ The `/_bffless/auth/*` endpoints are **built into BFFless nginx** and handled by a dedicated controller — they work on every domain without any configuration. They are separate from the SuperTokens `/api/auth/*` endpoints (which only exist on the admin host).
52
+
53
+ Reach for `/_bffless/auth/*` first. The two situations where it isn't enough are:
44
54
 
45
- The `/_bffless/auth/*` endpoints are **built into BFFless nginx** and handled by a dedicated controller. They are separate from the SuperTokens `/api/auth/*` endpoints. Do NOT use `/api/auth/*` on custom domains those are SuperTokens endpoints that use different cookies (`sAccessToken`) which are not set by the domain relay flow.
55
+ - You need to **clear the SuperTokens session** (true logout) on the primary domain or one of its subdomains.
56
+ - You need an endpoint not in the built-in surface (OAuth start/callback, `session/refresh`, etc.).
46
57
 
47
- The domain relay callback sets `bffless_access` and `bffless_refresh` cookies, which are only recognized by the `/_bffless/auth/*` endpoints. Using `/api/auth/session` instead of `/_bffless/auth/session` will cause a redirect loop because the SuperTokens session check won't find the `bffless_access` cookie.
58
+ For those, set up the [reverse-proxy rule](#advanced-reverse-proxy-to-supertokens-endpoints) and call the proxied `/auth/*` path. The proxy only works on the primary domain (the SuperTokens cookie has to reach the request). On an additional cross-origin custom domain like `bat.com`, stick with `/_bffless/auth/*`.
48
59
 
49
60
  ## Auth Endpoints (Built-in)
50
61
 
@@ -52,20 +63,113 @@ All auth endpoints are available at `/_bffless/auth/*` on any domain served by B
52
63
 
53
64
  | Endpoint | Method | Purpose |
54
65
  | --------------------------- | ------ | -------------------------------------------------------- |
55
- | `/_bffless/auth/session` | GET | Check current session (returns user info or 401) |
66
+ | `/_bffless/auth/session` | GET | Check current session see response shape below |
56
67
  | `/_bffless/auth/refresh` | POST | Refresh an expired access token using the refresh cookie |
57
68
  | `/_bffless/auth/callback` | GET | Exchange a domain relay token for auth cookies |
58
- | `/_bffless/auth/logout` | POST | Clear auth cookies |
69
+ | `/_bffless/auth/logout` | POST | Clear `bffless_access` / `bffless_refresh` cookies (does NOT clear SuperTokens session — see [Advanced](#advanced-reverse-proxy-to-supertokens-endpoints)) |
70
+ | `/_bffless/auth/signin` | POST | In-page email+password sign-in (mints `bffless_access`) |
71
+ | `/_bffless/auth/signup` | POST | In-page email+password sign-up |
72
+ | `/_bffless/auth/forgot-password` | POST | Trigger password-reset email |
73
+ | `/_bffless/auth/reset-password` | POST | Complete password reset with token |
74
+ | `/_bffless/auth/verify-email` | POST | Verify email with token |
75
+ | `/_bffless/auth/send-verification-email` | POST | Resend the verification email |
76
+ | `/_bffless/auth/login-methods` | GET | Enabled auth providers / signup gates |
77
+ | `/_bffless/auth/check-email` | POST | Test if an email exists in the workspace |
78
+
79
+ ### Session Endpoint Response Shape
80
+
81
+ `GET /_bffless/auth/session` has **three** possible outcomes — make sure your client distinguishes all three:
82
+
83
+ | Outcome | Status | Body | Meaning |
84
+ | ------- | ------ | ---- | ------- |
85
+ | Logged in | `200` | `{ "authenticated": true, "user": { id, email, role } }` | Use the user object |
86
+ | **Guest** | **`200`** | **`{ "authenticated": false, "user": null }`** | **Not logged in — do NOT trust `res.ok` alone** |
87
+ | Expired | `401` | `"try refresh token"` | Call `/_bffless/auth/refresh`, then retry session |
88
+
89
+ **Common bug**: writing `if (res.ok) return res.json()` and treating guests as authenticated. The body's `authenticated` field is the source of truth, not the HTTP status.
59
90
 
60
91
  ### Session Check Priority
61
92
 
62
93
  The `/_bffless/auth/session` endpoint checks auth in this order:
63
94
 
64
- 1. **`bffless_access` cookie** — custom domain JWT issued by the callback flow
65
- 2. **`sAccessToken` cookie** — SuperTokens session (fallback for workspace subdomains)
95
+ 1. **`bffless_access` cookie** — domain-relay JWT issued by the callback flow (cross-origin custom domains)
96
+ 2. **`sAccessToken` cookie** — SuperTokens session (fallback for shared-parent topologies: primary domain & enterprise workspace subdomains)
66
97
 
67
98
  If the access token is expired, it returns `401` with `"try refresh token"` to signal the client should call `/_bffless/auth/refresh`.
68
99
 
100
+ ## Advanced: Reverse-Proxy to SuperTokens Endpoints
101
+
102
+ The built-in `/_bffless/auth/*` controller covers the read path (`session`) and the in-page sign-in / sign-up / password-reset flows, but it is **not a complete proxy to the underlying SuperTokens routes**. Two things specifically are missing:
103
+
104
+ 1. **It can't clear the SuperTokens session.** `/_bffless/auth/logout` only deletes the `bffless_access` / `bffless_refresh` cookies that were minted by the domain-relay callback. The real `sAccessToken` cookie lives on the parent admin domain (`.bffless.app`, `.yourdomain.com`) and is managed by SuperTokens' `signOut()`. There's no built-in endpoint on the content domain that can revoke it.
105
+ 2. **It exposes a curated subset of endpoints.** OAuth callbacks, provider lists (`/api/auth/oauth/*`), `session/refresh` (the SuperTokens-format refresh, distinct from `/_bffless/auth/refresh`), and a few other internal routes are only available under the admin's `/api/auth/*` namespace.
106
+
107
+ When you need any of these from a content domain, set up a **reverse proxy rule** from a prefix path on the content domain to the admin backend's `/api/auth` namespace. This is the same pattern the admin UI itself uses.
108
+
109
+ ### The Proxy Rule (canonical example)
110
+
111
+ For a workspace whose admin lives at `admin.<workspace>`, add an **External Proxy** rule to the content alias:
112
+
113
+ | Field | Value |
114
+ | --------------------------- | ---------------------------------------------- |
115
+ | Path Pattern | `/auth/*` (or `/api/auth/*` — choose one) |
116
+ | Method | Any |
117
+ | Rule Type | External Proxy |
118
+ | Target URL | `http://localhost:3000/api/auth` (same-instance backend) **or** `https://admin.<workspace>/api/auth` (cross-instance) |
119
+ | Strip matched path prefix | ON |
120
+ | Preserve original Host | OFF |
121
+ | Forward cookies to target | **ON** (required — the session cookie has to travel with the request) |
122
+
123
+ `localhost:3000` is the internal CE backend on the same node; BFFless allows HTTP targets only for `*.svc` / `localhost`. For cross-instance setups, point at the admin's HTTPS URL.
124
+
125
+ With the rule above:
126
+
127
+ ```
128
+ GET j5s.dev/auth/session → http://localhost:3000/api/auth/session
129
+ POST j5s.dev/auth/signout → http://localhost:3000/api/auth/signout
130
+ GET j5s.dev/auth/oauth/... → http://localhost:3000/api/auth/oauth/...
131
+ ```
132
+
133
+ ### Response Shape Differs From `_bffless/auth/session`
134
+
135
+ The proxied SuperTokens session endpoint returns a **richer object** than the BFFless one — both `emailVerified` and the session handle, with `user: null` instead of `authenticated: false` for guests:
136
+
137
+ ```json
138
+ // GET /auth/session (proxied to /api/auth/session)
139
+ {
140
+ "session": { "userId": "...", "handle": "..." },
141
+ "user": { "id": "...", "email": "...", "role": "admin" },
142
+ "emailVerified": true,
143
+ "emailVerificationRequired": false
144
+ }
145
+ ```
146
+
147
+ Compare with the BFFless built-in (covered above):
148
+
149
+ ```json
150
+ // GET /_bffless/auth/session
151
+ { "authenticated": true, "user": { "id": "...", "email": "...", "role": "admin" } }
152
+ ```
153
+
154
+ Pick one shape per client and stick with it; mixing causes the same "treated guest as authed" bug described earlier. If you need `emailVerified` on the content domain, use the proxied endpoint.
155
+
156
+ ### When to Use Each
157
+
158
+ | Need | Use |
159
+ | ---------------------------------------------------- | ---------------------------------------------------------- |
160
+ | Cheap session check, no SuperTokens dep | `/_bffless/auth/session` |
161
+ | In-page sign-in / sign-up / forgot-password dialog | `/_bffless/auth/signin` etc. (works on true custom domains too) |
162
+ | Custom-domain relay callback | `/_bffless/auth/callback` (built-in, can't be proxied) |
163
+ | **Clearing the SuperTokens session** (real logout) | Proxied `/auth/signout` **or** bounce through `admin.<workspace>/logout` (see [Logout](#logout)) |
164
+ | OAuth / SSO flows started from the content domain | Proxied `/auth/oauth/*` |
165
+ | `emailVerified`, session handle, pending invitations | Proxied `/auth/session` |
166
+
167
+ ### Caveat: This Only Works When the Cookie Reaches the Proxy
168
+
169
+ The reverse-proxy approach depends on the browser sending the SuperTokens session cookie to the content domain so BFFless can forward it. That works on the **primary domain and its subdomains** (`foo.com`, `bar.foo.com`, …) because `sAccessToken` is on `.foo.com`. It also works on `*.workspace.bffless.app` since that is mechanically the same setup.
170
+
171
+ It does **not** work on additional cross-origin custom domains (a `bat.com` attached to a `foo.com` install): the `sAccessToken` cookie never reaches `bat.com`, so the proxy has nothing to forward. Use `_bffless/auth/*` + the admin-bounce logout on those.
172
+
69
173
  ## Frontend Integration
70
174
 
71
175
  ### Checking Session (with automatic token refresh)
@@ -73,44 +177,54 @@ If the access token is expired, it returns `401` with `"try refresh token"` to s
73
177
  Use a shared promise pattern to avoid duplicate session checks across components:
74
178
 
75
179
  ```typescript
76
- async function checkSession() {
180
+ type Session =
181
+ | { authenticated: true; user: { id: string; email?: string; role?: string } }
182
+ | { authenticated: false };
183
+
184
+ async function checkSession(): Promise<Session> {
77
185
  // Reuse shared session promise so multiple components don't duplicate requests
78
186
  if (!(window as any).__bfflessSession) {
79
- (window as any).__bfflessSession = (async () => {
80
- const res = await fetch('/_bffless/auth/session', { credentials: 'include' });
81
- if (res.ok) return res.json();
187
+ (window as any).__bfflessSession = (async (): Promise<Session> => {
188
+ const get = () => fetch('/_bffless/auth/session', { credentials: 'include' });
82
189
 
190
+ let res = await get();
83
191
  if (res.status === 401) {
84
- // Token expired — try refreshing
192
+ // Token expired — try refreshing, then retry the session check
85
193
  const refreshRes = await fetch('/_bffless/auth/refresh', {
86
194
  method: 'POST',
87
195
  credentials: 'include',
88
196
  });
89
- if (refreshRes.ok) {
90
- // Retry session check with new token
91
- const retryRes = await fetch('/_bffless/auth/session', { credentials: 'include' });
92
- if (retryRes.ok) return retryRes.json();
93
- }
197
+ if (refreshRes.ok) res = await get();
94
198
  }
95
- return null;
96
- })().catch(() => null);
199
+
200
+ if (!res.ok) return { authenticated: false };
201
+
202
+ // IMPORTANT: a 200 can still be a guest — the body decides.
203
+ const body = await res.json();
204
+ if (body?.authenticated === false || body?.user == null) {
205
+ return { authenticated: false };
206
+ }
207
+ return { authenticated: true, user: body.user ?? body };
208
+ })().catch(() => ({ authenticated: false }) as Session);
97
209
  }
98
210
 
99
211
  return (window as any).__bfflessSession;
100
212
  }
101
-
102
- // Returns: { authenticated: true, user: { id, email, role } } or null
103
213
  ```
104
214
 
105
- The flow is: session check → if 401, refresh tokenretry session check. This handles the common case where the access token has expired but the refresh token is still valid.
215
+ The flow is: session check → if 401, refresh and retry inspect `body.authenticated`. Do NOT treat any 200 as authenticated; the guest response is also a 200 (see the response shape table above).
106
216
 
107
217
  ### Redirecting to Login
108
218
 
109
- When unauthenticated, redirect the user to the admin login with relay params. Use the **promoted admin domain** (e.g., `admin.console.bffless.app`), not the full workspace subdomain:
219
+ When unauthenticated, redirect the user to the admin on the **primary domain** (e.g., `admin.foo.com`):
110
220
 
111
221
  ```typescript
112
222
  function getLoginUrl(adminLoginUrl: string, redirectPath: string): string {
113
- const targetDomain = window.location.hostname;
223
+ // Use `host`, NOT `hostname` — host includes the port (e.g. `localhost:5173`).
224
+ // Using `hostname` strips the port, and the backend builds a callback URL
225
+ // like `https://localhost/_bffless/auth/callback?...` (no port, wrong scheme)
226
+ // which is unreachable in local dev.
227
+ const targetDomain = window.location.host;
114
228
  const params = new URLSearchParams({
115
229
  customDomainRelay: 'true',
116
230
  targetDomain,
@@ -119,11 +233,11 @@ function getLoginUrl(adminLoginUrl: string, redirectPath: string): string {
119
233
  return `${adminLoginUrl}?${params.toString()}`;
120
234
  }
121
235
 
122
- // Example: redirect to admin login, then relay back to /portal/
236
+ // Example: redirect to admin login on the primary domain, then relay back to /portal/
123
237
  const session = await checkSession();
124
238
  if (!session) {
125
239
  window.location.href = getLoginUrl(
126
- 'https://admin.console.bffless.app/login',
240
+ 'https://admin.foo.com/login',
127
241
  '/portal/',
128
242
  );
129
243
  }
@@ -131,10 +245,38 @@ if (!session) {
131
245
 
132
246
  ### Logout
133
247
 
248
+ Logout is symmetric to login: the admin host owns the SuperTokens session, so on the primary domain (and its subdomains) you have to bounce through `admin.foo.com/logout` to actually revoke. Calling `/_bffless/auth/logout` on its own is **not enough** — it only clears `bffless_access`, which isn't even set on the primary domain. See the dedicated troubleshooting entry below.
249
+
250
+ > **Alternative for the common case:** if you've configured the [reverse-proxy rule](#advanced-reverse-proxy-to-supertokens-endpoints) (e.g., `/auth/*` → admin `/api/auth`), you can `POST /auth/signout` directly from the content domain instead of bouncing through the admin page. The proxy forwards the SuperTokens session cookie and SuperTokens clears it on `.foo.com`. The admin-bounce below is the universal fallback (and the only option on additional cross-origin custom domains, where the proxy can't reach the cookie).
251
+
134
252
  ```typescript
135
- await fetch('/_bffless/auth/logout', { method: 'POST', credentials: 'include' });
253
+ async function logout(adminLogoutUrl: string) {
254
+ // 1. Clear the bffless_access / bffless_refresh cookies that live on this
255
+ // domain. No-op on the primary domain (those cookies are never set
256
+ // there), but required for additional cross-origin custom domains.
257
+ try {
258
+ await fetch('/_bffless/auth/logout', {
259
+ method: 'POST',
260
+ credentials: 'include',
261
+ });
262
+ } catch {
263
+ // ignore — the admin bounce below is the source of truth
264
+ }
265
+
266
+ // 2. Bounce through the admin logout page so SuperTokens revokes the
267
+ // session and clears `sAccessToken` on the parent domain. The admin
268
+ // page validates `redirect` (same base-domain only) and sends the
269
+ // user back.
270
+ const redirect = window.location.origin + window.location.pathname;
271
+ window.location.href = `${adminLogoutUrl}?redirect=${encodeURIComponent(redirect)}`;
272
+ }
273
+
274
+ // Example:
275
+ // logout('https://admin.foo.com/logout');
136
276
  ```
137
277
 
278
+ Mirrors the login flow — same admin URL pattern, just `/logout` instead of `/login`. If you derive both URLs from a single env var, do it explicitly rather than munging the login URL with regex, so the intent is obvious to the next reader.
279
+
138
280
  ### Updating UI Based on Auth State (Header example)
139
281
 
140
282
  ```typescript
@@ -151,15 +293,96 @@ window.__bfflessSession.then((data) => {
151
293
  });
152
294
  ```
153
295
 
296
+ ## Local Development
297
+
298
+ There is no auth backend running on `localhost`, so `/_bffless/auth/*` 404s out of the box. There are two patterns for working around this:
299
+
300
+ ### 1. Proxy `/_bffless` to a deployed workspace (real auth)
301
+
302
+ Best when you want to exercise the real cookie/relay flow. Configure your dev server to proxy `/_bffless` (and usually `/api`) to a real BFFless deployment:
303
+
304
+ ```ts
305
+ // vite.config.ts
306
+ export default defineConfig({
307
+ server: {
308
+ proxy: {
309
+ '/api': { target: 'https://foo.com', changeOrigin: true },
310
+ '/_bffless': { target: 'https://foo.com', changeOrigin: true },
311
+ },
312
+ },
313
+ });
314
+ ```
315
+
316
+ Caveats:
317
+ - Login redirects you to the admin domain. The `targetDomain` you send must be a registered domain in that workspace — `localhost:5173` will get rejected with "Domain not registered" unless an admin adds it (or you point at a workspace that does). Most teams add their dev host to a sandbox workspace's domain mappings for this purpose.
318
+ - The session endpoint will return the **guest** shape (`200 { authenticated: false, user: null }`) until you complete the login + callback round trip, which is why the body-inspection pattern above is required.
319
+
320
+ ### 2. Mock `/_bffless/auth/*` with MSW (no backend)
321
+
322
+ Best when you want to iterate on auth-gated UI without leaving localhost. [MSW](https://mswjs.io) intercepts at the service-worker layer so the production `fetch` calls stay untouched:
323
+
324
+ ```ts
325
+ // src/mocks/handlers.ts
326
+ import { http, HttpResponse, passthrough } from 'msw';
327
+
328
+ const STORAGE_KEY = 'bffless:mockAuth';
329
+
330
+ function readMock() {
331
+ try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}'); }
332
+ catch { return {}; }
333
+ }
334
+
335
+ export const handlers = [
336
+ http.get('/_bffless/auth/session', () => {
337
+ const m = readMock();
338
+ if (!m.enabled) return passthrough();
339
+ if (!m.authenticated) {
340
+ return HttpResponse.json({ authenticated: false, user: null });
341
+ }
342
+ return HttpResponse.json({ authenticated: true, user: m.user });
343
+ }),
344
+ http.post('/_bffless/auth/refresh', () => {
345
+ const m = readMock();
346
+ if (!m.enabled) return passthrough();
347
+ return new HttpResponse(null, { status: m.authenticated ? 200 : 401 });
348
+ }),
349
+ http.post('/_bffless/auth/logout', () => {
350
+ const m = readMock();
351
+ if (!m.enabled) return passthrough();
352
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...m, authenticated: false }));
353
+ return new HttpResponse(null, { status: 204 });
354
+ }),
355
+ ];
356
+ ```
357
+
358
+ Then boot the worker only in dev, before render:
359
+
360
+ ```ts
361
+ // src/main.tsx
362
+ async function enableMocks() {
363
+ if (!import.meta.env.DEV) return;
364
+ const { setupWorker } = await import('msw/browser');
365
+ const { handlers } = await import('./mocks/handlers');
366
+ await setupWorker(...handlers).start({ onUnhandledRequest: 'bypass' });
367
+ }
368
+ enableMocks().then(() => { /* createRoot(...).render(...) */ });
369
+ ```
370
+
371
+ Add a small dev-only panel (rendered when `import.meta.env.DEV`) that writes `{ enabled, authenticated, user }` to localStorage and dispatches a `CustomEvent` so your session hook can refetch. With this setup you can toggle authed/guest and swap user attributes live without restarting the dev server.
372
+
373
+ Caveats:
374
+ - Returning `passthrough()` when `enabled === false` lets you fall back to a proxied real backend (pattern #1) on demand.
375
+ - MSW requires `public/mockServiceWorker.js`; generate it once with `npx msw init public/ --save`.
376
+
154
377
  ## Auth Flow Diagram
155
378
 
156
379
  ```
157
- Custom Domain Flow:
380
+ Additional Custom Domain Flow (edge case):
158
381
  ┌──────────────────┐ JS redirect ┌──────────────────────────┐
159
- www.bffless.com │ ──────────────────→ │ admin.<workspace>/login
382
+ bat.com │ ──────────────────→ │ admin.foo.com/login
160
383
  │ (private page) │ customDomainRelay= │ ?customDomainRelay=true │
161
- │ │ true&targetDomain= │ &targetDomain=www...
162
- └──────────────────┘ www.bffless.com └────────────┬─────────────┘
384
+ │ │ true&targetDomain= │ &targetDomain=bat.com
385
+ └──────────────────┘ bat.com └────────────┬─────────────┘
163
386
  ▲ │
164
387
  │ User logs in (SuperTokens)
165
388
  │ │
@@ -169,7 +392,7 @@ Custom Domain Flow:
169
392
  │ │
170
393
  │ 302 redirect │
171
394
  │ ←─────────────────────────────────────────────┘
172
- │ to: www.bffless.com/_bffless/auth/callback?token=...
395
+ │ to: bat.com/_bffless/auth/callback?token=...
173
396
 
174
397
 
175
398
  ┌──────────────────┐
@@ -181,9 +404,9 @@ Custom Domain Flow:
181
404
 
182
405
  ## Troubleshooting
183
406
 
184
- **User gets stuck in a redirect loop?**
407
+ **User gets stuck in a redirect loop on an additional custom domain (e.g., `bat.com`)?**
185
408
 
186
- - **Most common cause:** Using `/api/auth/session` instead of `/_bffless/auth/session`. The domain relay callback sets `bffless_access` cookies which are only recognized by `/_bffless/auth/*` endpoints. The `/api/auth/*` endpoints check SuperTokens cookies (`sAccessToken`) which are NOT set by the domain relay flow.
409
+ - **Most common cause:** Calling `/api/auth/session` instead of `/_bffless/auth/session`. The relay flow sets `bffless_access`, which only `/_bffless/auth/*` recognizes and `/api/auth/*` doesn't even exist on `bat.com` without a reverse-proxy rule (which wouldn't help anyway, since the `sAccessToken` cookie can't reach `bat.com`).
187
410
  - Verify the custom domain is registered in `domain_mappings` with `isActive = true`
188
411
  - Ensure cookies are being set (requires HTTPS for `Secure` flag)
189
412
 
@@ -194,11 +417,14 @@ Custom Domain Flow:
194
417
 
195
418
  **Session check returns 401 but user just logged in?**
196
419
 
197
- - On custom domains: verify the `/_bffless/auth/callback` was reached and cookies were set
198
- - On workspace subdomains: verify `COOKIE_DOMAIN` is configured for cross-subdomain cookie sharing
420
+ - On the primary domain or one of its subdomains: verify `COOKIE_DOMAIN` is set to `.foo.com` so `sAccessToken` is shared across them
421
+ - On an additional cross-origin custom domain (`bat.com` attached to a `foo.com` install): verify the `/_bffless/auth/callback` was reached and `bffless_access` was set
199
422
  - Check that the `bffless_access` or `sAccessToken` cookie is present in the request
200
423
 
201
- **Admin login URL use promoted domain, not workspace subdomain:**
424
+ **Logout returns 200 "Logged out successfully" but the next session check still returns `authenticated: true`?**
425
+
426
+ This is the most-reported logout footgun on the primary domain. `/_bffless/auth/logout` only clears `bffless_access` / `bffless_refresh`, but on the primary domain those cookies never existed — the session is in `sAccessToken` on `.foo.com`, which `/_bffless/auth/logout` cannot touch.
427
+
428
+ Fix: either configure the [reverse-proxy rule](#advanced-reverse-proxy-to-supertokens-endpoints) and `POST /auth/signout` directly, or navigate to `admin.foo.com/logout?redirect=<current-page>`. The admin page calls SuperTokens `signOut()`, which revokes the session and clears the shared cookie, then redirects back. See the [Logout](#logout) section for the full pattern.
202
429
 
203
- - If the workspace has a promoted domain (e.g., `console.bffless.app`), use `admin.console.bffless.app`, NOT `admin.console.workspace.bffless.app`
204
- - The workspace subdomain format still works but the promoted domain is cleaner
430
+ On a cross-origin custom domain (`bat.com`) `/_bffless/auth/logout` actually does clear the relevant cookies (`bffless_access` / `bffless_refresh`) no admin bounce required there.