@bffless/skills 1.8.0 → 1.8.1

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.1"
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.1",
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.1",
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"
@@ -52,11 +52,23 @@ All auth endpoints are available at `/_bffless/auth/*` on any domain served by B
52
52
 
53
53
  | Endpoint | Method | Purpose |
54
54
  | --------------------------- | ------ | -------------------------------------------------------- |
55
- | `/_bffless/auth/session` | GET | Check current session (returns user info or 401) |
55
+ | `/_bffless/auth/session` | GET | Check current session see response shape below |
56
56
  | `/_bffless/auth/refresh` | POST | Refresh an expired access token using the refresh cookie |
57
57
  | `/_bffless/auth/callback` | GET | Exchange a domain relay token for auth cookies |
58
58
  | `/_bffless/auth/logout` | POST | Clear auth cookies |
59
59
 
60
+ ### Session Endpoint Response Shape
61
+
62
+ `GET /_bffless/auth/session` has **three** possible outcomes — make sure your client distinguishes all three:
63
+
64
+ | Outcome | Status | Body | Meaning |
65
+ | ------- | ------ | ---- | ------- |
66
+ | Logged in | `200` | `{ "authenticated": true, "user": { id, email, role } }` | Use the user object |
67
+ | **Guest** | **`200`** | **`{ "authenticated": false, "user": null }`** | **Not logged in — do NOT trust `res.ok` alone** |
68
+ | Expired | `401` | `"try refresh token"` | Call `/_bffless/auth/refresh`, then retry session |
69
+
70
+ **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.
71
+
60
72
  ### Session Check Priority
61
73
 
62
74
  The `/_bffless/auth/session` endpoint checks auth in this order:
@@ -73,36 +85,42 @@ If the access token is expired, it returns `401` with `"try refresh token"` to s
73
85
  Use a shared promise pattern to avoid duplicate session checks across components:
74
86
 
75
87
  ```typescript
76
- async function checkSession() {
88
+ type Session =
89
+ | { authenticated: true; user: { id: string; email?: string; role?: string } }
90
+ | { authenticated: false };
91
+
92
+ async function checkSession(): Promise<Session> {
77
93
  // Reuse shared session promise so multiple components don't duplicate requests
78
94
  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();
95
+ (window as any).__bfflessSession = (async (): Promise<Session> => {
96
+ const get = () => fetch('/_bffless/auth/session', { credentials: 'include' });
82
97
 
98
+ let res = await get();
83
99
  if (res.status === 401) {
84
- // Token expired — try refreshing
100
+ // Token expired — try refreshing, then retry the session check
85
101
  const refreshRes = await fetch('/_bffless/auth/refresh', {
86
102
  method: 'POST',
87
103
  credentials: 'include',
88
104
  });
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
- }
105
+ if (refreshRes.ok) res = await get();
94
106
  }
95
- return null;
96
- })().catch(() => null);
107
+
108
+ if (!res.ok) return { authenticated: false };
109
+
110
+ // IMPORTANT: a 200 can still be a guest — the body decides.
111
+ const body = await res.json();
112
+ if (body?.authenticated === false || body?.user == null) {
113
+ return { authenticated: false };
114
+ }
115
+ return { authenticated: true, user: body.user ?? body };
116
+ })().catch(() => ({ authenticated: false }) as Session);
97
117
  }
98
118
 
99
119
  return (window as any).__bfflessSession;
100
120
  }
101
-
102
- // Returns: { authenticated: true, user: { id, email, role } } or null
103
121
  ```
104
122
 
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.
123
+ 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
124
 
107
125
  ### Redirecting to Login
108
126
 
@@ -110,7 +128,11 @@ When unauthenticated, redirect the user to the admin login with relay params. Us
110
128
 
111
129
  ```typescript
112
130
  function getLoginUrl(adminLoginUrl: string, redirectPath: string): string {
113
- const targetDomain = window.location.hostname;
131
+ // Use `host`, NOT `hostname` — host includes the port (e.g. `localhost:5173`).
132
+ // Using `hostname` strips the port, and the backend builds a callback URL
133
+ // like `https://localhost/_bffless/auth/callback?...` (no port, wrong scheme)
134
+ // which is unreachable in local dev.
135
+ const targetDomain = window.location.host;
114
136
  const params = new URLSearchParams({
115
137
  customDomainRelay: 'true',
116
138
  targetDomain,
@@ -131,10 +153,36 @@ if (!session) {
131
153
 
132
154
  ### Logout
133
155
 
156
+ Logout is symmetric to login: the admin domain owns the SuperTokens session, so you have to bounce through it to actually revoke. Calling `/_bffless/auth/logout` on its own is **not enough** on workspace subdomains — see the dedicated troubleshooting entry below.
157
+
134
158
  ```typescript
135
- await fetch('/_bffless/auth/logout', { method: 'POST', credentials: 'include' });
159
+ async function logout(adminLogoutUrl: string) {
160
+ // 1. Clear the bffless_access / bffless_refresh cookies that live on this
161
+ // domain. No-op on workspace subdomains (those cookies are never set
162
+ // there), but required for custom domains.
163
+ try {
164
+ await fetch('/_bffless/auth/logout', {
165
+ method: 'POST',
166
+ credentials: 'include',
167
+ });
168
+ } catch {
169
+ // ignore — the admin bounce below is the source of truth
170
+ }
171
+
172
+ // 2. Bounce through the admin logout page so SuperTokens revokes the
173
+ // session and clears `sAccessToken` on the parent domain. The admin
174
+ // page validates `redirect` (same base-domain only) and sends the
175
+ // user back.
176
+ const redirect = window.location.origin + window.location.pathname;
177
+ window.location.href = `${adminLogoutUrl}?redirect=${encodeURIComponent(redirect)}`;
178
+ }
179
+
180
+ // Example:
181
+ // logout('https://admin.console.bffless.app/logout');
136
182
  ```
137
183
 
184
+ 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.
185
+
138
186
  ### Updating UI Based on Auth State (Header example)
139
187
 
140
188
  ```typescript
@@ -151,6 +199,87 @@ window.__bfflessSession.then((data) => {
151
199
  });
152
200
  ```
153
201
 
202
+ ## Local Development
203
+
204
+ There is no auth backend running on `localhost`, so `/_bffless/auth/*` 404s out of the box. There are two patterns for working around this:
205
+
206
+ ### 1. Proxy `/_bffless` to a deployed workspace (real auth)
207
+
208
+ 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:
209
+
210
+ ```ts
211
+ // vite.config.ts
212
+ export default defineConfig({
213
+ server: {
214
+ proxy: {
215
+ '/api': { target: 'https://yourworkspace.bffless.app', changeOrigin: true },
216
+ '/_bffless': { target: 'https://yourworkspace.bffless.app', changeOrigin: true },
217
+ },
218
+ },
219
+ });
220
+ ```
221
+
222
+ Caveats:
223
+ - 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.
224
+ - 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.
225
+
226
+ ### 2. Mock `/_bffless/auth/*` with MSW (no backend)
227
+
228
+ 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:
229
+
230
+ ```ts
231
+ // src/mocks/handlers.ts
232
+ import { http, HttpResponse, passthrough } from 'msw';
233
+
234
+ const STORAGE_KEY = 'bffless:mockAuth';
235
+
236
+ function readMock() {
237
+ try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}'); }
238
+ catch { return {}; }
239
+ }
240
+
241
+ export const handlers = [
242
+ http.get('/_bffless/auth/session', () => {
243
+ const m = readMock();
244
+ if (!m.enabled) return passthrough();
245
+ if (!m.authenticated) {
246
+ return HttpResponse.json({ authenticated: false, user: null });
247
+ }
248
+ return HttpResponse.json({ authenticated: true, user: m.user });
249
+ }),
250
+ http.post('/_bffless/auth/refresh', () => {
251
+ const m = readMock();
252
+ if (!m.enabled) return passthrough();
253
+ return new HttpResponse(null, { status: m.authenticated ? 200 : 401 });
254
+ }),
255
+ http.post('/_bffless/auth/logout', () => {
256
+ const m = readMock();
257
+ if (!m.enabled) return passthrough();
258
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...m, authenticated: false }));
259
+ return new HttpResponse(null, { status: 204 });
260
+ }),
261
+ ];
262
+ ```
263
+
264
+ Then boot the worker only in dev, before render:
265
+
266
+ ```ts
267
+ // src/main.tsx
268
+ async function enableMocks() {
269
+ if (!import.meta.env.DEV) return;
270
+ const { setupWorker } = await import('msw/browser');
271
+ const { handlers } = await import('./mocks/handlers');
272
+ await setupWorker(...handlers).start({ onUnhandledRequest: 'bypass' });
273
+ }
274
+ enableMocks().then(() => { /* createRoot(...).render(...) */ });
275
+ ```
276
+
277
+ 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.
278
+
279
+ Caveats:
280
+ - Returning `passthrough()` when `enabled === false` lets you fall back to a proxied real backend (pattern #1) on demand.
281
+ - MSW requires `public/mockServiceWorker.js`; generate it once with `npx msw init public/ --save`.
282
+
154
283
  ## Auth Flow Diagram
155
284
 
156
285
  ```
@@ -202,3 +331,13 @@ Custom Domain Flow:
202
331
 
203
332
  - If the workspace has a promoted domain (e.g., `console.bffless.app`), use `admin.console.bffless.app`, NOT `admin.console.workspace.bffless.app`
204
333
  - The workspace subdomain format still works but the promoted domain is cleaner
334
+
335
+ **Logout returns 200 "Logged out successfully" but the next session check still returns `authenticated: true`?**
336
+
337
+ This is the most-reported logout footgun. It means you called `/_bffless/auth/logout` alone:
338
+
339
+ - `/_bffless/auth/logout` only clears the **custom-domain JWT cookies** (`bffless_access`, `bffless_refresh`).
340
+ - The session endpoint falls back to the **SuperTokens session** (`sAccessToken`) when no `bffless_access` cookie is present. That cookie lives on the parent domain (`.bffless.app` / `.yourdomain.com`) and was set by the admin login — it is not cleared by `/_bffless/auth/logout`.
341
+ - On workspace subdomains the `bffless_access` cookie was never set in the first place, so `/_bffless/auth/logout` is effectively a no-op and the SuperTokens fallback re-authenticates the user immediately.
342
+
343
+ Fix: after the `/_bffless/auth/logout` call, navigate to `admin.<workspace>/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.