@biffo/cli 0.77.1 → 0.78.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.
@@ -22,14 +22,38 @@ function userWithSession(session: unknown, err: Error | null = null) {
22
22
  }
23
23
  }
24
24
 
25
+ // The env vars are the TRANSITIONAL fallback (#403 Stage 3 removes them). The
26
+ // document is now the preferred source, so most tests set env AND control
27
+ // `fetch` to prove which one wins.
25
28
  function configureCognitoEnv() {
26
29
  vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', 'us-east-1_TESTPOOL')
27
30
  vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', 'testclientid')
28
31
  }
29
32
 
30
- // Each test re-imports the module fresh: the pool is memoised after its first
31
- // construction, so a module instance left over from a previous test would carry
32
- // that test's env into the next one.
33
+ // A `fetch` returning a valid identity document. The ids differ from the env
34
+ // values on purpose so a test can prove the DOCUMENT wins over baked env.
35
+ function mockFetchDocument(overrides: Record<string, unknown> = {}) {
36
+ const body = {
37
+ userPoolId: 'us-east-1_DOCPOOL',
38
+ clientId: 'docclientid',
39
+ region: 'us-east-1',
40
+ ...overrides,
41
+ }
42
+ global.fetch = vi.fn().mockResolvedValue({
43
+ ok: true,
44
+ json: async () => body,
45
+ }) as unknown as typeof fetch
46
+ }
47
+
48
+ // A `fetch` that rejects (network error) — drives the env fallback path.
49
+ function mockFetchUnreachable() {
50
+ global.fetch = vi.fn().mockRejectedValue(new Error('network down')) as unknown as typeof fetch
51
+ }
52
+
53
+ // Each test re-imports the module fresh: both the pool (auth.ts) and the
54
+ // resolved-identity Promise (identity.ts) are memoised at module scope, so a
55
+ // module instance left over from a previous test would carry that test's
56
+ // document/env into the next one.
33
57
  async function loadAuth() {
34
58
  vi.resetModules()
35
59
  return await import('@/lib/auth')
@@ -39,10 +63,15 @@ describe('getCurrentSession', () => {
39
63
  beforeEach(() => {
40
64
  vi.clearAllMocks()
41
65
  configureCognitoEnv()
66
+ // Default: document unreachable, so these behaviour tests build the pool
67
+ // from the env fallback (us-east-1_TESTPOOL / testclientid) exactly as the
68
+ // pre-runtime version did.
69
+ mockFetchUnreachable()
42
70
  })
43
71
 
44
72
  afterEach(() => {
45
73
  vi.unstubAllEnvs()
74
+ vi.restoreAllMocks()
46
75
  })
47
76
 
48
77
  it('returns null when no user is stored in the shared localStorage session', async () => {
@@ -71,18 +100,113 @@ describe('getCurrentSession', () => {
71
100
  })
72
101
  })
73
102
 
103
+ // The core now publishes its Cognito coordinates at runtime; the sibling
104
+ // prefers that document over its baked env vars (#403/#400). These tests pin
105
+ // which source wins under each condition.
106
+ describe('runtime core identity resolution', () => {
107
+ beforeEach(() => {
108
+ vi.clearAllMocks()
109
+ })
110
+
111
+ afterEach(() => {
112
+ vi.unstubAllEnvs()
113
+ vi.restoreAllMocks()
114
+ })
115
+
116
+ it('builds the pool from the published document, not from env', async () => {
117
+ // Env is set to DIFFERENT values so a pass proves the document won.
118
+ configureCognitoEnv()
119
+ mockFetchDocument()
120
+ const { getCurrentSession } = await loadAuth()
121
+ getCurrentUser.mockReturnValue(null)
122
+
123
+ await getCurrentSession()
124
+
125
+ expect(global.fetch).toHaveBeenCalledWith('/.well-known/biffo-identity.json', {
126
+ cache: 'no-store',
127
+ })
128
+ expect(poolConstructor).toHaveBeenCalledWith({
129
+ UserPoolId: 'us-east-1_DOCPOOL',
130
+ ClientId: 'docclientid',
131
+ })
132
+ })
133
+
134
+ it('falls back to env and warns when the document is unreachable', async () => {
135
+ configureCognitoEnv()
136
+ mockFetchUnreachable()
137
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
138
+ const { getCurrentSession } = await loadAuth()
139
+ getCurrentUser.mockReturnValue(null)
140
+
141
+ await getCurrentSession()
142
+
143
+ expect(poolConstructor).toHaveBeenCalledWith({
144
+ UserPoolId: 'us-east-1_TESTPOOL',
145
+ ClientId: 'testclientid',
146
+ })
147
+ expect(warn).toHaveBeenCalledOnce()
148
+ expect(warn.mock.calls[0]?.[0]).toContain('DEGRADED')
149
+ })
150
+
151
+ it('falls back to env when the document is served but missing ids', async () => {
152
+ configureCognitoEnv()
153
+ // ok:true but the body lacks a clientId — treated as unusable.
154
+ mockFetchDocument({ clientId: '' })
155
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
156
+ const { getCurrentSession } = await loadAuth()
157
+ getCurrentUser.mockReturnValue(null)
158
+
159
+ await getCurrentSession()
160
+
161
+ expect(poolConstructor).toHaveBeenCalledWith({
162
+ UserPoolId: 'us-east-1_TESTPOOL',
163
+ ClientId: 'testclientid',
164
+ })
165
+ expect(warn).toHaveBeenCalledOnce()
166
+ })
167
+
168
+ it('resolves null and never constructs the pool when both document and env are absent', async () => {
169
+ vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', '')
170
+ vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', '')
171
+ mockFetchUnreachable()
172
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
173
+ const { getCurrentSession } = await loadAuth()
174
+
175
+ await expect(getCurrentSession()).resolves.toBeNull()
176
+ expect(poolConstructor).not.toHaveBeenCalled()
177
+ // No document AND no env is an unconfigured build, not degradation — quiet.
178
+ expect(warn).not.toHaveBeenCalled()
179
+ })
180
+
181
+ it('fetches the document at most once across multiple session reads (memoised)', async () => {
182
+ configureCognitoEnv()
183
+ mockFetchDocument()
184
+ const { getCurrentSession } = await loadAuth()
185
+ getCurrentUser.mockReturnValue(null)
186
+
187
+ await getCurrentSession()
188
+ await getCurrentSession()
189
+ await getCurrentSession()
190
+
191
+ expect(global.fetch).toHaveBeenCalledTimes(1)
192
+ expect(poolConstructor).toHaveBeenCalledTimes(1)
193
+ })
194
+ })
195
+
74
196
  // The CognitoUserPool constructor throws ("Both UserPoolId and ClientId are
75
197
  // required") when either value is missing, and `next build` prerenders `/` in
76
198
  // Node — which imports this module. Building the pool at module scope therefore
77
- // made the skeleton un-buildable without real Cognito credentials in scope.
78
- // These tests pin that shut.
199
+ // made the skeleton un-buildable without a resolvable identity in scope. These
200
+ // tests pin that shut.
79
201
  describe('lazy pool construction', () => {
80
202
  beforeEach(() => {
81
203
  vi.clearAllMocks()
204
+ mockFetchUnreachable()
82
205
  })
83
206
 
84
207
  afterEach(() => {
85
208
  vi.unstubAllEnvs()
209
+ vi.restoreAllMocks()
86
210
  })
87
211
 
88
212
  it('does not construct the pool merely by importing the module', async () => {
@@ -91,11 +215,19 @@ describe('lazy pool construction', () => {
91
215
  expect(poolConstructor).not.toHaveBeenCalled()
92
216
  })
93
217
 
94
- it('imports cleanly with no Cognito env vars set', async () => {
218
+ it('imports cleanly with no Cognito env vars set and no fetch available', async () => {
95
219
  vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', '')
96
220
  vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', '')
97
- await expect(loadAuth()).resolves.toBeDefined()
98
- expect(poolConstructor).not.toHaveBeenCalled()
221
+ // Simulate `next build` prerendering `/` in Node with no fetch in scope.
222
+ const originalFetch = global.fetch
223
+ // @ts-expect-error deliberately remove fetch to mimic the Node prerender.
224
+ delete global.fetch
225
+ try {
226
+ await expect(loadAuth()).resolves.toBeDefined()
227
+ expect(poolConstructor).not.toHaveBeenCalled()
228
+ } finally {
229
+ global.fetch = originalFetch
230
+ }
99
231
  })
100
232
 
101
233
  it('resolves null instead of throwing when Cognito env vars are absent', async () => {
@@ -106,7 +238,7 @@ describe('lazy pool construction', () => {
106
238
  expect(poolConstructor).not.toHaveBeenCalled()
107
239
  })
108
240
 
109
- it('constructs the pool once, on first session read, from the core env vars', async () => {
241
+ it('constructs the pool once, on first session read, from the env fallback', async () => {
110
242
  configureCognitoEnv()
111
243
  const { getCurrentSession } = await loadAuth()
112
244
  getCurrentUser.mockReturnValue(null)
@@ -127,6 +259,9 @@ describe('lazy pool construction', () => {
127
259
  // login path that bypasses the portal and silently breaks single-sign-on.
128
260
  // If you are here because you deliberately added one of these, that decision
129
261
  // belongs in an ADR — not in a green test suite.
262
+ //
263
+ // resolveCoreIdentity lives in identity.ts, deliberately NOT re-exported here:
264
+ // auth.ts's surface stays exactly one export.
130
265
  describe('sibling auth surface', () => {
131
266
  it('exposes only session reading — no sign-in/sign-out machinery', async () => {
132
267
  const authModule = await loadAuth()
@@ -4,6 +4,8 @@ import {
4
4
  type ICognitoUserPoolData,
5
5
  } from 'amazon-cognito-identity-js'
6
6
 
7
+ import { resolveCoreIdentity } from './identity'
8
+
7
9
  // ---------------------------------------------------------------------------
8
10
  // SHARED-SESSION INVARIANT — read this before changing anything below.
9
11
  //
@@ -29,28 +31,38 @@ import {
29
31
  // portal too.
30
32
  // ---------------------------------------------------------------------------
31
33
  // Module-private on purpose: the pool is an implementation detail of
32
- // getCurrentSession(), not part of this sibling's auth surface.
34
+ // getCurrentSession(), not part of this sibling's auth surface. Its Cognito
35
+ // coordinates now come from resolveCoreIdentity() (identity.ts), which prefers
36
+ // the core's runtime-published /.well-known/biffo-identity.json document and
37
+ // only falls back to the baked NEXT_PUBLIC_CORE_COGNITO_* env vars when that
38
+ // document is unreachable — see identity.ts for why runtime resolution kills
39
+ // the stale-baked-pool bug class (#403/#400).
33
40
  //
34
41
  // Constructed LAZILY, on first session read — never at module scope. The
35
42
  // CognitoUserPool constructor throws ("Both UserPoolId and ClientId are
36
43
  // required") when either value is missing, and `next build` prerenders `/` in
37
44
  // Node, which imports this module. Constructing eagerly therefore made the
38
- // whole app un-buildable without the real Cognito env vars in scope — a build
39
- // is not a sign-in, and it has no business needing pool credentials. Deferring
45
+ // whole app un-buildable without a resolvable identity in scope — a build is
46
+ // not a sign-in, and it has no business needing pool credentials. Deferring
40
47
  // keeps `pnpm run build` (and any import of this module) working with no env
41
- // configured, while an actual session read in a misconfigured deployment
42
- // surfaces as "signed out" rather than a hard crash.
48
+ // and no reachable document, while an actual session read in a misconfigured
49
+ // deployment surfaces as "signed out" rather than a hard crash.
50
+ //
51
+ // The pool itself is memoised: resolveCoreIdentity() is memoised too, but the
52
+ // CognitoUserPool wrapper is built here exactly once so repeated session reads
53
+ // reuse one instance (and its localStorage view of the shared session).
43
54
  let userPool: CognitoUserPool | null = null
44
55
 
45
- function getUserPool(): CognitoUserPool | null {
56
+ async function getUserPool(): Promise<CognitoUserPool | null> {
46
57
  if (userPool) return userPool
47
58
 
59
+ const identity = await resolveCoreIdentity()
60
+ if (!identity) return null
61
+
48
62
  const poolData: ICognitoUserPoolData = {
49
- UserPoolId: process.env['NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID'] ?? '',
50
- ClientId: process.env['NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID'] ?? '',
63
+ UserPoolId: identity.userPoolId,
64
+ ClientId: identity.clientId,
51
65
  }
52
- if (!poolData.UserPoolId || !poolData.ClientId) return null
53
-
54
66
  userPool = new CognitoUserPool(poolData)
55
67
  return userPool
56
68
  }
@@ -62,15 +74,15 @@ function getUserPool(): CognitoUserPool | null {
62
74
  * (see `createApiClient`, which takes a `getIdToken` callback). A null result
63
75
  * means "redirect the user to the portal's login".
64
76
  */
65
- export function getCurrentSession(): Promise<CognitoUserSession | null> {
77
+ export async function getCurrentSession(): Promise<CognitoUserSession | null> {
78
+ // Resolving the pool is now async because the core identity is fetched at
79
+ // runtime (identity.ts). A null pool means no resolvable core Cognito config,
80
+ // which is indistinguishable from "not signed in" as far as callers are
81
+ // concerned — the semantics are unchanged from the env-only version.
82
+ const pool = await getUserPool()
83
+ if (!pool) return null
84
+
66
85
  return new Promise((resolve) => {
67
- // No pool means no core Cognito config, which is indistinguishable from
68
- // "not signed in" as far as callers are concerned.
69
- const pool = getUserPool()
70
- if (!pool) {
71
- resolve(null)
72
- return
73
- }
74
86
  const user = pool.getCurrentUser()
75
87
  if (!user) {
76
88
  resolve(null)
@@ -0,0 +1,140 @@
1
+ // ---------------------------------------------------------------------------
2
+ // RUNTIME CORE IDENTITY (#403 / #400)
3
+ //
4
+ // The core project publishes its Cognito coordinates at
5
+ // `/.well-known/biffo-identity.json`, served same-origin from the portal
6
+ // bucket (see modules/cloud/aws/cdn/main.tf and .github/workflows/deploy-infra.yml).
7
+ // This module resolves that document at RUNTIME so a sibling never has to bake
8
+ // the core's User Pool / App Client id into its bundle.
9
+ //
10
+ // WHY runtime, not build-time:
11
+ // Baking `NEXT_PUBLIC_CORE_COGNITO_*` into the static export copies a
12
+ // snapshot of the core's pool id into every sibling. When the core replaces
13
+ // its pool (or client), every sibling still points at the dead one until it
14
+ // is rebuilt and redeployed — that stranding is exactly the bug class #400
15
+ // and #403 exist to delete. Reading the published document each page load
16
+ // removes the copy entirely: the source of truth lives with the core.
17
+ //
18
+ // WHY a RELATIVE, same-origin fetch is valid:
19
+ // A sibling and the core portal live on the SAME ORIGIN (baseurl.com/ vs
20
+ // baseurl.com/<name>/). That is the same property that makes shared-session
21
+ // SSO work (see the SHARED-SESSION INVARIANT in auth.ts). So a relative
22
+ // `fetch('/.well-known/biffo-identity.json')` reaches the core's document
23
+ // with no CORS, no configured base URL, and no knowledge of the deployment's
24
+ // domain.
25
+ //
26
+ // WHY memoised:
27
+ // The document is immutable for a page's lifetime, and auth is read on many
28
+ // code paths. Memoising the in-flight/resolved Promise at module scope makes
29
+ // this exactly ONE network request per page load, shared by every caller,
30
+ // rather than one per session read.
31
+ //
32
+ // The env fallback below is TRANSITIONAL (Stage 3 of #403 removes it). It lets
33
+ // an instance whose core has not yet started publishing the document keep
34
+ // working off its baked env vars, so instances can upgrade at their own pace.
35
+ // A baked value that shadows a live document is precisely the stale-copy bug
36
+ // we are removing, so falling back because the document was UNREACHABLE is a
37
+ // degraded path and warns loudly.
38
+ // ---------------------------------------------------------------------------
39
+
40
+ export interface CoreIdentity {
41
+ userPoolId: string
42
+ clientId: string
43
+ region?: string
44
+ apiUrl?: string
45
+ portalUrl?: string
46
+ }
47
+
48
+ // The relative, same-origin path the core publishes its identity document at.
49
+ const IDENTITY_DOCUMENT_PATH = '/.well-known/biffo-identity.json'
50
+
51
+ // Memoised for the page's lifetime: the first call kicks off the fetch and
52
+ // every subsequent call shares the same Promise, so the document is requested
53
+ // at most once. Storing the Promise (not the resolved value) means concurrent
54
+ // callers before the fetch settles also coalesce onto the one request.
55
+ let cached: Promise<CoreIdentity | null> | null = null
56
+
57
+ // Read the transitional env fallback. Returns a valid CoreIdentity only when
58
+ // BOTH ids are present; otherwise null (an unconfigured build has neither a
59
+ // document nor env, and that is not an error to shout about).
60
+ function identityFromEnv(): CoreIdentity | null {
61
+ const userPoolId = process.env['NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID'] ?? ''
62
+ const clientId = process.env['NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID'] ?? ''
63
+ if (!userPoolId || !clientId) return null
64
+ return { userPoolId, clientId }
65
+ }
66
+
67
+ // A parsed document counts only when it carries both ids non-empty; a document
68
+ // missing either is treated as unusable and triggers the fallback.
69
+ function identityFromDocument(data: unknown): CoreIdentity | null {
70
+ if (typeof data !== 'object' || data === null) return null
71
+ const doc = data as Record<string, unknown>
72
+ const userPoolId = typeof doc['userPoolId'] === 'string' ? doc['userPoolId'] : ''
73
+ const clientId = typeof doc['clientId'] === 'string' ? doc['clientId'] : ''
74
+ if (!userPoolId || !clientId) return null
75
+
76
+ const identity: CoreIdentity = { userPoolId, clientId }
77
+ if (typeof doc['region'] === 'string') identity.region = doc['region']
78
+ if (typeof doc['apiUrl'] === 'string') identity.apiUrl = doc['apiUrl']
79
+ if (typeof doc['portalUrl'] === 'string') identity.portalUrl = doc['portalUrl']
80
+ return identity
81
+ }
82
+
83
+ async function fetchCoreIdentity(): Promise<CoreIdentity | null> {
84
+ try {
85
+ // `no-store`: never let a stale document linger in the fetch cache — the
86
+ // whole point of runtime resolution is to always see the core's current
87
+ // coordinates.
88
+ const res = await fetch(IDENTITY_DOCUMENT_PATH, { cache: 'no-store' })
89
+ if (res.ok) {
90
+ const identity = identityFromDocument(await res.json())
91
+ if (identity) return identity
92
+ }
93
+ // Reached here => the document was served but unusable (non-ok status or
94
+ // missing ids). Fall through to the degraded env fallback below.
95
+ } catch {
96
+ // Network error, or `fetch` not available (e.g. `next build` prerendering
97
+ // `/` in Node with no fetch). Fall through to the env fallback.
98
+ }
99
+
100
+ // DEGRADED path: the document was unreachable/unusable, so we lean on the
101
+ // baked env vars. If those exist, warn once — a stale baked value silently
102
+ // shadowing a live document is the #403/#400 bug. If they DON'T exist, this
103
+ // is simply an unconfigured build (no document, no env) — not degradation,
104
+ // so stay quiet.
105
+ const fallback = identityFromEnv()
106
+ if (fallback) {
107
+ console.warn(
108
+ '[biffo] DEGRADED: could not resolve the core identity document at ' +
109
+ `${IDENTITY_DOCUMENT_PATH}; falling back to baked ` +
110
+ 'NEXT_PUBLIC_CORE_COGNITO_* env vars. This is transitional (#403) and ' +
111
+ 'risks pointing at a stale/dead Cognito pool — ensure the core is ' +
112
+ 'publishing the runtime identity document.',
113
+ )
114
+ }
115
+ return fallback
116
+ }
117
+
118
+ /**
119
+ * Resolve the core's Cognito identity at runtime, preferring the published
120
+ * `/.well-known/biffo-identity.json` document and falling back to baked
121
+ * `NEXT_PUBLIC_CORE_COGNITO_*` env vars when the document is unreachable.
122
+ *
123
+ * Memoised: at most one fetch per page load, shared by every caller. Returns
124
+ * null when neither the document nor the env vars supply both ids.
125
+ */
126
+ export function resolveCoreIdentity(): Promise<CoreIdentity | null> {
127
+ cached ??= fetchCoreIdentity()
128
+ return cached
129
+ }
130
+
131
+ /**
132
+ * Test-only: clear the memoised resolution so the next `resolveCoreIdentity()`
133
+ * fetches afresh. Production code never calls this — the memo is meant to live
134
+ * for the whole page. Tests that re-import the module via `vi.resetModules()`
135
+ * get a fresh module-scope `cached` for free; this hook is for tests that want
136
+ * to reset within a single module instance.
137
+ */
138
+ export function __resetCoreIdentityForTests(): void {
139
+ cached = null
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.77.1",
3
+ "version": "0.78.0",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",