@bffless/skills 1.7.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.
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -102,11 +102,13 @@ You can upload multiple artifacts in the same workflow:
|
|
|
102
102
|
| `branch` | no | auto | Branch name |
|
|
103
103
|
| `is-public` | no | `'true'` | Public visibility |
|
|
104
104
|
| `alias` | no | -- | Deployment alias (e.g., `production`) |
|
|
105
|
-
| `base-path` | no | `/<path>` |
|
|
105
|
+
| `base-path` | no | `/<path>` | URL prefix the alias serves under. See [Base path and URL shape](#base-path-and-url-shape). |
|
|
106
106
|
| `committed-at` | no | auto | ISO 8601 commit timestamp |
|
|
107
107
|
| `description` | no | -- | Human-readable description |
|
|
108
|
-
| `proxy-rule-set-name` | no | -- |
|
|
109
|
-
| `proxy-rule-set-id` | no | -- |
|
|
108
|
+
| `proxy-rule-set-name` | no | -- | Single proxy rule set name (legacy — prefer `proxy-rule-set-names`) |
|
|
109
|
+
| `proxy-rule-set-id` | no | -- | Single proxy rule set ID (legacy — prefer `proxy-rule-set-ids`) |
|
|
110
|
+
| `proxy-rule-set-names` | no | -- | Comma-separated proxy rule set names. Appended idempotently — re-deploying with the same names is a no-op. See [Attaching multiple proxy rule sets](#attaching-multiple-proxy-rule-sets). |
|
|
111
|
+
| `proxy-rule-set-ids` | no | -- | Comma-separated proxy rule set IDs. Same append-and-dedupe semantics as `proxy-rule-set-names`. |
|
|
110
112
|
| `tags` | no | -- | Comma-separated tags |
|
|
111
113
|
| `summary` | no | `'true'` | Write GitHub Step Summary |
|
|
112
114
|
| `summary-title` | no | `'Deployment Summary'` | Summary heading |
|
|
@@ -147,7 +149,60 @@ The action automatically detects:
|
|
|
147
149
|
- **Commit SHA**: PR head SHA or push SHA
|
|
148
150
|
- **Branch**: PR head ref or push ref
|
|
149
151
|
- **Committed At**: via `git log` (requires `fetch-depth: 0`)
|
|
150
|
-
- **Base Path**: derived from `path` input as `/<path>`
|
|
152
|
+
- **Base Path**: derived from `path` input as `/<path>` — chosen so files appear at the auto-alias root. See [Base path and URL shape](#base-path-and-url-shape) for what this means and when to override.
|
|
153
|
+
|
|
154
|
+
## Base path and URL shape
|
|
155
|
+
|
|
156
|
+
`base-path` controls the URL prefix the deployment's alias serves files under. It does **not** rewrite the zip — the action always zips the contents of `path` with the source folder name preserved (e.g. `path: coverage` produces a zip containing `coverage/index.html`). At serve time, the alias's `basePath` is prepended to the incoming request URL before the lookup against stored asset keys.
|
|
157
|
+
|
|
158
|
+
Combined with the default `base-path: /<path>`, this produces a useful sleight of hand: the prefix on the URL cancels the folder name on the stored path, so files appear at the **auto-alias root**.
|
|
159
|
+
|
|
160
|
+
Example: `path: coverage`, default `base-path`
|
|
161
|
+
|
|
162
|
+
| Request URL | `basePath` prepended | Resolves to stored | Result |
|
|
163
|
+
|---|---|---|---|
|
|
164
|
+
| `/index.html` | `coverage/index.html` | `coverage/index.html` | ✅ served |
|
|
165
|
+
| `/coverage/index.html` | `coverage/coverage/index.html` | — | ❌ 404 |
|
|
166
|
+
|
|
167
|
+
If you'd rather the source folder be **visible** in the URL (e.g. `/coverage/index.html`), set `base-path: /` so the alias's prefix is empty:
|
|
168
|
+
|
|
169
|
+
| Request URL | `basePath` prepended | Resolves to stored | Result |
|
|
170
|
+
|---|---|---|---|
|
|
171
|
+
| `/coverage/index.html` | `coverage/index.html` | `coverage/index.html` | ✅ served |
|
|
172
|
+
| `/index.html` | `index.html` | — | ❌ 404 |
|
|
173
|
+
|
|
174
|
+
Rule of thumb:
|
|
175
|
+
|
|
176
|
+
- **Want files at auto-alias root** (`<auto-alias>/file.png`) → leave `base-path` unset (default).
|
|
177
|
+
- **Want folder visible in URL** (`<auto-alias>/srcfolder/file.png`) → set `base-path: /`.
|
|
178
|
+
- **Want a different sub-path** → set `base-path: /custom-prefix`. The serving lookup will prepend `custom-prefix/` to incoming requests, so this only works if the zip contents are under a folder of the same name (i.e. `path: custom-prefix`).
|
|
179
|
+
|
|
180
|
+
### Pitfalls
|
|
181
|
+
|
|
182
|
+
- **Do not use `base-path: ./`** — the backend normalization only strips leading/trailing slashes, so `./` becomes the literal segment `.` and gets prepended to every lookup, breaking all requests. Use `/` instead.
|
|
183
|
+
- Empty / whitespace values are also unsafe; pass exactly `/` to mean "no prefix."
|
|
184
|
+
|
|
185
|
+
## Attaching multiple proxy rule sets
|
|
186
|
+
|
|
187
|
+
Use `proxy-rule-set-names` (or `proxy-rule-set-ids`) when the deployment's auto-preview alias needs to chain more than one proxy rule set — for example a Stripe webhook rule set followed by an AI proxy rule set:
|
|
188
|
+
|
|
189
|
+
```yaml
|
|
190
|
+
- uses: bffless/upload-artifact@v1
|
|
191
|
+
with:
|
|
192
|
+
path: dist
|
|
193
|
+
api-url: ${{ vars.ASSET_HOST_URL }}
|
|
194
|
+
api-key: ${{ secrets.ASSET_HOST_KEY }}
|
|
195
|
+
proxy-rule-set-names: stripe-webhook,ai-proxy
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Semantics:
|
|
199
|
+
|
|
200
|
+
- **Append + idempotent.** Each name/id is appended to whatever the alias already has. Re-running the workflow with the same list is a no-op — no duplicate join rows, no rule reordering.
|
|
201
|
+
- **Order preserved on first attach.** The list order is the priority order in the rule merge — earlier entries win when two rule sets match the same request.
|
|
202
|
+
- **Names resolve per-project.** Unknown names fail the deploy with a `400`; existing IDs that don't belong to the project are silently ignored at the rule-merge layer.
|
|
203
|
+
- **Singular still works.** `proxy-rule-set-name` / `proxy-rule-set-id` remain supported as a back-compat shim. If both singular and plural are provided, the plural list wins and the singular value is ignored.
|
|
204
|
+
|
|
205
|
+
If you need full replacement (drop all existing rule sets and set exactly this list), do not use the action — use the `update_alias` API with the `proxyRuleSetIds` array instead. The action's deploy path is intentionally append-only so that rule sets added through other channels (admin UI, scripts) are never silently dropped by a workflow run.
|
|
151
206
|
|
|
152
207
|
## PR Comments
|
|
153
208
|
|
|
@@ -183,9 +238,15 @@ permissions:
|
|
|
183
238
|
pull-requests: write # Required for comments
|
|
184
239
|
```
|
|
185
240
|
|
|
241
|
+
### Files 404 at the auto-alias root
|
|
242
|
+
|
|
243
|
+
If `https://<auto-alias>/<file>` returns 404 but `https://<auto-alias>/<srcfolder>/<file>` works (or vice versa), `base-path` is the wrong shape. See [Base path and URL shape](#base-path-and-url-shape).
|
|
244
|
+
|
|
245
|
+
Common cause: setting `base-path: ./` instead of `base-path: /`. The backend doesn't normalize `./`, so it gets prepended literally and breaks every lookup.
|
|
246
|
+
|
|
186
247
|
### Custom base path
|
|
187
248
|
|
|
188
|
-
If your app expects to be served from a subdirectory:
|
|
249
|
+
If your app expects to be served from a subdirectory (and the zip contents live under that directory):
|
|
189
250
|
|
|
190
251
|
```yaml
|
|
191
252
|
- uses: bffless/upload-artifact@v1
|
|
@@ -193,3 +254,5 @@ If your app expects to be served from a subdirectory:
|
|
|
193
254
|
path: build
|
|
194
255
|
base-path: /docs/build # Served at /docs/build/*
|
|
195
256
|
```
|
|
257
|
+
|
|
258
|
+
See [Base path and URL shape](#base-path-and-url-shape) for the full mental model.
|