@biffo/cli 0.79.0 → 0.81.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.
- package/_skeletons/sibling-template/.github/workflows/ci.yml +10 -9
- package/_skeletons/sibling-template/.github/workflows/deploy.yml +6 -2
- package/_skeletons/sibling-template/README.md +24 -10
- package/_skeletons/sibling-template/apps/frontend/.env.example +5 -2
- package/_skeletons/sibling-template/apps/frontend/src/lib/auth.test.ts +39 -55
- package/_skeletons/sibling-template/apps/frontend/src/lib/auth.ts +6 -5
- package/_skeletons/sibling-template/apps/frontend/src/lib/identity.ts +23 -39
- package/dist/index.js +318 -42
- package/package.json +1 -1
|
@@ -67,15 +67,16 @@ jobs:
|
|
|
67
67
|
- name: Test
|
|
68
68
|
if: ${{ !cancelled() }}
|
|
69
69
|
run: pnpm run test
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
70
|
+
# Run with NO NEXT_PUBLIC_CORE_COGNITO_* in scope — this is now the
|
|
71
|
+
# PERMANENT state, not a build-guard quirk. The frontend NEVER reads those
|
|
72
|
+
# vars: it resolves the core's Cognito identity at runtime from
|
|
73
|
+
# /.well-known/biffo-identity.json (#403 Stage 3), so nothing is baked into
|
|
74
|
+
# the bundle and deploy.yml no longer sets them either. `next build`
|
|
75
|
+
# prerenders `/`, which imports src/lib/auth.ts; that module builds its
|
|
76
|
+
# Cognito pool lazily (never at module scope) so the build succeeds with no
|
|
77
|
+
# identity in scope. Originally added as a guard for the eager-construction
|
|
78
|
+
# crash (issue #286); it now simply asserts a sibling's build needs no auth
|
|
79
|
+
# credentials at all.
|
|
79
80
|
- name: Build
|
|
80
81
|
if: ${{ !cancelled() }}
|
|
81
82
|
run: pnpm run build
|
|
@@ -153,8 +153,12 @@ jobs:
|
|
|
153
153
|
NEXT_PUBLIC_SIBLING_PATH_PREFIX: ${{ vars.PATH_PREFIX && format('/{0}', vars.PATH_PREFIX) || '' }}
|
|
154
154
|
NEXT_PUBLIC_BASE_PATH: ${{ vars.PATH_PREFIX && format('/{0}', vars.PATH_PREFIX) || '' }}
|
|
155
155
|
NEXT_PUBLIC_CORE_PORTAL_URL: ${{ vars.CORE_PORTAL_URL }}
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
# The frontend no longer bakes NEXT_PUBLIC_CORE_COGNITO_* into the
|
|
157
|
+
# bundle: it resolves the core's Cognito identity at RUNTIME from the
|
|
158
|
+
# core-published /.well-known/biffo-identity.json document (#403), so
|
|
159
|
+
# the build no longer needs the pool/client ids. (The BACKEND still
|
|
160
|
+
# consumes CORE_COGNITO_* via TF_VAR for JWT validation — see the
|
|
161
|
+
# deploy-infra job above; that is deliberately unchanged.)
|
|
158
162
|
NEXT_PUBLIC_API_URL: ${{ vars.API_URL }}
|
|
159
163
|
run: pnpm run build
|
|
160
164
|
- uses: aws-actions/configure-aws-credentials@v6
|
|
@@ -198,14 +198,21 @@ part of the install, so its lifecycle scripts never run.)
|
|
|
198
198
|
|
|
199
199
|
## The build must not need Cognito credentials
|
|
200
200
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
201
|
+
The frontend resolves the core's Cognito identity at **runtime** from the
|
|
202
|
+
core-published `/.well-known/biffo-identity.json` document (#403) — it never
|
|
203
|
+
bakes `NEXT_PUBLIC_CORE_COGNITO_*` into the bundle. So `pnpm run build` succeeds
|
|
204
|
+
with **no** `NEXT_PUBLIC_CORE_COGNITO_*` set, and `.github/workflows/ci.yml`
|
|
205
|
+
runs it that way on every PR to keep it that way. `next build` prerenders `/` in
|
|
206
|
+
Node, which imports `src/lib/auth.ts`; that module constructs its Cognito user
|
|
207
|
+
pool lazily, on first session read, never at module scope (the `CognitoUserPool`
|
|
208
|
+
constructor throws outright when either id is missing). If you add module-scope
|
|
209
|
+
code that requires real core config, the build breaks for everyone — starting
|
|
210
|
+
with CI. Read config inside the function that needs it.
|
|
211
|
+
|
|
212
|
+
(The sibling's **backend** still receives the core pool/client ids at deploy
|
|
213
|
+
time via `TF_VAR_core_cognito_*` — its Terraform builds a JWKS URL from the pool
|
|
214
|
+
id to validate JWTs. That is a separate concern from the frontend bundle and is
|
|
215
|
+
deliberately unchanged.)
|
|
209
216
|
|
|
210
217
|
## The two-phase CDN registration
|
|
211
218
|
|
|
@@ -267,12 +274,19 @@ this repo's own `statusChecks` list — see that method's third parameter):
|
|
|
267
274
|
|
|
268
275
|
1. Rename `biffo.sibling.json`'s `name`/`core_project`/`path_prefix`.
|
|
269
276
|
2. Copy `apps/frontend/.env.example` to `.env.local` and fill in your core
|
|
270
|
-
project's real
|
|
277
|
+
project's real API/portal values for local dev. No Cognito values are
|
|
278
|
+
needed: the frontend resolves the core's Cognito identity at runtime from
|
|
279
|
+
`/.well-known/biffo-identity.json` (#403). A locally-run frontend with no
|
|
280
|
+
reachable document just treats the visitor as signed-out.
|
|
271
281
|
3. `cd infra && terraform init -backend-config=backend.hcl && terraform apply`
|
|
272
282
|
(generate `backend.hcl` yourself for local use — CI generates it inline,
|
|
273
283
|
see `deploy.yml`).
|
|
274
284
|
4. Wire the GitHub Environment variables/secrets `deploy.yml` reads
|
|
275
285
|
(`CORE_COGNITO_USER_POOL_ID`, `CORE_COGNITO_CLIENT_ID`, `CORE_API_URL`,
|
|
276
|
-
`CORE_PORTAL_URL`, `SIBLING_OIDC_ROLE_ARN`, `TF_STATE_BUCKET`, etc.).
|
|
286
|
+
`CORE_PORTAL_URL`, `SIBLING_OIDC_ROLE_ARN`, `TF_STATE_BUCKET`, etc.). The
|
|
287
|
+
`CORE_COGNITO_*` variables are still required: the **backend** consumes them
|
|
288
|
+
via `TF_VAR_core_cognito_*` for JWT validation (its Terraform derives a JWKS
|
|
289
|
+
URL from the pool id). They are no longer inlined into the frontend bundle —
|
|
290
|
+
the frontend resolves the core's Cognito identity at runtime (#403).
|
|
277
291
|
5. Open the registration PR against your core project (`infra/siblings.auto.tfvars.json`)
|
|
278
292
|
yourself, or let `biffo sibling create` do it for you.
|
|
@@ -6,8 +6,11 @@ NEXT_PUBLIC_SIBLING_NAME=example-sibling
|
|
|
6
6
|
NEXT_PUBLIC_SIBLING_PATH_PREFIX=/example-sibling
|
|
7
7
|
NEXT_PUBLIC_BASE_PATH=/example-sibling
|
|
8
8
|
NEXT_PUBLIC_CORE_PORTAL_URL=https://baseurl.com
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
# No Cognito env is needed: the sibling resolves the core's Cognito identity at
|
|
10
|
+
# RUNTIME from /.well-known/biffo-identity.json (served by the core), so nothing
|
|
11
|
+
# is baked into the bundle (#403). A locally-run frontend with no reachable
|
|
12
|
+
# document simply treats the visitor as signed-out — a sibling never signs
|
|
13
|
+
# anyone in (ADR-0007).
|
|
11
14
|
# This sibling's OWN API Gateway endpoint (from `terraform output api_url`),
|
|
12
15
|
# NOT the core project's API — the frontend never calls the core API
|
|
13
16
|
# directly (ADR-0002/ADR-0007).
|
|
@@ -22,16 +22,16 @@ function userWithSession(session: unknown, err: Error | null = null) {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
// The
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
function
|
|
29
|
-
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', 'us-east-
|
|
30
|
-
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', '
|
|
25
|
+
// The sibling is DOC-ONLY (#403 Stage 3): it NEVER reads NEXT_PUBLIC_CORE_COGNITO_*.
|
|
26
|
+
// This helper only exists to set BOGUS env values in the "env is ignored" tests,
|
|
27
|
+
// proving the pool is never built from env even when it is present.
|
|
28
|
+
function stubBogusCognitoEnv() {
|
|
29
|
+
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', 'us-east-1_BOGUSENV')
|
|
30
|
+
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', 'bogusenvclientid')
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
// A `fetch` returning a valid identity document. The ids
|
|
34
|
-
//
|
|
33
|
+
// A `fetch` returning a valid identity document. The ids are what the pool must
|
|
34
|
+
// be built from — the ONLY source of Cognito coordinates now.
|
|
35
35
|
function mockFetchDocument(overrides: Record<string, unknown> = {}) {
|
|
36
36
|
const body = {
|
|
37
37
|
userPoolId: 'us-east-1_DOCPOOL',
|
|
@@ -45,7 +45,8 @@ function mockFetchDocument(overrides: Record<string, unknown> = {}) {
|
|
|
45
45
|
}) as unknown as typeof fetch
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
// A `fetch` that rejects (network error) —
|
|
48
|
+
// A `fetch` that rejects (network error) — the document is unreachable, so the
|
|
49
|
+
// sibling resolves to null (no env fallback).
|
|
49
50
|
function mockFetchUnreachable() {
|
|
50
51
|
global.fetch = vi.fn().mockRejectedValue(new Error('network down')) as unknown as typeof fetch
|
|
51
52
|
}
|
|
@@ -53,7 +54,7 @@ function mockFetchUnreachable() {
|
|
|
53
54
|
// Each test re-imports the module fresh: both the pool (auth.ts) and the
|
|
54
55
|
// resolved-identity Promise (identity.ts) are memoised at module scope, so a
|
|
55
56
|
// module instance left over from a previous test would carry that test's
|
|
56
|
-
// document
|
|
57
|
+
// document into the next one.
|
|
57
58
|
async function loadAuth() {
|
|
58
59
|
vi.resetModules()
|
|
59
60
|
return await import('@/lib/auth')
|
|
@@ -62,11 +63,9 @@ async function loadAuth() {
|
|
|
62
63
|
describe('getCurrentSession', () => {
|
|
63
64
|
beforeEach(() => {
|
|
64
65
|
vi.clearAllMocks()
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
// pre-runtime version did.
|
|
69
|
-
mockFetchUnreachable()
|
|
66
|
+
// The document is reachable, so these behaviour tests build the pool from
|
|
67
|
+
// the DOCUMENT (us-east-1_DOCPOOL / docclientid) — never from env.
|
|
68
|
+
mockFetchDocument()
|
|
70
69
|
})
|
|
71
70
|
|
|
72
71
|
afterEach(() => {
|
|
@@ -100,9 +99,9 @@ describe('getCurrentSession', () => {
|
|
|
100
99
|
})
|
|
101
100
|
})
|
|
102
101
|
|
|
103
|
-
// The core
|
|
104
|
-
//
|
|
105
|
-
//
|
|
102
|
+
// The core publishes its Cognito coordinates at runtime; the sibling is
|
|
103
|
+
// DOC-ONLY (#403 Stage 3) and resolves them from that document alone. These
|
|
104
|
+
// tests pin that the document is the sole source — env is never consulted.
|
|
106
105
|
describe('runtime core identity resolution', () => {
|
|
107
106
|
beforeEach(() => {
|
|
108
107
|
vi.clearAllMocks()
|
|
@@ -113,9 +112,9 @@ describe('runtime core identity resolution', () => {
|
|
|
113
112
|
vi.restoreAllMocks()
|
|
114
113
|
})
|
|
115
114
|
|
|
116
|
-
it('builds the pool from the published document
|
|
117
|
-
//
|
|
118
|
-
|
|
115
|
+
it('builds the pool from the published document', async () => {
|
|
116
|
+
// Bogus env is set to prove the document — not env — is the source.
|
|
117
|
+
stubBogusCognitoEnv()
|
|
119
118
|
mockFetchDocument()
|
|
120
119
|
const { getCurrentSession } = await loadAuth()
|
|
121
120
|
getCurrentUser.mockReturnValue(null)
|
|
@@ -131,55 +130,44 @@ describe('runtime core identity resolution', () => {
|
|
|
131
130
|
})
|
|
132
131
|
})
|
|
133
132
|
|
|
134
|
-
it('
|
|
135
|
-
|
|
133
|
+
it('resolves null and never builds the pool from env when the document is unreachable', async () => {
|
|
134
|
+
// Env is present but BOGUS: a DOC-ONLY sibling must ignore it entirely.
|
|
135
|
+
stubBogusCognitoEnv()
|
|
136
136
|
mockFetchUnreachable()
|
|
137
137
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
138
138
|
const { getCurrentSession } = await loadAuth()
|
|
139
139
|
getCurrentUser.mockReturnValue(null)
|
|
140
140
|
|
|
141
|
-
await getCurrentSession()
|
|
142
|
-
|
|
143
|
-
expect(poolConstructor).
|
|
144
|
-
UserPoolId: 'us-east-1_TESTPOOL',
|
|
145
|
-
ClientId: 'testclientid',
|
|
146
|
-
})
|
|
141
|
+
await expect(getCurrentSession()).resolves.toBeNull()
|
|
142
|
+
// Proves env is ignored: the pool was never constructed from the bogus env.
|
|
143
|
+
expect(poolConstructor).not.toHaveBeenCalled()
|
|
147
144
|
expect(warn).toHaveBeenCalledOnce()
|
|
148
|
-
expect(warn.mock.calls[0]?.[0]).toContain('DEGRADED')
|
|
149
145
|
})
|
|
150
146
|
|
|
151
|
-
it('
|
|
152
|
-
|
|
147
|
+
it('resolves null and never builds the pool when the document is served but missing ids', async () => {
|
|
148
|
+
stubBogusCognitoEnv()
|
|
153
149
|
// ok:true but the body lacks a clientId — treated as unusable.
|
|
154
150
|
mockFetchDocument({ clientId: '' })
|
|
155
151
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
156
152
|
const { getCurrentSession } = await loadAuth()
|
|
157
153
|
getCurrentUser.mockReturnValue(null)
|
|
158
154
|
|
|
159
|
-
await getCurrentSession()
|
|
160
|
-
|
|
161
|
-
expect(poolConstructor).toHaveBeenCalledWith({
|
|
162
|
-
UserPoolId: 'us-east-1_TESTPOOL',
|
|
163
|
-
ClientId: 'testclientid',
|
|
164
|
-
})
|
|
155
|
+
await expect(getCurrentSession()).resolves.toBeNull()
|
|
156
|
+
expect(poolConstructor).not.toHaveBeenCalled()
|
|
165
157
|
expect(warn).toHaveBeenCalledOnce()
|
|
166
158
|
})
|
|
167
159
|
|
|
168
|
-
it('resolves null
|
|
169
|
-
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', '')
|
|
170
|
-
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', '')
|
|
160
|
+
it('resolves null when the document is unreachable', async () => {
|
|
171
161
|
mockFetchUnreachable()
|
|
172
162
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
173
163
|
const { getCurrentSession } = await loadAuth()
|
|
174
164
|
|
|
175
165
|
await expect(getCurrentSession()).resolves.toBeNull()
|
|
176
166
|
expect(poolConstructor).not.toHaveBeenCalled()
|
|
177
|
-
|
|
178
|
-
expect(warn).not.toHaveBeenCalled()
|
|
167
|
+
expect(warn).toHaveBeenCalledOnce()
|
|
179
168
|
})
|
|
180
169
|
|
|
181
170
|
it('fetches the document at most once across multiple session reads (memoised)', async () => {
|
|
182
|
-
configureCognitoEnv()
|
|
183
171
|
mockFetchDocument()
|
|
184
172
|
const { getCurrentSession } = await loadAuth()
|
|
185
173
|
getCurrentUser.mockReturnValue(null)
|
|
@@ -210,14 +198,12 @@ describe('lazy pool construction', () => {
|
|
|
210
198
|
})
|
|
211
199
|
|
|
212
200
|
it('does not construct the pool merely by importing the module', async () => {
|
|
213
|
-
|
|
201
|
+
mockFetchDocument()
|
|
214
202
|
await loadAuth()
|
|
215
203
|
expect(poolConstructor).not.toHaveBeenCalled()
|
|
216
204
|
})
|
|
217
205
|
|
|
218
|
-
it('imports cleanly with no
|
|
219
|
-
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', '')
|
|
220
|
-
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', '')
|
|
206
|
+
it('imports cleanly with no fetch available (next build prerender)', async () => {
|
|
221
207
|
// Simulate `next build` prerendering `/` in Node with no fetch in scope.
|
|
222
208
|
const originalFetch = global.fetch
|
|
223
209
|
// @ts-expect-error deliberately remove fetch to mimic the Node prerender.
|
|
@@ -230,16 +216,14 @@ describe('lazy pool construction', () => {
|
|
|
230
216
|
}
|
|
231
217
|
})
|
|
232
218
|
|
|
233
|
-
it('resolves null instead of throwing when
|
|
234
|
-
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', '')
|
|
235
|
-
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', '')
|
|
219
|
+
it('resolves null instead of throwing when the document is unreachable', async () => {
|
|
236
220
|
const { getCurrentSession } = await loadAuth()
|
|
237
221
|
await expect(getCurrentSession()).resolves.toBeNull()
|
|
238
222
|
expect(poolConstructor).not.toHaveBeenCalled()
|
|
239
223
|
})
|
|
240
224
|
|
|
241
|
-
it('constructs the pool once, on first session read, from the
|
|
242
|
-
|
|
225
|
+
it('constructs the pool once, on first session read, from the document', async () => {
|
|
226
|
+
mockFetchDocument()
|
|
243
227
|
const { getCurrentSession } = await loadAuth()
|
|
244
228
|
getCurrentUser.mockReturnValue(null)
|
|
245
229
|
|
|
@@ -248,8 +232,8 @@ describe('lazy pool construction', () => {
|
|
|
248
232
|
|
|
249
233
|
expect(poolConstructor).toHaveBeenCalledTimes(1)
|
|
250
234
|
expect(poolConstructor).toHaveBeenCalledWith({
|
|
251
|
-
UserPoolId: 'us-east-
|
|
252
|
-
ClientId: '
|
|
235
|
+
UserPoolId: 'us-east-1_DOCPOOL',
|
|
236
|
+
ClientId: 'docclientid',
|
|
253
237
|
})
|
|
254
238
|
})
|
|
255
239
|
})
|
|
@@ -32,11 +32,12 @@ import { resolveCoreIdentity } from './identity'
|
|
|
32
32
|
// ---------------------------------------------------------------------------
|
|
33
33
|
// Module-private on purpose: the pool is an implementation detail of
|
|
34
34
|
// getCurrentSession(), not part of this sibling's auth surface. Its Cognito
|
|
35
|
-
// coordinates
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
35
|
+
// coordinates come from resolveCoreIdentity() (identity.ts), which resolves the
|
|
36
|
+
// core's runtime-published /.well-known/biffo-identity.json document. The
|
|
37
|
+
// sibling is now DOC-ONLY (#403 Stage 3): there is NO NEXT_PUBLIC_CORE_COGNITO_*
|
|
38
|
+
// env fallback. An unreachable document resolves to null → the caller treats it
|
|
39
|
+
// as "signed out" — see identity.ts for why runtime resolution kills the
|
|
40
|
+
// stale-baked-pool bug class (#403/#400).
|
|
40
41
|
//
|
|
41
42
|
// Constructed LAZILY, on first session read — never at module scope. The
|
|
42
43
|
// CognitoUserPool constructor throws ("Both UserPoolId and ClientId are
|
|
@@ -29,12 +29,13 @@
|
|
|
29
29
|
// this exactly ONE network request per page load, shared by every caller,
|
|
30
30
|
// rather than one per session read.
|
|
31
31
|
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
32
|
+
// A sibling is DOC-ONLY (#403 Stage 3): it carries NO CORE_COGNITO_* env vars —
|
|
33
|
+
// `biffo sibling create` no longer sets them and this module no longer reads
|
|
34
|
+
// them. When the document is unreachable, resolution returns null, and the
|
|
35
|
+
// caller (getCurrentSession) treats that as "no session" — the sibling redirects
|
|
36
|
+
// to the portal's login rather than trusting a stale baked value, because a
|
|
37
|
+
// stale value is exactly the bug (#400) this removes. A clean redirect beats a
|
|
38
|
+
// silent point at a dead pool.
|
|
38
39
|
// ---------------------------------------------------------------------------
|
|
39
40
|
|
|
40
41
|
export interface CoreIdentity {
|
|
@@ -54,16 +55,6 @@ const IDENTITY_DOCUMENT_PATH = '/.well-known/biffo-identity.json'
|
|
|
54
55
|
// callers before the fetch settles also coalesce onto the one request.
|
|
55
56
|
let cached: Promise<CoreIdentity | null> | null = null
|
|
56
57
|
|
|
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
58
|
// A parsed document counts only when it carries both ids non-empty; a document
|
|
68
59
|
// missing either is treated as unusable and triggers the fallback.
|
|
69
60
|
function identityFromDocument(data: unknown): CoreIdentity | null {
|
|
@@ -90,38 +81,31 @@ async function fetchCoreIdentity(): Promise<CoreIdentity | null> {
|
|
|
90
81
|
const identity = identityFromDocument(await res.json())
|
|
91
82
|
if (identity) return identity
|
|
92
83
|
}
|
|
93
|
-
//
|
|
94
|
-
// missing ids). Fall through to the degraded env fallback below.
|
|
84
|
+
// Served but unusable (non-ok status or missing ids). Fall through to null.
|
|
95
85
|
} catch {
|
|
96
86
|
// Network error, or `fetch` not available (e.g. `next build` prerendering
|
|
97
|
-
//
|
|
87
|
+
// in Node with no fetch — though resolution is lazy and never runs there).
|
|
88
|
+
// Fall through to null.
|
|
98
89
|
}
|
|
99
90
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
91
|
+
// The document was unreachable or unusable. A sibling has NO baked fallback
|
|
92
|
+
// (#403 Stage 3), so this resolves to null and the caller treats it as
|
|
93
|
+
// "signed out" — a clean redirect to the portal login beats trusting a stale
|
|
94
|
+
// local pool id, which is the exact bug (#400) this removes.
|
|
95
|
+
console.warn(
|
|
96
|
+
`[biffo] could not resolve the core identity document at ${IDENTITY_DOCUMENT_PATH}; ` +
|
|
97
|
+
'treating the visitor as signed out. If this persists, confirm the core is publishing it.',
|
|
98
|
+
)
|
|
99
|
+
return null
|
|
116
100
|
}
|
|
117
101
|
|
|
118
102
|
/**
|
|
119
|
-
* Resolve the core's Cognito identity at runtime
|
|
120
|
-
* `/.well-known/biffo-identity.json` document
|
|
121
|
-
*
|
|
103
|
+
* Resolve the core's Cognito identity at runtime from the published
|
|
104
|
+
* `/.well-known/biffo-identity.json` document. A sibling has no env fallback
|
|
105
|
+
* (#403 Stage 3).
|
|
122
106
|
*
|
|
123
107
|
* Memoised: at most one fetch per page load, shared by every caller. Returns
|
|
124
|
-
* null when
|
|
108
|
+
* null when the document is unreachable or does not supply both ids.
|
|
125
109
|
*/
|
|
126
110
|
export function resolveCoreIdentity(): Promise<CoreIdentity | null> {
|
|
127
111
|
cached ??= fetchCoreIdentity()
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command as
|
|
4
|
+
import { Command as Command25 } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/commands/core.ts
|
|
7
7
|
import { Command as Command4 } from "commander";
|
|
@@ -819,7 +819,7 @@ var GitHubAdapter = class {
|
|
|
819
819
|
} catch (err) {
|
|
820
820
|
if (err.status !== 404) throw err;
|
|
821
821
|
}
|
|
822
|
-
await new Promise((
|
|
822
|
+
await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
|
|
823
823
|
}
|
|
824
824
|
throw new Error(
|
|
825
825
|
`Branch "${branch}" not found in ${org}/${repo} after ${timeoutMs / 1e3}s \u2014 GitHub template generation may have stalled. Check the repository and re-run biffo init.`
|
|
@@ -834,7 +834,7 @@ var GitHubAdapter = class {
|
|
|
834
834
|
} catch (err) {
|
|
835
835
|
if (err.status !== 404) throw err;
|
|
836
836
|
}
|
|
837
|
-
await new Promise((
|
|
837
|
+
await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
|
|
838
838
|
}
|
|
839
839
|
throw new Error(
|
|
840
840
|
`Ref "${ref}" not found in ${org}/${repo} after ${timeoutMs / 1e3}s \u2014 GitHub template generation may have stalled. Check the repository and re-run biffo init.`
|
|
@@ -1039,7 +1039,7 @@ var GitHubAdapter = class {
|
|
|
1039
1039
|
}
|
|
1040
1040
|
if (status !== 404 || Date.now() >= deadline) throw err;
|
|
1041
1041
|
log.info("Branch protection endpoint not yet ready, retrying...");
|
|
1042
|
-
await new Promise((
|
|
1042
|
+
await new Promise((resolve18) => setTimeout(resolve18, protectionIntervalMs));
|
|
1043
1043
|
}
|
|
1044
1044
|
}
|
|
1045
1045
|
}
|
|
@@ -1082,6 +1082,28 @@ var GitHubAdapter = class {
|
|
|
1082
1082
|
}
|
|
1083
1083
|
}
|
|
1084
1084
|
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Read an environment-scoped Actions variable's value, or `null` if it isn't
|
|
1087
|
+
* set (a 404). The counterpart to `setEnvVariable`, used by
|
|
1088
|
+
* `biffo sibling check-identity` to read each sibling's baked-in
|
|
1089
|
+
* `CORE_COGNITO_USER_POOL_ID` and compare it against the core's live pool
|
|
1090
|
+
* (#400/#496). A 404 means the variable was never set on that environment,
|
|
1091
|
+
* which is a real, reportable state — not an error — so it maps to `null`.
|
|
1092
|
+
*/
|
|
1093
|
+
async getEnvVariable(org, repo, env, name) {
|
|
1094
|
+
try {
|
|
1095
|
+
const { data } = await this.octokit.request(
|
|
1096
|
+
"GET /repos/{owner}/{repo}/environments/{environment_name}/variables/{variable_name}",
|
|
1097
|
+
{ owner: org, repo, environment_name: env, variable_name: name }
|
|
1098
|
+
);
|
|
1099
|
+
return data.value;
|
|
1100
|
+
} catch (err) {
|
|
1101
|
+
if (err.status === 404) {
|
|
1102
|
+
return null;
|
|
1103
|
+
}
|
|
1104
|
+
throw err;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1085
1107
|
async setRepoVariable(org, repo, name, value) {
|
|
1086
1108
|
log.info(`Setting variable: ${name}`);
|
|
1087
1109
|
try {
|
|
@@ -1243,7 +1265,7 @@ var GitHubAdapter = class {
|
|
|
1243
1265
|
} catch (err) {
|
|
1244
1266
|
if (err.status !== 404 || Date.now() >= deadline) throw err;
|
|
1245
1267
|
log.info(`Workflow ${workflowId} not yet indexed by GitHub Actions, retrying...`);
|
|
1246
|
-
await new Promise((
|
|
1268
|
+
await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
|
|
1247
1269
|
}
|
|
1248
1270
|
}
|
|
1249
1271
|
}
|
|
@@ -1262,7 +1284,7 @@ var GitHubAdapter = class {
|
|
|
1262
1284
|
} catch (err) {
|
|
1263
1285
|
if (err.status !== 404 || Date.now() >= deadline) throw err;
|
|
1264
1286
|
log.info(`Workflow ${workflowId} not yet indexed by GitHub Actions, retrying...`);
|
|
1265
|
-
await new Promise((
|
|
1287
|
+
await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
|
|
1266
1288
|
}
|
|
1267
1289
|
}
|
|
1268
1290
|
}
|
|
@@ -1286,7 +1308,7 @@ var GitHubAdapter = class {
|
|
|
1286
1308
|
} else {
|
|
1287
1309
|
log.info(" Waiting for run to be queued...");
|
|
1288
1310
|
}
|
|
1289
|
-
await new Promise((
|
|
1311
|
+
await new Promise((resolve18) => setTimeout(resolve18, intervalMs));
|
|
1290
1312
|
}
|
|
1291
1313
|
throw new Error(
|
|
1292
1314
|
`Workflow ${workflowId} did not complete within ${timeoutMs / 1e3 / 60} minutes`
|
|
@@ -2831,7 +2853,7 @@ var AwsAdapter = class {
|
|
|
2831
2853
|
const code = err.Code;
|
|
2832
2854
|
if (code === "OperationAborted" && attempt < maxAttempts) {
|
|
2833
2855
|
log.info(` Waiting for S3 to release "${bucketName}"... (${attempt}/${maxAttempts})`);
|
|
2834
|
-
await new Promise((
|
|
2856
|
+
await new Promise((resolve18) => setTimeout(resolve18, retryDelayMs));
|
|
2835
2857
|
} else if (code === "OperationAborted") {
|
|
2836
2858
|
return false;
|
|
2837
2859
|
} else {
|
|
@@ -7258,14 +7280,268 @@ pluginCommand.addCommand(pluginSyncMigrationsCommand);
|
|
|
7258
7280
|
pluginCommand.addCommand(pluginInfoCommand);
|
|
7259
7281
|
|
|
7260
7282
|
// src/commands/sibling.ts
|
|
7283
|
+
import { Command as Command22 } from "commander";
|
|
7284
|
+
|
|
7285
|
+
// src/commands/sibling-check-identity.ts
|
|
7286
|
+
import { existsSync as existsSync29, readFileSync as readFileSync22 } from "fs";
|
|
7287
|
+
import { resolve as resolve17 } from "path";
|
|
7288
|
+
import chalk20 from "chalk";
|
|
7261
7289
|
import { Command as Command21 } from "commander";
|
|
7262
|
-
|
|
7290
|
+
|
|
7291
|
+
// src/lib/sibling-identity-check.ts
|
|
7292
|
+
function checkSiblingIdentity(envs) {
|
|
7293
|
+
const findings = [];
|
|
7294
|
+
for (const env of envs) {
|
|
7295
|
+
if (env.publishedDoc === null) {
|
|
7296
|
+
findings.push({
|
|
7297
|
+
environment: env.environment,
|
|
7298
|
+
subject: "published-document",
|
|
7299
|
+
kind: "published-doc-unreachable",
|
|
7300
|
+
expected: env.livePoolId,
|
|
7301
|
+
actual: null
|
|
7302
|
+
});
|
|
7303
|
+
} else {
|
|
7304
|
+
const docPool = env.publishedDoc.userPoolId ?? null;
|
|
7305
|
+
if (docPool !== env.livePoolId) {
|
|
7306
|
+
findings.push({
|
|
7307
|
+
environment: env.environment,
|
|
7308
|
+
subject: "published-document",
|
|
7309
|
+
kind: "published-doc-stale",
|
|
7310
|
+
expected: env.livePoolId,
|
|
7311
|
+
actual: docPool
|
|
7312
|
+
});
|
|
7313
|
+
}
|
|
7314
|
+
}
|
|
7315
|
+
for (const sibling of env.siblings) {
|
|
7316
|
+
if (sibling.coreCognitoUserPoolId === null) {
|
|
7317
|
+
findings.push({
|
|
7318
|
+
environment: env.environment,
|
|
7319
|
+
subject: sibling.projectName,
|
|
7320
|
+
kind: "sibling-var-missing",
|
|
7321
|
+
expected: env.livePoolId,
|
|
7322
|
+
actual: null
|
|
7323
|
+
});
|
|
7324
|
+
} else if (sibling.coreCognitoUserPoolId !== env.livePoolId) {
|
|
7325
|
+
findings.push({
|
|
7326
|
+
environment: env.environment,
|
|
7327
|
+
subject: sibling.projectName,
|
|
7328
|
+
kind: "sibling-backend-stale",
|
|
7329
|
+
expected: env.livePoolId,
|
|
7330
|
+
actual: sibling.coreCognitoUserPoolId
|
|
7331
|
+
});
|
|
7332
|
+
}
|
|
7333
|
+
}
|
|
7334
|
+
}
|
|
7335
|
+
return { ok: findings.length === 0, findings };
|
|
7336
|
+
}
|
|
7337
|
+
|
|
7338
|
+
// src/commands/sibling-check-identity.ts
|
|
7339
|
+
var VALID_ENVIRONMENTS2 = ["dev", "staging", "prod"];
|
|
7340
|
+
var SIBLING_CORE_POOL_VAR = "CORE_COGNITO_USER_POOL_ID";
|
|
7341
|
+
var siblingCheckIdentityCommand = new Command21("check-identity").description(
|
|
7342
|
+
"Detect when a core's Cognito pool has drifted from its published identity document or any sibling's baked-in CORE_COGNITO_USER_POOL_ID (#400). Run from the core repo; exits non-zero on drift so a scheduled/CI run goes red."
|
|
7343
|
+
).option("--env <environment>", "Only check this environment (default: dev, staging, prod)").option("-p, --project <name>", "Project name (overrides biffo.config.json in current directory)").option("-c, --config <path>", "Path to biffo.config.json").action(async (options) => {
|
|
7344
|
+
if (options.env && !VALID_ENVIRONMENTS2.includes(options.env)) {
|
|
7345
|
+
log.error(
|
|
7346
|
+
`Unknown environment: ${options.env}. Must be one of: ${VALID_ENVIRONMENTS2.join(", ")}`
|
|
7347
|
+
);
|
|
7348
|
+
process.exit(1);
|
|
7349
|
+
}
|
|
7350
|
+
const environments = options.env ? [options.env] : [...VALID_ENVIRONMENTS2];
|
|
7351
|
+
const config = await resolveConfig4(options);
|
|
7352
|
+
const { org, repo } = config.source_control.config;
|
|
7353
|
+
const awsConfig2 = config.cloud.config;
|
|
7354
|
+
const stateBucket = awsConfig2.tf_state_bucket ?? `${config.project.name}-terraform-state-${awsConfig2.account_id}`;
|
|
7355
|
+
const token = await resolveGithubToken3(true);
|
|
7356
|
+
const deps = {
|
|
7357
|
+
aws: new AwsAdapter(config),
|
|
7358
|
+
github: new GitHubAdapter(token),
|
|
7359
|
+
fetchIdentityDoc: fetchPublishedIdentity
|
|
7360
|
+
};
|
|
7361
|
+
console.log(chalk20.bold(`
|
|
7362
|
+
Biffo \u2014 Sibling identity check (${config.project.name})
|
|
7363
|
+
`));
|
|
7364
|
+
let result;
|
|
7365
|
+
try {
|
|
7366
|
+
result = await runCheckIdentity(deps, {
|
|
7367
|
+
coreOrg: org,
|
|
7368
|
+
coreRepo: repo,
|
|
7369
|
+
coreProjectName: config.project.name,
|
|
7370
|
+
stateBucket,
|
|
7371
|
+
environments
|
|
7372
|
+
});
|
|
7373
|
+
} catch (err) {
|
|
7374
|
+
if (err instanceof SiblingResolutionError) {
|
|
7375
|
+
log.error(`Could not enumerate siblings: ${err.message}`);
|
|
7376
|
+
} else {
|
|
7377
|
+
log.error(`Identity check failed: ${err.message}`);
|
|
7378
|
+
}
|
|
7379
|
+
process.exit(1);
|
|
7380
|
+
}
|
|
7381
|
+
printIdentityReport(result);
|
|
7382
|
+
if (!result.ok) process.exit(1);
|
|
7383
|
+
});
|
|
7384
|
+
async function runCheckIdentity(deps, params) {
|
|
7385
|
+
const siblings = await discoverSiblings(
|
|
7386
|
+
deps.github,
|
|
7387
|
+
params.coreOrg,
|
|
7388
|
+
params.coreRepo,
|
|
7389
|
+
params.coreProjectName
|
|
7390
|
+
);
|
|
7391
|
+
const envInputs = [];
|
|
7392
|
+
const skipped = [];
|
|
7393
|
+
for (const environment of params.environments) {
|
|
7394
|
+
let outputs;
|
|
7395
|
+
try {
|
|
7396
|
+
outputs = await deps.aws.readTerraformOutputs(
|
|
7397
|
+
params.stateBucket,
|
|
7398
|
+
`${environment}/terraform.tfstate`
|
|
7399
|
+
);
|
|
7400
|
+
} catch {
|
|
7401
|
+
skipped.push({
|
|
7402
|
+
environment,
|
|
7403
|
+
reason: "no Terraform state (core not deployed to this environment)"
|
|
7404
|
+
});
|
|
7405
|
+
continue;
|
|
7406
|
+
}
|
|
7407
|
+
const livePoolId = outputs["cognito_user_pool_id"];
|
|
7408
|
+
const portalUrl = outputs["portal_url"];
|
|
7409
|
+
if (!livePoolId || !portalUrl) {
|
|
7410
|
+
skipped.push({
|
|
7411
|
+
environment,
|
|
7412
|
+
reason: "Terraform outputs missing cognito_user_pool_id or portal_url"
|
|
7413
|
+
});
|
|
7414
|
+
continue;
|
|
7415
|
+
}
|
|
7416
|
+
const publishedDoc = await deps.fetchIdentityDoc(portalUrl);
|
|
7417
|
+
const envSiblings = siblings.filter(
|
|
7418
|
+
(s) => s.registered && s.repoState !== "gone" && s.environments.includes(environment)
|
|
7419
|
+
);
|
|
7420
|
+
const siblingInputs = await Promise.all(
|
|
7421
|
+
envSiblings.map(async (s) => ({
|
|
7422
|
+
projectName: s.projectName,
|
|
7423
|
+
coreCognitoUserPoolId: await deps.github.getEnvVariable(
|
|
7424
|
+
s.org,
|
|
7425
|
+
s.repo,
|
|
7426
|
+
environment,
|
|
7427
|
+
SIBLING_CORE_POOL_VAR
|
|
7428
|
+
)
|
|
7429
|
+
}))
|
|
7430
|
+
);
|
|
7431
|
+
envInputs.push({ environment, livePoolId, publishedDoc, siblings: siblingInputs });
|
|
7432
|
+
}
|
|
7433
|
+
const { ok, findings } = checkSiblingIdentity(envInputs);
|
|
7434
|
+
return { ok, findings, skipped };
|
|
7435
|
+
}
|
|
7436
|
+
var FINDING_LABEL = {
|
|
7437
|
+
"published-doc-unreachable": "published identity document unreachable",
|
|
7438
|
+
"published-doc-stale": "published identity document is stale",
|
|
7439
|
+
"sibling-var-missing": `sibling backend has no ${SIBLING_CORE_POOL_VAR}`,
|
|
7440
|
+
"sibling-backend-stale": `sibling backend ${SIBLING_CORE_POOL_VAR} is stale`
|
|
7441
|
+
};
|
|
7442
|
+
function printIdentityReport(result) {
|
|
7443
|
+
for (const s of result.skipped) {
|
|
7444
|
+
log.warn(`${s.environment}: skipped \u2014 ${s.reason}`);
|
|
7445
|
+
}
|
|
7446
|
+
if (result.ok) {
|
|
7447
|
+
log.success(
|
|
7448
|
+
chalk20.green(
|
|
7449
|
+
"\u2713 identity consistent \u2014 every published document and sibling backend matches the live pool"
|
|
7450
|
+
)
|
|
7451
|
+
);
|
|
7452
|
+
return;
|
|
7453
|
+
}
|
|
7454
|
+
log.error(chalk20.red(`\u2718 ${String(result.findings.length)} identity drift finding(s):`));
|
|
7455
|
+
for (const f of result.findings) {
|
|
7456
|
+
console.error(
|
|
7457
|
+
chalk20.red(
|
|
7458
|
+
` [${f.environment}] ${f.subject}: ${FINDING_LABEL[f.kind]}
|
|
7459
|
+
expected (live pool): ${f.expected}
|
|
7460
|
+
found: ${f.actual ?? "(unset)"}`
|
|
7461
|
+
)
|
|
7462
|
+
);
|
|
7463
|
+
}
|
|
7464
|
+
}
|
|
7465
|
+
async function fetchPublishedIdentity(portalUrl) {
|
|
7466
|
+
try {
|
|
7467
|
+
const base = portalUrl.replace(/\/+$/, "");
|
|
7468
|
+
const res = await fetch(`${base}/.well-known/biffo-identity.json`, {
|
|
7469
|
+
cache: "no-store"
|
|
7470
|
+
});
|
|
7471
|
+
if (!res.ok) return null;
|
|
7472
|
+
const data = await res.json();
|
|
7473
|
+
return { userPoolId: data?.userPoolId ?? null };
|
|
7474
|
+
} catch {
|
|
7475
|
+
return null;
|
|
7476
|
+
}
|
|
7477
|
+
}
|
|
7478
|
+
async function resolveConfig4(options) {
|
|
7479
|
+
if (options.config) {
|
|
7480
|
+
const raw = JSON.parse(readFileSync22(resolve17(options.config), "utf8"));
|
|
7481
|
+
const result = BiffoConfigSchema.safeParse(raw);
|
|
7482
|
+
if (!result.success) {
|
|
7483
|
+
log.error(`Invalid config at ${options.config}:`);
|
|
7484
|
+
result.error.issues.forEach((i) => log.error(` ${i.path.join(".")} \u2014 ${i.message}`));
|
|
7485
|
+
process.exit(1);
|
|
7486
|
+
}
|
|
7487
|
+
return result.data;
|
|
7488
|
+
}
|
|
7489
|
+
if (options.project) {
|
|
7490
|
+
const cfg = loadProjectConfig(options.project);
|
|
7491
|
+
if (!cfg) {
|
|
7492
|
+
log.error(
|
|
7493
|
+
`Project "${options.project}" not found in ~/.biffo/projects/. Run biffo init first or pass --config <path>.`
|
|
7494
|
+
);
|
|
7495
|
+
process.exit(1);
|
|
7496
|
+
}
|
|
7497
|
+
return cfg;
|
|
7498
|
+
}
|
|
7499
|
+
const localConfigPath = resolve17(process.cwd(), "biffo.config.json");
|
|
7500
|
+
if (existsSync29(localConfigPath)) {
|
|
7501
|
+
const raw = JSON.parse(readFileSync22(localConfigPath, "utf8"));
|
|
7502
|
+
const result = BiffoConfigSchema.safeParse(raw);
|
|
7503
|
+
if (result.success) return result.data;
|
|
7504
|
+
if (isTemplatePlaceholderConfig(raw)) {
|
|
7505
|
+
log.warn(
|
|
7506
|
+
`Ignoring ${localConfigPath} \u2014 it is the unsubstituted Biffo template placeholder, not this project's config.`
|
|
7507
|
+
);
|
|
7508
|
+
} else {
|
|
7509
|
+
log.error(`Invalid config at ${localConfigPath}:`);
|
|
7510
|
+
result.error.issues.forEach((i) => log.error(` ${i.path.join(".")} \u2014 ${i.message}`));
|
|
7511
|
+
log.error("Refusing to fall back to a saved project while a local config file is present.");
|
|
7512
|
+
log.error("Fix biffo.config.json, or pass --project <name> / --config <path> explicitly.");
|
|
7513
|
+
process.exit(1);
|
|
7514
|
+
}
|
|
7515
|
+
}
|
|
7516
|
+
const projects = listProjectConfigs();
|
|
7517
|
+
if (projects.length === 0) {
|
|
7518
|
+
log.error(
|
|
7519
|
+
"No biffo.config.json found in the current directory and no projects in ~/.biffo/projects/."
|
|
7520
|
+
);
|
|
7521
|
+
log.error("Run biffo init first, or pass --project <name> or --config <path>.");
|
|
7522
|
+
log.error(
|
|
7523
|
+
"A Biffo instance's resolved config is not committed to its repo \u2014 it lives in ~/.biffo/projects/ on the machine that ran biffo init. On a second machine, copy it there or pass it with --config <path>."
|
|
7524
|
+
);
|
|
7525
|
+
process.exit(1);
|
|
7526
|
+
}
|
|
7527
|
+
if (projects.length === 1) {
|
|
7528
|
+
log.info(`Using project: ${projects[0].project.name}`);
|
|
7529
|
+
return projects[0];
|
|
7530
|
+
}
|
|
7531
|
+
log.error("Multiple projects found in ~/.biffo/projects/. Pass --project <name> to choose one:");
|
|
7532
|
+
for (const p of projects) log.error(` ${p.project.name}`);
|
|
7533
|
+
process.exit(1);
|
|
7534
|
+
}
|
|
7535
|
+
|
|
7536
|
+
// src/commands/sibling.ts
|
|
7537
|
+
var siblingCommand = new Command22("sibling").description(
|
|
7263
7538
|
"Create and manage sibling apps that share a Biffo core project (ADR-0007)"
|
|
7264
7539
|
);
|
|
7265
7540
|
siblingCommand.addCommand(siblingCreateCommand);
|
|
7541
|
+
siblingCommand.addCommand(siblingCheckIdentityCommand);
|
|
7266
7542
|
|
|
7267
7543
|
// src/commands/check.ts
|
|
7268
|
-
import { Command as
|
|
7544
|
+
import { Command as Command23 } from "commander";
|
|
7269
7545
|
|
|
7270
7546
|
// src/scripts/check-core-ownership.ts
|
|
7271
7547
|
import { execa as execa5 } from "execa";
|
|
@@ -7291,8 +7567,8 @@ async function runOwnershipCheck(argv) {
|
|
|
7291
7567
|
const { stdout } = await execa5("git", ["diff", "--cached", "--name-status"], { cwd: root });
|
|
7292
7568
|
({ changed: changedFiles, deleted: deletedFiles } = parseNameStatus(stdout));
|
|
7293
7569
|
if (messageFile) {
|
|
7294
|
-
const { readFileSync:
|
|
7295
|
-
if (
|
|
7570
|
+
const { readFileSync: readFileSync24, existsSync: existsSync31 } = await import("fs");
|
|
7571
|
+
if (existsSync31(messageFile)) commitMessage = readFileSync24(messageFile, "utf8");
|
|
7296
7572
|
}
|
|
7297
7573
|
} else {
|
|
7298
7574
|
const base = process.env["GITHUB_BASE_REF"] ?? args[0];
|
|
@@ -7396,7 +7672,7 @@ ${BOLD}If the divergence is deliberate${OFF}
|
|
|
7396
7672
|
import { execa as execa6 } from "execa";
|
|
7397
7673
|
|
|
7398
7674
|
// src/lib/plugin-terraform-guard.ts
|
|
7399
|
-
import { existsSync as
|
|
7675
|
+
import { existsSync as existsSync30, readFileSync as readFileSync23, readdirSync as readdirSync12 } from "fs";
|
|
7400
7676
|
import { dirname as dirname9, join as join30, relative as relative6, sep as sep3 } from "path";
|
|
7401
7677
|
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".worktrees", "dist", ".venv", "__pycache__"]);
|
|
7402
7678
|
var PLUGIN_MANIFEST_FILE2 = "biffo.plugin.json";
|
|
@@ -7424,7 +7700,7 @@ function findPluginManifests(root) {
|
|
|
7424
7700
|
function readSubscriptions(absManifestPath) {
|
|
7425
7701
|
let parsed;
|
|
7426
7702
|
try {
|
|
7427
|
-
parsed = JSON.parse(
|
|
7703
|
+
parsed = JSON.parse(readFileSync23(absManifestPath, "utf8"));
|
|
7428
7704
|
} catch {
|
|
7429
7705
|
return null;
|
|
7430
7706
|
}
|
|
@@ -7439,14 +7715,14 @@ function readSubscriptions(absManifestPath) {
|
|
|
7439
7715
|
}
|
|
7440
7716
|
function checkPluginTerraform(root) {
|
|
7441
7717
|
const violations = [];
|
|
7442
|
-
const coreManifest =
|
|
7718
|
+
const coreManifest = existsSync30(join30(root, CORE_MANIFEST_FILE)) ? readCoreManifest(root) : null;
|
|
7443
7719
|
for (const manifest of findPluginManifests(root)) {
|
|
7444
7720
|
if (coreManifest && !isTemplateOwned(manifest, coreManifest)) continue;
|
|
7445
7721
|
const absManifest = join30(root, manifest);
|
|
7446
7722
|
const subscriptions = readSubscriptions(absManifest);
|
|
7447
7723
|
if (subscriptions === null) continue;
|
|
7448
7724
|
const pluginDir2 = dirname9(absManifest);
|
|
7449
|
-
if (
|
|
7725
|
+
if (existsSync30(join30(pluginDir2, "terraform"))) continue;
|
|
7450
7726
|
const relPluginDir = relative6(root, pluginDir2).split(sep3).join("/");
|
|
7451
7727
|
violations.push({
|
|
7452
7728
|
manifest,
|
|
@@ -7565,7 +7841,7 @@ async function runReleaseSubjectCheck(argv) {
|
|
|
7565
7841
|
}
|
|
7566
7842
|
|
|
7567
7843
|
// src/commands/check.ts
|
|
7568
|
-
var checkCommand = new
|
|
7844
|
+
var checkCommand = new Command23("check").description(
|
|
7569
7845
|
"Repo guards (ownership, release subject, plugin terraform) \u2014 run in CI and git hooks"
|
|
7570
7846
|
);
|
|
7571
7847
|
checkCommand.command("ownership").description("Refuse changes to template-owned paths in an instance (#370)").argument("[base]", "Base branch to diff against; defaults to $GITHUB_BASE_REF").option("--staged <messageFile>", "Check staged changes instead of a branch diff (commit hook)").allowExcessArguments(true).action(async () => {
|
|
@@ -7585,17 +7861,17 @@ function rawArgsAfter(subcommand) {
|
|
|
7585
7861
|
// src/commands/teardown.ts
|
|
7586
7862
|
import { execSync as execSync7 } from "child_process";
|
|
7587
7863
|
import { GetCallerIdentityCommand as GetCallerIdentityCommand3, STSClient as STSClient3 } from "@aws-sdk/client-sts";
|
|
7588
|
-
import
|
|
7589
|
-
import { Command as
|
|
7864
|
+
import chalk21 from "chalk";
|
|
7865
|
+
import { Command as Command24 } from "commander";
|
|
7590
7866
|
import inquirer8 from "inquirer";
|
|
7591
|
-
var teardownCommand = new
|
|
7867
|
+
var teardownCommand = new Command24("teardown").description(
|
|
7592
7868
|
"Destroy all infrastructure then remove the repo, IAM role, and state bucket \u2014 single command"
|
|
7593
7869
|
).option("--project <name>", "Project name to tear down (reads session if omitted)").option("--skip-destroy", "Skip terraform destroy (only use if infrastructure is already gone)").option(
|
|
7594
7870
|
"--confirm <name>",
|
|
7595
7871
|
"Pre-confirm by supplying the project name (the scriptable form of the typed confirmation)"
|
|
7596
7872
|
).option("-y, --yes", "Skip the typed project-name confirmation entirely").action(
|
|
7597
7873
|
async (options) => {
|
|
7598
|
-
console.log(
|
|
7874
|
+
console.log(chalk21.bold("\n Biffo \u2014 Teardown\n"));
|
|
7599
7875
|
const githubToken = resolveGithubToken4();
|
|
7600
7876
|
const sts = new STSClient3({});
|
|
7601
7877
|
const { Account: accountId } = await sts.send(new GetCallerIdentityCommand3({}));
|
|
@@ -7617,7 +7893,7 @@ var teardownCommand = new Command23("teardown").description(
|
|
|
7617
7893
|
adminEmail = session.config.admin?.email ?? "noop@example.com";
|
|
7618
7894
|
adminUsername = session.config.admin?.username ?? "noop";
|
|
7619
7895
|
domain = session.config.project.domain ?? "";
|
|
7620
|
-
console.log(
|
|
7896
|
+
console.log(chalk21.yellow(" Loaded session for: ") + chalk21.bold(projectName));
|
|
7621
7897
|
if (org && repo) console.log(` Repository: ${org}/${repo}`);
|
|
7622
7898
|
console.log();
|
|
7623
7899
|
} else if (savedConfig) {
|
|
@@ -7630,7 +7906,7 @@ var teardownCommand = new Command23("teardown").description(
|
|
|
7630
7906
|
adminEmail = savedConfig.admin.email;
|
|
7631
7907
|
adminUsername = savedConfig.admin.username;
|
|
7632
7908
|
domain = resolveDnsConfig(savedConfig).domain;
|
|
7633
|
-
console.log(
|
|
7909
|
+
console.log(chalk21.yellow(" Loaded config for: ") + chalk21.bold(projectName));
|
|
7634
7910
|
console.log(` Repository: ${org}/${repo}`);
|
|
7635
7911
|
console.log();
|
|
7636
7912
|
} else {
|
|
@@ -7701,35 +7977,35 @@ var teardownCommand = new Command23("teardown").description(
|
|
|
7701
7977
|
throw err;
|
|
7702
7978
|
}
|
|
7703
7979
|
}
|
|
7704
|
-
console.log(
|
|
7980
|
+
console.log(chalk21.red.bold(" This will permanently delete:\n"));
|
|
7705
7981
|
if (deployedEnvs.length > 0 || hasGlobal) {
|
|
7706
|
-
console.log(
|
|
7982
|
+
console.log(chalk21.red(" Infrastructure (via GitHub Actions terraform destroy):"));
|
|
7707
7983
|
for (const env of deployedEnvs) {
|
|
7708
7984
|
console.log(
|
|
7709
|
-
` ${
|
|
7985
|
+
` ${chalk21.red("\u2717")} ${env} \u2014 VPC, RDS, Lambda, Cognito, CloudFront, EventBridge`
|
|
7710
7986
|
);
|
|
7711
7987
|
}
|
|
7712
7988
|
if (hasGlobal) {
|
|
7713
|
-
console.log(` ${
|
|
7989
|
+
console.log(` ${chalk21.red("\u2717")} global \u2014 Route 53 hosted zone, ACM certificate`);
|
|
7714
7990
|
}
|
|
7715
7991
|
console.log();
|
|
7716
7992
|
}
|
|
7717
7993
|
for (const line of formatSiblingPlan(siblings, options.skipDestroy === true)) {
|
|
7718
7994
|
console.log(line);
|
|
7719
7995
|
}
|
|
7720
|
-
console.log(
|
|
7721
|
-
console.log(` ${
|
|
7996
|
+
console.log(chalk21.red(" Biffo resources:"));
|
|
7997
|
+
console.log(` ${chalk21.red("\u2717")} GitHub repository ${chalk21.bold(`${org}/${repo}`)}`);
|
|
7722
7998
|
console.log(
|
|
7723
|
-
` ${
|
|
7999
|
+
` ${chalk21.red("\u2717")} IAM role ${chalk21.bold(`biffo-github-actions-${projectName}`)}`
|
|
7724
8000
|
);
|
|
7725
8001
|
console.log(
|
|
7726
|
-
` ${
|
|
8002
|
+
` ${chalk21.red("\u2717")} S3 bucket ${chalk21.bold(stateBucket)} (all versions)`
|
|
7727
8003
|
);
|
|
7728
|
-
console.log(` ${
|
|
8004
|
+
console.log(` ${chalk21.red("\u2717")} Local session file`);
|
|
7729
8005
|
if (options.skipDestroy) {
|
|
7730
8006
|
console.log();
|
|
7731
8007
|
console.log(
|
|
7732
|
-
|
|
8008
|
+
chalk21.yellow(
|
|
7733
8009
|
" --skip-destroy: NO terraform destroy runs, for this project or any sibling.\n Everything above is deleted, but any infrastructure still standing is left\n standing \u2014 and, with the state buckets gone, orphaned."
|
|
7734
8010
|
)
|
|
7735
8011
|
);
|
|
@@ -7917,30 +8193,30 @@ async function assertSiblingsAreDestroyable(github, siblings) {
|
|
|
7917
8193
|
}
|
|
7918
8194
|
function formatSiblingPlan(siblings, skipDestroy) {
|
|
7919
8195
|
if (siblings.length === 0) return [];
|
|
7920
|
-
const lines = [
|
|
8196
|
+
const lines = [chalk21.red(` Sibling apps (${siblings.length}) \u2014 ADR-0007:`)];
|
|
7921
8197
|
for (const s of siblings) {
|
|
7922
8198
|
const envs = s.environments.join(", ");
|
|
7923
8199
|
if (s.repoState === "gone") {
|
|
7924
8200
|
lines.push(
|
|
7925
|
-
` ${
|
|
8201
|
+
` ${chalk21.yellow("!")} ${chalk21.bold(`${s.org}/${s.repo}`)} \u2014 repo already deleted; its ${envs} infrastructure CANNOT be destroyed and will be left standing`
|
|
7926
8202
|
);
|
|
7927
8203
|
continue;
|
|
7928
8204
|
}
|
|
7929
8205
|
lines.push(
|
|
7930
|
-
` ${
|
|
8206
|
+
` ${chalk21.red("\u2717")} GitHub repository ${chalk21.bold(`${s.org}/${s.repo}`)} ` + (s.pathPrefix === ROOT_SIBLING_NAME ? "(the application, routed at /)" : `(routed at /${s.pathPrefix})`)
|
|
7931
8207
|
);
|
|
7932
8208
|
lines.push(
|
|
7933
|
-
` ${
|
|
8209
|
+
` ${chalk21.red("\u2717")} ${skipDestroy ? "infrastructure NOT destroyed (--skip-destroy)" : `${envs} infrastructure \u2014 S3 site bucket, Lambda, API Gateway`}`
|
|
7934
8210
|
);
|
|
7935
8211
|
lines.push(
|
|
7936
|
-
` ${
|
|
8212
|
+
` ${chalk21.red("\u2717")} IAM role ${chalk21.bold(`biffo-github-actions-${s.projectName}`)}`
|
|
7937
8213
|
);
|
|
7938
8214
|
lines.push(
|
|
7939
|
-
` ${
|
|
8215
|
+
` ${chalk21.red("\u2717")} S3 bucket ${chalk21.bold(`${s.projectName}-terraform-state-${s.accountId}`)}`
|
|
7940
8216
|
);
|
|
7941
8217
|
if (s.pendingRegistrationPr !== void 0) {
|
|
7942
8218
|
lines.push(
|
|
7943
|
-
|
|
8219
|
+
chalk21.dim(` registration PR #${s.pendingRegistrationPr} is still open \u2014 never routed`)
|
|
7944
8220
|
);
|
|
7945
8221
|
}
|
|
7946
8222
|
}
|
|
@@ -7978,7 +8254,7 @@ async function confirmTeardown(projectName, options) {
|
|
|
7978
8254
|
{
|
|
7979
8255
|
type: "input",
|
|
7980
8256
|
name: "confirm",
|
|
7981
|
-
message: `Type ${
|
|
8257
|
+
message: `Type ${chalk21.bold(projectName)} to confirm:`
|
|
7982
8258
|
}
|
|
7983
8259
|
]);
|
|
7984
8260
|
return confirm === projectName;
|
|
@@ -7996,7 +8272,7 @@ function resolveGithubToken4() {
|
|
|
7996
8272
|
}
|
|
7997
8273
|
|
|
7998
8274
|
// src/index.ts
|
|
7999
|
-
var program = new
|
|
8275
|
+
var program = new Command25();
|
|
8000
8276
|
function cliVersion() {
|
|
8001
8277
|
try {
|
|
8002
8278
|
return getLatestCoreVersion();
|