@biffo/cli 0.244.9 → 0.244.11

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.
@@ -34,9 +34,10 @@ anywhere in `services/api/`, on purpose.
34
34
 
35
35
  ```
36
36
  apps/frontend/ # Next.js 15 static export. `/` is the SSO demo ("<name> - Hello <username>",
37
- # proving the shared-session SSO works); src/lib/auth-gate.tsx + the
38
- # src/app/example/ routes show the public-default / opt-in-auth pattern
39
- # ("Your app goes here" below). src/lib/auth.ts reads the shared session.
37
+ # proving the shared-session SSO works end to end, gate included);
38
+ # src/lib/auth-gate.tsx is the reusable wrapper for any OTHER page that
39
+ # needs the same gate ("Your app goes here" below). src/lib/auth.ts reads
40
+ # the shared session.
40
41
  services/api/ # FastAPI + Mangum backend. Verifies the core project's Cognito JWT itself
41
42
  # (defense in depth — API Gateway's own JWT authorizer is the first layer).
42
43
  # core_client.py is the ONLY sanctioned way to reach core-owned data.
@@ -81,12 +82,10 @@ else depends on it.
81
82
  This is a Next.js **App Router** app with `output: 'export'` (a static site).
82
83
  A route is just a folder with a `page.tsx` under `apps/frontend/src/app/`:
83
84
 
84
- | File | URL served |
85
- | ------------------------------- | ----------------------------------- |
86
- | `src/app/page.tsx` | `/` (the demo — replace or keep) |
87
- | `src/app/pricing/page.tsx` | `/pricing/` |
88
- | `src/app/example/page.tsx` | `/example/` (public example, below) |
89
- | `src/app/example/members/page.tsx` | `/example/members/` (gated example) |
85
+ | File | URL served |
86
+ | --------------------------- | --------------------------------- |
87
+ | `src/app/page.tsx` | `/` (the demo — replace or keep) |
88
+ | `src/app/pricing/page.tsx` | `/pricing/` |
90
89
 
91
90
  **The `basePath` / `PATH_PREFIX` wiring is automatic — don't hand-write it.**
92
91
  The core project's CloudFront routes `baseurl.com/<name>/*` to this sibling and
@@ -116,8 +115,7 @@ browser never holds a core credential.
116
115
 
117
116
  The go-live state for most products is a **public** app. That is the easy path
118
117
  here: any `page.tsx` you add is served **unauthenticated** the moment it
119
- deploys — no auth code, no bounce. `src/app/example/page.tsx` is a one-screen
120
- demonstration of exactly that; copy it or delete it.
118
+ deploys — no auth code, no bounce, nothing to opt out of.
121
119
 
122
120
  When a page _does_ need a signed-in user, opt in with the `<AuthGate>` helper
123
121
  (`src/lib/auth-gate.tsx`) — one wrapper, and only that page becomes private:
@@ -142,15 +140,18 @@ export default function Dashboard() {
142
140
  A signed-out visitor is redirected to the core portal's login and returned to
143
141
  that exact route afterwards; a signed-in visitor sees the content. `AuthGate`
144
142
  builds on `getCurrentSession`/`auth.ts` and never signs anyone in itself
145
- (ADR-0007). `src/app/example/members/page.tsx` is the runnable version of the
146
- snippet above. Wrap only what must be private — never gate the whole app.
143
+ (ADR-0007) it is the same round-trip `/` already runs, packaged as a
144
+ one-line wrapper for any page besides `/`. Wrap only what must be private —
145
+ never gate the whole app. There is no separate demo route for this: `/`
146
+ already proves the mechanism works end to end, and the snippet above is the
147
+ runnable form.
147
148
 
148
149
  ### The path a founder actually walks
149
150
 
150
151
  1. Run locally (`pnpm dev`, below) and open `/` — watch the SSO demo work.
151
152
  2. Replace `src/app/page.tsx` with your own public home page (or add
152
153
  `src/app/<something>/page.tsx`). It's public by default — that's your
153
- go-live state. Delete the `example/` routes once you've read them.
154
+ go-live state.
154
155
  3. For any area that needs a login, wrap its `page.tsx` in `<AuthGate>`.
155
156
  4. Push to `main`; `deploy.yml` builds the static export with the right
156
157
  `NEXT_PUBLIC_BASE_PATH` and syncs it to S3 behind the core CloudFront —
@@ -61,3 +61,226 @@ describe('createApiClient error handling', () => {
61
61
  )
62
62
  })
63
63
  })
64
+
65
+ /**
66
+ * An expired id token is not an error the user can act on — it is a renewal
67
+ * this app already knows how to perform, and did not (tabsii-lms#3).
68
+ *
69
+ * The observed failure: after ~an hour an authoring session's next action
70
+ * rendered `{"message":"Unauthorized"}` in red in the page. That body is API
71
+ * Gateway's, produced by the JWT authorizer before the request ever reached the
72
+ * app, so `extractErrorMessage` has no `detail` to unwrap and correctly falls
73
+ * back to the raw text — the message layer was working exactly as designed on an
74
+ * input it was never given a chance to improve. Meanwhile a usable refresh
75
+ * token sat in localStorage, and reloading the page renewed silently.
76
+ *
77
+ * Every assertion below is on observable behaviour rather than on an exported
78
+ * constant, so each one fails against the unfixed client for the reason the
79
+ * reporter saw rather than on a missing import.
80
+ */
81
+ describe('createApiClient session renewal', () => {
82
+ const GATEWAY_401 = '{"message":"Unauthorized"}'
83
+
84
+ afterEach(() => {
85
+ vi.unstubAllGlobals()
86
+ })
87
+
88
+ function ok(body: unknown) {
89
+ return {
90
+ ok: true,
91
+ status: 200,
92
+ statusText: 'OK',
93
+ json: () => Promise.resolve(body),
94
+ text: () => Promise.resolve(JSON.stringify(body)),
95
+ }
96
+ }
97
+
98
+ function unauthorized(body = GATEWAY_401) {
99
+ return {
100
+ ok: false,
101
+ status: 401,
102
+ statusText: 'Unauthorized',
103
+ text: () => Promise.resolve(body),
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Answers on the token it is given rather than on call count, so a retry that
109
+ * re-sends the stale token is a failed assertion rather than a passing one.
110
+ */
111
+ function fetchAcceptingOnly(freshToken: string, body: unknown = { items: [] }) {
112
+ return vi.fn((_url: string, init: RequestInit) => {
113
+ const headers = (init.headers ?? {}) as Record<string, string>
114
+ return Promise.resolve(
115
+ headers['Authorization'] === `Bearer ${freshToken}` ? ok(body) : unauthorized(),
116
+ )
117
+ })
118
+ }
119
+
120
+ it('renews the session and retries once, so the call succeeds', async () => {
121
+ const fetchMock = fetchAcceptingOnly('fresh', { items: ['course'] })
122
+ vi.stubGlobal('fetch', fetchMock)
123
+ const refresh = vi.fn(() => Promise.resolve<string | null>('fresh'))
124
+
125
+ const api = createApiClient(() => 'expired', refresh)
126
+
127
+ await expect(api.get('/api/v1/courses')).resolves.toEqual({ items: ['course'] })
128
+ expect(refresh).toHaveBeenCalledTimes(1)
129
+ expect(fetchMock).toHaveBeenCalledTimes(2)
130
+ })
131
+
132
+ it('retries with the refreshed token, never the one that was rejected', async () => {
133
+ const fetchMock = fetchAcceptingOnly('fresh')
134
+ vi.stubGlobal('fetch', fetchMock)
135
+
136
+ // The shape every caller actually uses: `() => token` closes over React
137
+ // state, so it still returns the expired token after a renewal. A retry
138
+ // that re-reads it would send the same rejected credential.
139
+ const api = createApiClient(
140
+ () => 'expired',
141
+ () => Promise.resolve('fresh'),
142
+ )
143
+ await api.get('/api/v1/courses')
144
+
145
+ const sent = fetchMock.mock.calls.map(
146
+ ([, init]) => ((init.headers ?? {}) as Record<string, string>)['Authorization'],
147
+ )
148
+ expect(sent).toEqual(['Bearer expired', 'Bearer fresh'])
149
+ })
150
+
151
+ it('renews once for concurrent 401s rather than once per caller', async () => {
152
+ vi.stubGlobal('fetch', fetchAcceptingOnly('fresh'))
153
+ let resolveRefresh: (token: string) => void = () => {}
154
+ const refresh = vi.fn(
155
+ () =>
156
+ new Promise<string | null>((resolve) => {
157
+ resolveRefresh = resolve
158
+ }),
159
+ )
160
+
161
+ const api = createApiClient(() => 'expired', refresh)
162
+ const inFlight = Promise.all([
163
+ api.get('/api/v1/courses'),
164
+ api.get('/api/v1/modules'),
165
+ api.get('/api/v1/learners'),
166
+ ])
167
+ // Let all three reach their 401 before the single refresh settles.
168
+ await Promise.resolve()
169
+ resolveRefresh('fresh')
170
+ await inFlight
171
+
172
+ expect(refresh).toHaveBeenCalledTimes(1)
173
+ })
174
+
175
+ it('surfaces an actionable sentence, not API Gateway body, when renewal fails', async () => {
176
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(unauthorized()))
177
+
178
+ const api = createApiClient(
179
+ () => 'expired',
180
+ () => Promise.resolve(null),
181
+ )
182
+ const error = await api.get('/api/v1/courses').then(
183
+ () => {
184
+ throw new Error('expected the request to reject')
185
+ },
186
+ (e: unknown) => e as ApiError,
187
+ )
188
+
189
+ expect(error).toBeInstanceOf(ApiError)
190
+ expect(error.status).toBe(401)
191
+ expect(error.message).not.toContain('Unauthorized"')
192
+ expect(error.message).toBe(
193
+ 'Your session has expired. Reload the page to sign in again, then retry that action.',
194
+ )
195
+ })
196
+
197
+ it('does not retry in a loop when the renewed token is also rejected', async () => {
198
+ const fetchMock = vi.fn().mockResolvedValue(unauthorized())
199
+ vi.stubGlobal('fetch', fetchMock)
200
+
201
+ const api = createApiClient(
202
+ () => 'expired',
203
+ () => Promise.resolve('also-stale'),
204
+ )
205
+
206
+ await expect(api.get('/api/v1/courses')).rejects.toThrow(/session has expired/i)
207
+ // The first attempt and exactly one retry. A revoked session must not spin.
208
+ expect(fetchMock).toHaveBeenCalledTimes(2)
209
+ })
210
+
211
+ it('treats a renewal that hands back the same token as no renewal at all', async () => {
212
+ const fetchMock = vi.fn().mockResolvedValue(unauthorized())
213
+ vi.stubGlobal('fetch', fetchMock)
214
+
215
+ const api = createApiClient(
216
+ () => 'expired',
217
+ () => Promise.resolve('expired'),
218
+ )
219
+
220
+ await expect(api.get('/api/v1/courses')).rejects.toThrow(/session has expired/i)
221
+ // Re-sending a credential the gateway just rejected cannot succeed, so the
222
+ // retry is skipped rather than spent.
223
+ expect(fetchMock).toHaveBeenCalledTimes(1)
224
+ })
225
+
226
+ it('reports the expiry sentence when the renewal itself throws', async () => {
227
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(unauthorized()))
228
+
229
+ const api = createApiClient(
230
+ () => 'expired',
231
+ () => Promise.reject(new Error('network down')),
232
+ )
233
+
234
+ // The Cognito failure is plumbing the user cannot act on; what they can act
235
+ // on is signing in again.
236
+ await expect(api.get('/api/v1/courses')).rejects.toThrow(/session has expired/i)
237
+ })
238
+
239
+ it('keeps a backend 401 that explains itself, rather than overwriting it', async () => {
240
+ vi.stubGlobal(
241
+ 'fetch',
242
+ vi.fn().mockResolvedValue(unauthorized('{"detail":"Token issued for another tenant"}')),
243
+ )
244
+
245
+ const api = createApiClient(
246
+ () => 'expired',
247
+ () => Promise.resolve(null),
248
+ )
249
+
250
+ // Same reasoning as extractErrorMessage's: never hide information the
251
+ // caller already had. A `detail` is the product's own voice already.
252
+ await expect(api.get('/api/v1/courses')).rejects.toThrow('Token issued for another tenant')
253
+ })
254
+
255
+ it('renews for a mutating verb too, and retries the body', async () => {
256
+ const fetchMock = fetchAcceptingOnly('fresh', { id: 'c1' })
257
+ vi.stubGlobal('fetch', fetchMock)
258
+
259
+ const api = createApiClient(
260
+ () => 'expired',
261
+ () => Promise.resolve('fresh'),
262
+ )
263
+ await expect(api.post('/api/v1/courses', { title: 'New' })).resolves.toEqual({ id: 'c1' })
264
+
265
+ const [, retry] = fetchMock.mock.calls
266
+ expect(retry[1].method).toBe('POST')
267
+ expect(retry[1].body).toBe(JSON.stringify({ title: 'New' }))
268
+ })
269
+
270
+ it('leaves a non-401 failure entirely alone', async () => {
271
+ const fetchMock = vi.fn().mockResolvedValue({
272
+ ok: false,
273
+ status: 403,
274
+ statusText: 'Forbidden',
275
+ text: () => Promise.resolve('{"detail":"Administrator access required"}'),
276
+ })
277
+ vi.stubGlobal('fetch', fetchMock)
278
+ const refresh = vi.fn(() => Promise.resolve<string | null>('fresh'))
279
+
280
+ const api = createApiClient(() => 'token', refresh)
281
+
282
+ await expect(api.get('/api/v1/users')).rejects.toThrow('Administrator access required')
283
+ expect(refresh).not.toHaveBeenCalled()
284
+ expect(fetchMock).toHaveBeenCalledTimes(1)
285
+ })
286
+ })
@@ -38,67 +38,204 @@ export class ApiError extends Error {
38
38
  * status text, because a browser rendering `''` shows the user nothing at all.
39
39
  */
40
40
  export function extractErrorMessage(body: string, statusText: string): string {
41
+ const detail = parsedDetail(body)
42
+ if (detail !== null) return detail || statusText
43
+ return body || statusText
44
+ }
45
+
46
+ /**
47
+ * The `detail` a Biffo backend put in its error body, or null if there isn't
48
+ * one to read.
49
+ *
50
+ * Split out of `extractErrorMessage` so the 401 path can ask the same question
51
+ * without re-implementing the answer — writing this parse a second time in the
52
+ * same file is precisely the mistake `_extract_detail` records (#1107/#1108),
53
+ * and a second copy would drift on the next FastAPI change.
54
+ *
55
+ * Returns null for "the backend did not speak", and a string — possibly `''` —
56
+ * for "it did". Callers need that distinction: an empty `detail` still means the
57
+ * body was the product's own shape, and collapsing the two would make an empty
58
+ * string indistinguishable from a CloudFront HTML page.
59
+ */
60
+ function parsedDetail(body: string): string | null {
41
61
  let parsed: unknown
42
62
  try {
43
63
  parsed = JSON.parse(body)
44
64
  } catch {
45
- return body || statusText
65
+ return null
46
66
  }
47
67
  if (typeof parsed === 'object' && parsed !== null && 'detail' in parsed) {
48
68
  const { detail } = parsed
49
- if (typeof detail === 'string') return detail || statusText
69
+ // A non-string `detail` also falls back. FastAPI's own 422 makes it a list
70
+ // of field errors; picking something out of it would move the problem
71
+ // rather than fix it.
72
+ if (typeof detail === 'string') return detail
50
73
  }
51
- return body || statusText
74
+ return null
75
+ }
76
+
77
+ /** A response body as text, or `''` if it could not be read. */
78
+ async function bodyOf(res: Response): Promise<string> {
79
+ return res.text().catch(() => '')
52
80
  }
53
81
 
54
82
  async function handleResponse<T>(res: Response): Promise<T> {
55
83
  if (!res.ok) {
56
- const body = await res.text().catch(() => '')
57
- throw new ApiError(res.status, extractErrorMessage(body, res.statusText))
84
+ throw new ApiError(res.status, extractErrorMessage(await bodyOf(res), res.statusText))
58
85
  }
59
86
  return res.json() as Promise<T>
60
87
  }
61
88
 
62
- export function createApiClient(getIdToken: () => string | null) {
63
- function authHeaders(): HeadersInit {
64
- const token = getIdToken()
89
+ /**
90
+ * What a person is told when their session could not be renewed.
91
+ *
92
+ * "Unauthorized" is not an instruction, and it was not even the app's word: a
93
+ * 401 from the API Gateway JWT authorizer never reaches this app, so its body is
94
+ * `{"message":"Unauthorized"}` — a wire format with no `detail` for
95
+ * `extractErrorMessage` to unwrap (tabsii-lms#3). Reloading is named explicitly
96
+ * because it is what actually works: the mount path renews from the refresh
97
+ * token in localStorage, and if that token is dead too the reload lands on the
98
+ * portal's login, which is where the user needs to be either way.
99
+ */
100
+ const SESSION_EXPIRED_MESSAGE =
101
+ 'Your session has expired. Reload the page to sign in again, then retry that action.'
102
+
103
+ /**
104
+ * The message for a 401 that survived a renewal attempt.
105
+ *
106
+ * This deliberately keeps `extractErrorMessage`'s first principle — never hide
107
+ * information the caller already had — rather than stamping the expiry sentence
108
+ * over every 401. A body carrying a `detail` is the product's own voice and may
109
+ * say something the expiry sentence would erase ("Token issued for another
110
+ * tenant"); a body without one is upstream plumbing the user cannot act on, and
111
+ * that is the only case worth replacing.
112
+ *
113
+ * It takes no `statusText`, unlike `extractErrorMessage`: the fallback here is a
114
+ * full sentence rather than a bare status, so there is nothing left for "401" or
115
+ * "Unauthorized" to add.
116
+ */
117
+ function unauthorizedMessage(body: string): string {
118
+ const detail = parsedDetail(body)
119
+ return detail !== null && detail !== '' ? detail : SESSION_EXPIRED_MESSAGE
120
+ }
121
+
122
+ /**
123
+ * One renewal in flight at a time, across every client this module hands out.
124
+ *
125
+ * The Cognito session lives in localStorage, shared by the whole page, so a
126
+ * refresh is a property of the app rather than of one client instance — and a
127
+ * page load routinely fans out several calls at once, each of which would
128
+ * otherwise expire together and fire its own refresh. Cognito would then serve
129
+ * concurrent renewals for one session, and the losers could persist a token
130
+ * older than the winner's.
131
+ *
132
+ * Module scope rather than per-client is what makes that true even though
133
+ * `createApiClient` is called in several components independently.
134
+ */
135
+ let inFlightRenewal: Promise<string | null> | null = null
136
+ let renewalCount = 0
137
+
138
+ function renewSessionOnce(refreshIdToken: () => Promise<string | null>): Promise<string | null> {
139
+ if (inFlightRenewal) return inFlightRenewal
140
+
141
+ // Compared in the `finally` so a settling renewal can only clear itself,
142
+ // never a newer one. (Identity on the promise would say the same thing, but
143
+ // it cannot reference itself before it is assigned.)
144
+ const id = ++renewalCount
145
+ inFlightRenewal = (async () => {
146
+ try {
147
+ return await refreshIdToken()
148
+ } catch {
149
+ // A refresh that throws is indistinguishable, to the user, from one that
150
+ // returns no session: either way they need to sign in again. Rejecting
151
+ // here instead would surface a Cognito internal at them.
152
+ return null
153
+ } finally {
154
+ if (renewalCount === id) inFlightRenewal = null
155
+ }
156
+ })()
157
+
158
+ return inFlightRenewal
159
+ }
160
+
161
+ /**
162
+ * Renew through the portal session this sibling already reads (`./auth`).
163
+ *
164
+ * Imported dynamically, for two reasons. It keeps `api-client.ts` free of a
165
+ * load-time dependency on `auth.ts` — which is NOT distributed with this file
166
+ * (it carries a declared per-repo divergence, biffo-template#1117), so a static
167
+ * import would couple a synced file's module graph to one that may legitimately
168
+ * differ. And it keeps the Cognito SDK out of the bundle of any module that only
169
+ * ever calls the API and never hits a 401.
170
+ *
171
+ * `getCurrentSession()` is the renewal: `CognitoUser.getSession()` exchanges the
172
+ * stored refresh token for a fresh id token when the current one has expired.
173
+ * That is exactly what a page reload was already doing by accident, and nothing
174
+ * did on a live call.
175
+ */
176
+ async function refreshViaPortalSession(): Promise<string | null> {
177
+ const { getCurrentSession } = await import('./auth')
178
+ const session = await getCurrentSession()
179
+ return session?.getIdToken().getJwtToken() ?? null
180
+ }
181
+
182
+ export function createApiClient(
183
+ getIdToken: () => string | null,
184
+ refreshIdToken: () => Promise<string | null> = refreshViaPortalSession,
185
+ ) {
186
+ function authHeaders(token: string | null): HeadersInit {
65
187
  return {
66
188
  'Content-Type': 'application/json',
67
189
  ...(token != null ? { Authorization: `Bearer ${token}` } : {}),
68
190
  }
69
191
  }
70
192
 
193
+ /**
194
+ * Send the request; on a 401, renew the session once and send it again.
195
+ *
196
+ * The retry is a second statement rather than a recursive call, which is what
197
+ * makes "once" structural: there is no path back to the top, so a genuinely
198
+ * revoked session cannot spin however many 401s it produces.
199
+ *
200
+ * It also carries the token the renewal returned rather than re-reading
201
+ * `getIdToken()`. Every caller passes `() => token` closing over React state,
202
+ * which a renewal outside React has not updated — so re-reading it would
203
+ * re-send the credential the gateway just rejected and burn the one retry.
204
+ */
205
+ async function send<T>(path: string, init: RequestInit): Promise<T> {
206
+ const sentToken = getIdToken()
207
+ const res = await fetch(`${API_URL}${path}`, { ...init, headers: authHeaders(sentToken) })
208
+ if (res.status !== 401) return handleResponse<T>(res)
209
+
210
+ const renewed = await renewSessionOnce(refreshIdToken)
211
+ // An unchanged token means the renewal had nothing newer to give, so the
212
+ // retry would re-send what was just rejected. Skip it rather than spend it.
213
+ if (renewed == null || renewed === sentToken) {
214
+ throw new ApiError(401, unauthorizedMessage(await bodyOf(res)))
215
+ }
216
+
217
+ const retried = await fetch(`${API_URL}${path}`, { ...init, headers: authHeaders(renewed) })
218
+ if (retried.status === 401) {
219
+ throw new ApiError(401, unauthorizedMessage(await bodyOf(retried)))
220
+ }
221
+ return handleResponse<T>(retried)
222
+ }
223
+
71
224
  return {
72
- get: <T>(path: string): Promise<T> =>
73
- fetch(`${API_URL}${path}`, { headers: authHeaders() }).then((r) => handleResponse<T>(r)),
225
+ get: <T>(path: string): Promise<T> => send<T>(path, {}),
74
226
 
75
227
  post: <T>(path: string, body: unknown): Promise<T> =>
76
- fetch(`${API_URL}${path}`, {
77
- method: 'POST',
78
- headers: authHeaders(),
79
- body: JSON.stringify(body),
80
- }).then((r) => handleResponse<T>(r)),
228
+ send<T>(path, { method: 'POST', body: JSON.stringify(body) }),
81
229
 
82
230
  put: <T>(path: string, body: unknown): Promise<T> =>
83
- fetch(`${API_URL}${path}`, {
84
- method: 'PUT',
85
- headers: authHeaders(),
86
- body: JSON.stringify(body),
87
- }).then((r) => handleResponse<T>(r)),
231
+ send<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
88
232
 
89
233
  // Folded in from tabsii-crm, which had added it locally. It costs a sibling
90
234
  // that never calls it nothing, and leaving it out would mean this file
91
235
  // could never be distributed without destroying crm's copy.
92
236
  patch: <T>(path: string, body: unknown): Promise<T> =>
93
- fetch(`${API_URL}${path}`, {
94
- method: 'PATCH',
95
- headers: authHeaders(),
96
- body: JSON.stringify(body),
97
- }).then((r) => handleResponse<T>(r)),
98
-
99
- delete: <T>(path: string): Promise<T> =>
100
- fetch(`${API_URL}${path}`, { method: 'DELETE', headers: authHeaders() }).then((r) =>
101
- handleResponse<T>(r),
102
- ),
237
+ send<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
238
+
239
+ delete: <T>(path: string): Promise<T> => send<T>(path, { method: 'DELETE' }),
103
240
  }
104
241
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.244.9",
3
+ "version": "0.244.11",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,31 +0,0 @@
1
- 'use client'
2
-
3
- import { AuthGate } from '@/lib/auth-gate'
4
-
5
- // The SAME page, made private by a single wrapper. Opting into auth is one
6
- // deliberate line — <AuthGate> — not the default. A signed-out visitor is
7
- // bounced to the core portal's login (ADR-0007) and returned here afterwards;
8
- // a signed-in visitor sees the content below.
9
- //
10
- // The render-prop form hands you the session, whose ID token you pass to THIS
11
- // sibling's own backend via createApiClient (never the core API directly,
12
- // ADR-0002). This example doesn't call the backend, but shows where the token
13
- // comes from.
14
- export default function ExampleMembersPage() {
15
- return (
16
- <AuthGate>
17
- {(session) => (
18
- <main className="center-screen">
19
- <div>
20
- <h1>Members only</h1>
21
- <p>You&apos;re signed in — this rendered because a valid session exists.</p>
22
- <p>
23
- Your ID token (for calls to this sibling&apos;s backend) is{' '}
24
- {session.getIdToken().getJwtToken().slice(0, 8)}…
25
- </p>
26
- </div>
27
- </main>
28
- )}
29
- </AuthGate>
30
- )
31
- }
@@ -1,24 +0,0 @@
1
- import Link from 'next/link'
2
-
3
- // A minimal PUBLIC page — the go-live default.
4
- //
5
- // There is no auth code here, and that is the whole point: anything you drop
6
- // under src/app/ is served unauthenticated the moment it deploys. Delete this
7
- // route once you've seen how it works, or copy it as the starting point for
8
- // your own public content. To make a page private instead, wrap it in
9
- // <AuthGate> — see ./members/page.tsx.
10
- export default function ExamplePublicPage() {
11
- return (
12
- <main className="center-screen">
13
- <div>
14
- <h1>This page is public</h1>
15
- <p>
16
- Anyone can see it — no login, no redirect. This is how most of your app ships at go-live.
17
- </p>
18
- <p>
19
- <Link href="/example/members/">See the same pattern, but auth-gated →</Link>
20
- </p>
21
- </div>
22
- </main>
23
- )
24
- }