@biffo/cli 0.273.13 → 0.274.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/plugin-template/web-admin/_gitignore +5 -0
- package/_skeletons/plugin-template/web-admin/eslint.config.js +8 -0
- package/_skeletons/plugin-template/web-admin/index.html +12 -0
- package/_skeletons/plugin-template/web-admin/package.json +40 -0
- package/_skeletons/plugin-template/web-admin/src/App.test.tsx +22 -0
- package/_skeletons/plugin-template/web-admin/src/App.tsx +50 -0
- package/_skeletons/plugin-template/web-admin/src/base-path.test.ts +65 -0
- package/_skeletons/plugin-template/web-admin/src/index.css +40 -0
- package/_skeletons/plugin-template/web-admin/src/lib/api-core.ts +61 -0
- package/_skeletons/plugin-template/web-admin/src/lib/api.ts +40 -0
- package/_skeletons/plugin-template/web-admin/src/lib/auth.ts +82 -0
- package/_skeletons/plugin-template/web-admin/src/lib/cognito-hygiene.ts +60 -0
- package/_skeletons/plugin-template/web-admin/src/lib/identity.ts +73 -0
- package/_skeletons/plugin-template/web-admin/src/main.tsx +11 -0
- package/_skeletons/plugin-template/web-admin/src/test-setup.ts +1 -0
- package/_skeletons/plugin-template/web-admin/tsconfig.json +21 -0
- package/_skeletons/plugin-template/web-admin/vite.config.ts +28 -0
- package/package.json +1 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>example-plugin — Admin</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "example-plugin-admin",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc -b && vite build",
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"lint": "eslint .",
|
|
11
|
+
"test": "vitest run"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@biffo/design-tokens": "^0.152.0",
|
|
15
|
+
"amazon-cognito-identity-js": "^6.3.12",
|
|
16
|
+
"react": "^19.0.0",
|
|
17
|
+
"react-dom": "^19.0.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@testing-library/dom": "^10.4.1",
|
|
21
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
22
|
+
"@testing-library/react": "^16.3.2",
|
|
23
|
+
"@testing-library/user-event": "^14.6.1",
|
|
24
|
+
"@types/node": "^26.1.2",
|
|
25
|
+
"@types/react": "^19.0.7",
|
|
26
|
+
"@types/react-dom": "^19.0.3",
|
|
27
|
+
"@vitejs/plugin-react": "^4.7.0",
|
|
28
|
+
"eslint": "^9.18.0",
|
|
29
|
+
"jsdom": "^30.0.1",
|
|
30
|
+
"typescript": "^5.7.3",
|
|
31
|
+
"typescript-eslint": "^8.20.0",
|
|
32
|
+
"vite": "^6.4.3",
|
|
33
|
+
"vitest": "^4.1.0"
|
|
34
|
+
},
|
|
35
|
+
"pnpm": {
|
|
36
|
+
"overrides": {
|
|
37
|
+
"brace-expansion@<5.0.9": ">=5.0.9"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import App from './App'
|
|
5
|
+
import { __resetCoreIdentityForTests } from './lib/identity'
|
|
6
|
+
import { __resetUserPoolForTests } from './lib/auth'
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.restoreAllMocks()
|
|
10
|
+
__resetCoreIdentityForTests()
|
|
11
|
+
__resetUserPoolForTests()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('App', () => {
|
|
15
|
+
it('shows the signed-out message when the identity document is unreachable', async () => {
|
|
16
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network error'))
|
|
17
|
+
|
|
18
|
+
render(<App />)
|
|
19
|
+
|
|
20
|
+
expect(await screen.findByText(/not signed in/i)).toBeInTheDocument()
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { getCurrentSession } from './lib/auth'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Starter admin surface. It proves the SHARED-SESSION INVARIANT works — reads
|
|
7
|
+
* the portal's Cognito session, shows a signed-in/signed-out state, nothing
|
|
8
|
+
* more — and is meant to be replaced with this plugin's own admin UI.
|
|
9
|
+
*
|
|
10
|
+
* Wire real screens against `./lib/api`'s starter `request()` helper, which
|
|
11
|
+
* already carries the `Authorization: Bearer` header and error handling this
|
|
12
|
+
* plugin's calls will need; the endpoints themselves are this plugin's own.
|
|
13
|
+
*/
|
|
14
|
+
export default function App() {
|
|
15
|
+
const [signedIn, setSignedIn] = useState<boolean | null>(null)
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let cancelled = false
|
|
19
|
+
void getCurrentSession().then((session) => {
|
|
20
|
+
if (cancelled) return
|
|
21
|
+
setSignedIn(session != null)
|
|
22
|
+
})
|
|
23
|
+
return () => {
|
|
24
|
+
cancelled = true
|
|
25
|
+
}
|
|
26
|
+
}, [])
|
|
27
|
+
|
|
28
|
+
if (signedIn === false) {
|
|
29
|
+
return (
|
|
30
|
+
<main className="page">
|
|
31
|
+
<h1>example-plugin — Admin</h1>
|
|
32
|
+
<p className="error">Not signed in. Open this from the Biffo portal.</p>
|
|
33
|
+
</main>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<main className="page">
|
|
39
|
+
<h1>example-plugin — Admin</h1>
|
|
40
|
+
{signedIn === null ? (
|
|
41
|
+
<p className="muted">Loading…</p>
|
|
42
|
+
) : (
|
|
43
|
+
<p className="muted">
|
|
44
|
+
Signed in via the shared portal session. Replace this screen with the plugin's own admin
|
|
45
|
+
UI.
|
|
46
|
+
</p>
|
|
47
|
+
)}
|
|
48
|
+
</main>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Vite `base` must name THIS plugin, and the built HTML's asset
|
|
8
|
+
* references must actually live under it.
|
|
9
|
+
*
|
|
10
|
+
* Ported from idea-scout's guard (biffo-template#1492), which exists because
|
|
11
|
+
* idea-scout's own vite.config.ts was pasted from ideation's and kept
|
|
12
|
+
* ideation's base — 503, blank page, and every other local gate (eslint, tsc,
|
|
13
|
+
* unit tests, `vite build` itself) passed, because `base` only affects the
|
|
14
|
+
* URLs inside the emitted HTML. It was found by loading the deployed page and
|
|
15
|
+
* reading the network log — not a check that runs on every PR. This is that
|
|
16
|
+
* check, generalised so a plugin scaffolded from this skeleton inherits it
|
|
17
|
+
* automatically rather than every plugin author re-discovering the bug.
|
|
18
|
+
*
|
|
19
|
+
* Reads the plugin name OUT of vite.config.ts itself rather than hardcoding
|
|
20
|
+
* it, so this file needs no token substitution at scaffold time and stays
|
|
21
|
+
* correct whatever `biffo plugin create` rewrites `base` to.
|
|
22
|
+
*/
|
|
23
|
+
const ROOT = join(__dirname, '..')
|
|
24
|
+
|
|
25
|
+
function pluginFromConfig(config: string): string {
|
|
26
|
+
const match = config.match(/base:\s*'\/api\/v1\/plugins\/([^/]+)\/admin\/'/)
|
|
27
|
+
expect(match, "no `base: '/api/v1/plugins/<name>/admin/'` found in vite.config.ts").not.toBeNull()
|
|
28
|
+
return match![1]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('vite base path', () => {
|
|
32
|
+
const config = readFileSync(join(ROOT, 'vite.config.ts'), 'utf8')
|
|
33
|
+
const plugin = pluginFromConfig(config)
|
|
34
|
+
|
|
35
|
+
it('is the full API Gateway path for THIS plugin', () => {
|
|
36
|
+
expect(config).toContain(`base: '/api/v1/plugins/${plugin}/admin/'`)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// There is deliberately NO "the config mentions no other plugin" test. That
|
|
40
|
+
// shape (ban a token) rejects the correct fix as readily as the bug: a
|
|
41
|
+
// comment legitimately naming a DIFFERENT plugin, to explain this exact
|
|
42
|
+
// trap, is exactly what this file's own header does. Assert the property,
|
|
43
|
+
// not the absence of a string.
|
|
44
|
+
|
|
45
|
+
it('the built index.html requests assets under that base', () => {
|
|
46
|
+
// Skipped when dist/ is absent (a source checkout, not a built one). CI
|
|
47
|
+
// runs `build` before `test` — but if it ever does not, this must not
|
|
48
|
+
// pass silently, so the skip is explicit and visible.
|
|
49
|
+
let html: string
|
|
50
|
+
try {
|
|
51
|
+
html = readFileSync(join(ROOT, 'dist', 'index.html'), 'utf8')
|
|
52
|
+
} catch {
|
|
53
|
+
console.warn('dist/index.html absent — build not run; base-path check skipped')
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
const srcs = [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1])
|
|
57
|
+
const assetRefs = srcs.filter((s) => s.includes('/assets/'))
|
|
58
|
+
expect(assetRefs.length, 'no asset references in the built HTML').toBeGreaterThan(0)
|
|
59
|
+
for (const ref of assetRefs) {
|
|
60
|
+
expect(ref.startsWith(`/api/v1/plugins/${plugin}/admin/`), `bad asset path: ${ref}`).toBe(
|
|
61
|
+
true,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* Shares the design tokens the rest of the estate uses so a plugin admin panel
|
|
2
|
+
does not become its own brand blue (the drift @biffo/design-tokens was
|
|
3
|
+
created to end). */
|
|
4
|
+
@import '@biffo/design-tokens/tokens.css';
|
|
5
|
+
|
|
6
|
+
* {
|
|
7
|
+
box-sizing: border-box;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
body {
|
|
11
|
+
margin: 0;
|
|
12
|
+
font-family: var(--font-sans, system-ui, sans-serif);
|
|
13
|
+
color: var(--text, #1b1c1f);
|
|
14
|
+
background: var(--bg, #f7f8fa);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.page {
|
|
18
|
+
max-width: 60rem;
|
|
19
|
+
margin: 0 auto;
|
|
20
|
+
padding: 2rem 1.5rem 4rem;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
h1 {
|
|
24
|
+
font-size: 1.55rem;
|
|
25
|
+
margin: 0 0 0.35rem;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.muted {
|
|
29
|
+
color: var(--text-muted, #5b6070);
|
|
30
|
+
font-size: 0.9rem;
|
|
31
|
+
margin: 0 0 1.25rem;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.error {
|
|
35
|
+
background: var(--state-negative-soft, #fdeaea);
|
|
36
|
+
color: var(--state-negative, #8c2b2b);
|
|
37
|
+
padding: 0.6rem 0.8rem;
|
|
38
|
+
border-radius: 6px;
|
|
39
|
+
font-size: 0.9rem;
|
|
40
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Shared admin-API request core, DISTRIBUTED across every plugin's web-admin
|
|
2
|
+
// (biffo-template#1492). It carries exactly the parts that were rewritten
|
|
3
|
+
// identically, badly, by hand in three plugins: the fetch wrapper, the bearer
|
|
4
|
+
// auth header, error handling, and per-request base resolution. It carries
|
|
5
|
+
// NONE of a plugin's own endpoints — those are plugin-owned and live beside
|
|
6
|
+
// this module in each repo's own api.ts (see this skeleton's starter copy).
|
|
7
|
+
//
|
|
8
|
+
// `getIdToken` is `() => string | null | Promise<string | null>`, matching
|
|
9
|
+
// `./auth.ts`'s `getFreshIdToken()`, and MUST be called fresh per request —
|
|
10
|
+
// never snapshotted. `CognitoUserSession` is an immutable value object, so a
|
|
11
|
+
// client built from a token captured once at mount sends whatever was left on
|
|
12
|
+
// the token's remaining lifetime at that instant; once it lapses every call
|
|
13
|
+
// 401s for the rest of the page's life (ideation#69). idea-scout's original
|
|
14
|
+
// api.ts used a synchronous `token: () => string | null` for exactly this
|
|
15
|
+
// reason — it could not await a fresh resolution — and shipped the bug. This
|
|
16
|
+
// core is built async from the start so that mistake cannot recur here.
|
|
17
|
+
export class ApiError extends Error {
|
|
18
|
+
constructor(
|
|
19
|
+
public readonly status: number,
|
|
20
|
+
message: string,
|
|
21
|
+
) {
|
|
22
|
+
super(message)
|
|
23
|
+
this.name = 'ApiError'
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type GetIdToken = () => string | null | Promise<string | null>
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build a `request<T>(method, path, body?, base?)` function bound to a token
|
|
31
|
+
* source and a default base. A plugin's own api.ts calls this once per
|
|
32
|
+
* `createApi()` and defines its endpoints on top of the result — see the
|
|
33
|
+
* starter `api.ts` in this same directory for the worked shape.
|
|
34
|
+
*/
|
|
35
|
+
export function createRequest(getIdToken: GetIdToken, defaultBase: string) {
|
|
36
|
+
return async function request<T>(
|
|
37
|
+
method: string,
|
|
38
|
+
path: string,
|
|
39
|
+
body?: unknown,
|
|
40
|
+
base: string = defaultBase,
|
|
41
|
+
): Promise<T> {
|
|
42
|
+
const token = await getIdToken()
|
|
43
|
+
const res = await fetch(`${base}${path}`, {
|
|
44
|
+
method,
|
|
45
|
+
headers: {
|
|
46
|
+
'Content-Type': 'application/json',
|
|
47
|
+
...(token != null ? { Authorization: `Bearer ${token}` } : {}),
|
|
48
|
+
},
|
|
49
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
50
|
+
})
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
// Read the body for the reason: Core returns a JSON detail for a
|
|
53
|
+
// permission failure, and "403" alone tells an admin nothing about
|
|
54
|
+
// which rule bit.
|
|
55
|
+
const detail = await res.text().catch(() => res.statusText)
|
|
56
|
+
throw new ApiError(res.status, detail || res.statusText)
|
|
57
|
+
}
|
|
58
|
+
if (res.status === 204) return undefined as T
|
|
59
|
+
return (await res.json()) as T
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// STARTER admin API client — wires this plugin's own endpoints on top of the
|
|
2
|
+
// DISTRIBUTED shared request core in ./api-core.ts, then gets replaced with
|
|
3
|
+
// this plugin's real resources. See api-core.ts's header for why the core is
|
|
4
|
+
// shared and this file is not: every existing plugin's api.ts mixed the same
|
|
5
|
+
// fetch/auth/error core with a wholly different endpoint surface (idea-scout:
|
|
6
|
+
// build-types/agents/models; ideation: chat-agents; marketing: campaigns), so
|
|
7
|
+
// only the core distributes — this file is this plugin's own and is never
|
|
8
|
+
// synced (biffo-template#1492).
|
|
9
|
+
//
|
|
10
|
+
// The base MUST be under `/api/v1/plugins/<this-plugin-slug>` — not
|
|
11
|
+
// `/api/v1/admin/*`. The CDN forwards `/api/v1/plugins/*` to the plugin host;
|
|
12
|
+
// everything else falls through to the portal origin, which answers with its
|
|
13
|
+
// own HTML shell and a 403 that reads as "no data" rather than "wrong route"
|
|
14
|
+
// (biffo-template#1492, ideation#69 in miniature).
|
|
15
|
+
import { createRequest } from './api-core'
|
|
16
|
+
import { getFreshIdToken } from './auth'
|
|
17
|
+
|
|
18
|
+
export { ApiError } from './api-core'
|
|
19
|
+
|
|
20
|
+
const BASE = '/api/v1/plugins/example-plugin'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Example shape for a plugin-owned API client. Replace `Widget`/`list`/`create`
|
|
24
|
+
* with this plugin's real resources — see idea-scout's or ideation's api.ts
|
|
25
|
+
* for a worked example with multiple resource groups and bases.
|
|
26
|
+
*/
|
|
27
|
+
export interface Widget {
|
|
28
|
+
id: string
|
|
29
|
+
label: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createApi(getIdToken = getFreshIdToken) {
|
|
33
|
+
const request = createRequest(getIdToken, BASE)
|
|
34
|
+
return {
|
|
35
|
+
list: () => request<Widget[]>('GET', '/widgets'),
|
|
36
|
+
create: (draft: Omit<Widget, 'id'>) => request<Widget>('POST', '/widgets', draft),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type Api = ReturnType<typeof createApi>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CognitoUserPool,
|
|
3
|
+
type CognitoUserSession,
|
|
4
|
+
type ICognitoUserPoolData,
|
|
5
|
+
} from 'amazon-cognito-identity-js'
|
|
6
|
+
|
|
7
|
+
import { pruneForeignCognitoCredentials } from './cognito-hygiene'
|
|
8
|
+
import { resolveCoreIdentity } from './identity'
|
|
9
|
+
|
|
10
|
+
// SHARED-SESSION INVARIANT (ADR-0007), mirroring the sibling skeleton's auth.ts.
|
|
11
|
+
//
|
|
12
|
+
// This app NEVER signs anyone in — the core portal owns authentication. It only
|
|
13
|
+
// READS the session the portal already established, which works because it points
|
|
14
|
+
// at the SAME Cognito User Pool / App Client as the portal (resolved at runtime
|
|
15
|
+
// from identity.ts). Same origin + one App Client means amazon-cognito-identity-js's
|
|
16
|
+
// localStorage keys (keyed by Client ID, not path) carry the portal's session over
|
|
17
|
+
// here for free. Do NOT point at a different pool/client (breaks SSO), and do NOT
|
|
18
|
+
// add signIn/signOut here (a second login path bypasses the portal).
|
|
19
|
+
//
|
|
20
|
+
// The pool is built lazily and memoised: a missing identity resolves to null →
|
|
21
|
+
// "signed out", never a hard crash.
|
|
22
|
+
|
|
23
|
+
let userPool: CognitoUserPool | null = null
|
|
24
|
+
|
|
25
|
+
async function getUserPool(): Promise<CognitoUserPool | null> {
|
|
26
|
+
if (userPool) return userPool
|
|
27
|
+
const identity = await resolveCoreIdentity()
|
|
28
|
+
if (!identity) return null
|
|
29
|
+
// Once per page load, and only with a resolved client id: drop credentials
|
|
30
|
+
// left behind by pools this deployment no longer uses (biffo-template#834).
|
|
31
|
+
// The portal and the sibling skeleton do the same; this origin is shared, so
|
|
32
|
+
// whichever app loads first does the cleaning.
|
|
33
|
+
pruneForeignCognitoCredentials(identity.clientId)
|
|
34
|
+
const poolData: ICognitoUserPoolData = {
|
|
35
|
+
UserPoolId: identity.userPoolId,
|
|
36
|
+
ClientId: identity.clientId,
|
|
37
|
+
}
|
|
38
|
+
userPool = new CognitoUserPool(poolData)
|
|
39
|
+
return userPool
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The shared portal session, or null if there isn't a valid one (→ redirect to login). */
|
|
43
|
+
export async function getCurrentSession(): Promise<CognitoUserSession | null> {
|
|
44
|
+
const pool = await getUserPool()
|
|
45
|
+
if (!pool) return null
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const user = pool.getCurrentUser()
|
|
48
|
+
if (!user) {
|
|
49
|
+
resolve(null)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
user.getSession((err: Error | null, session: CognitoUserSession | null) => {
|
|
53
|
+
resolve((err ?? !session?.isValid()) ? null : session)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A currently-valid ID token for the shared portal session, or null.
|
|
60
|
+
*
|
|
61
|
+
* Call this per request; NEVER snapshot the JWT. A `CognitoUserSession` is an
|
|
62
|
+
* immutable value object — `getIdToken()` hands back the same `CognitoIdToken`
|
|
63
|
+
* forever, and `isValid()` is true right up to the expiry second. So a client
|
|
64
|
+
* built from `createApi(() => sessionCapturedAtMount.getIdToken().getJwtToken())`
|
|
65
|
+
* sends a token frozen at mount whose remaining life is whatever was left on the
|
|
66
|
+
* *cached* token — possibly seconds. Once it lapses every call 401s for the life
|
|
67
|
+
* of the page and nothing recovers it but a reload (#69).
|
|
68
|
+
*
|
|
69
|
+
* Re-resolving instead is cheap and self-healing: `pool.getCurrentUser()` returns
|
|
70
|
+
* a fresh `CognitoUser` with no in-memory session, so `getSession()` re-reads
|
|
71
|
+
* storage every time and swaps in a new token via the refresh token exactly when
|
|
72
|
+
* the stored one has expired. No network call while the token is still good.
|
|
73
|
+
*/
|
|
74
|
+
export async function getFreshIdToken(): Promise<string | null> {
|
|
75
|
+
const session = await getCurrentSession()
|
|
76
|
+
return session ? session.getIdToken().getJwtToken() : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Test-only: reset the memoised pool. */
|
|
80
|
+
export function __resetUserPoolForTests(): void {
|
|
81
|
+
userPool = null
|
|
82
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Removing Cognito credentials belonging to pools this deployment no longer uses.
|
|
2
|
+
//
|
|
3
|
+
// `amazon-cognito-identity-js` stores tokens under keys shaped
|
|
4
|
+
// `CognitoIdentityServiceProvider.<ClientId>.<username>.<tokenType>` and reads
|
|
5
|
+
// them back scoped by Client ID. Replacing a user pool therefore does not clear
|
|
6
|
+
// the old one's keys: they are simply never read again, and they accumulate for
|
|
7
|
+
// as long as the browser profile lives. `dev.biffo.io` was measured carrying
|
|
8
|
+
// four pools' credentials — three of them dead — where AWS has one pool and one
|
|
9
|
+
// client (biffo-template#834).
|
|
10
|
+
//
|
|
11
|
+
// What this is NOT: a fix for a wrong-identity read. Because every consumer
|
|
12
|
+
// resolves its pool from the runtime identity document (#403) and lets
|
|
13
|
+
// amazon-cognito-identity-js scope the lookup by Client ID, a stale pool's
|
|
14
|
+
// tokens are never enumerated and never selected. That claim was made and
|
|
15
|
+
// withdrawn on #834.
|
|
16
|
+
//
|
|
17
|
+
// What it IS: dead bearer tokens for real identities should not sit in browser
|
|
18
|
+
// storage forever, and any future code that enumerates
|
|
19
|
+
// `CognitoIdentityServiceProvider.*` — a debug helper, a sign-out-everywhere
|
|
20
|
+
// action, a migration — should not inherit a growing minefield.
|
|
21
|
+
|
|
22
|
+
/** `CognitoIdentityServiceProvider.<clientId>.<rest…>` — capture the client id. */
|
|
23
|
+
const COGNITO_KEY = /^CognitoIdentityServiceProvider\.([^.]+)\./
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Delete every Cognito credential key whose Client ID is not `clientId`.
|
|
27
|
+
*
|
|
28
|
+
* Returns the keys removed, so a caller can log or assert on them. A no-op when
|
|
29
|
+
* `clientId` is falsy — an unresolved identity must never be treated as "no
|
|
30
|
+
* client matches", which would delete the live session along with the residue.
|
|
31
|
+
*
|
|
32
|
+
* Storage is injected for tests and to stay safe where there is none: this
|
|
33
|
+
* module is imported during `next build`'s prerender in Node, where
|
|
34
|
+
* `localStorage` does not exist.
|
|
35
|
+
*/
|
|
36
|
+
export function pruneForeignCognitoCredentials(
|
|
37
|
+
clientId: string | null | undefined,
|
|
38
|
+
storage: Storage | undefined = typeof localStorage === 'undefined' ? undefined : localStorage,
|
|
39
|
+
): string[] {
|
|
40
|
+
if (!clientId || !storage) return []
|
|
41
|
+
|
|
42
|
+
// Snapshot the keys first, via the Storage API rather than Object.keys:
|
|
43
|
+
// removeItem() mutates the live key set, so index-based iteration would skip
|
|
44
|
+
// entries as it shrinks and leave half the residue behind.
|
|
45
|
+
const keys: string[] = []
|
|
46
|
+
for (let i = 0; i < storage.length; i++) {
|
|
47
|
+
const key = storage.key(i)
|
|
48
|
+
if (key !== null) keys.push(key)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const removed: string[] = []
|
|
52
|
+
for (const key of keys) {
|
|
53
|
+
const match = COGNITO_KEY.exec(key)
|
|
54
|
+
if (match && match[1] !== clientId) {
|
|
55
|
+
storage.removeItem(key)
|
|
56
|
+
removed.push(key)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return removed
|
|
60
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Runtime core identity — same mechanism as the founder-facing web/'s own
|
|
2
|
+
// identity.ts (ADR-0007). This used to fetch admin_app's own /identity route
|
|
3
|
+
// instead, on the theory that this app (served by the plugin host at
|
|
4
|
+
// /api/v1/plugins/<plugin-slug>/admin/*) is a different origin from the portal
|
|
5
|
+
// and so can't reach /.well-known/biffo-identity.json directly. That theory
|
|
6
|
+
// was wrong: both are served from the same dev.biffo.io origin. The self-served
|
|
7
|
+
// /identity route also turned out to be a dead end even for same-origin
|
|
8
|
+
// callers — the API Gateway's own JWT authorizer and the plugin host's
|
|
9
|
+
// group_gate both sit in front of it, so it can never be reached before a
|
|
10
|
+
// session exists to prove admin-group membership with (confirmed live: a
|
|
11
|
+
// direct fetch 401'd). /.well-known/biffo-identity.json has no such problem:
|
|
12
|
+
// it's public, unauthenticated static content on the portal's own bucket, and
|
|
13
|
+
// was already reachable the whole time.
|
|
14
|
+
//
|
|
15
|
+
// Core publishes its Cognito coordinates there. We resolve it at RUNTIME so
|
|
16
|
+
// this app never bakes the core's pool/client id into its bundle — when core
|
|
17
|
+
// replaces its pool, we always see the current one. Same-origin makes a
|
|
18
|
+
// relative fetch valid with no CORS. Memoised: at most one request per page
|
|
19
|
+
// load. Unreachable → null → the caller treats the visitor as signed out (a
|
|
20
|
+
// clean redirect beats trusting a stale local pool id).
|
|
21
|
+
|
|
22
|
+
export interface CoreIdentity {
|
|
23
|
+
userPoolId: string
|
|
24
|
+
clientId: string
|
|
25
|
+
region?: string
|
|
26
|
+
apiUrl?: string
|
|
27
|
+
portalUrl?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const IDENTITY_DOCUMENT_PATH = '/.well-known/biffo-identity.json'
|
|
31
|
+
|
|
32
|
+
let cached: Promise<CoreIdentity | null> | null = null
|
|
33
|
+
|
|
34
|
+
function identityFromDocument(data: unknown): CoreIdentity | null {
|
|
35
|
+
if (typeof data !== 'object' || data === null) return null
|
|
36
|
+
const doc = data as Record<string, unknown>
|
|
37
|
+
const userPoolId = typeof doc['userPoolId'] === 'string' ? doc['userPoolId'] : ''
|
|
38
|
+
const clientId = typeof doc['clientId'] === 'string' ? doc['clientId'] : ''
|
|
39
|
+
if (!userPoolId || !clientId) return null
|
|
40
|
+
const identity: CoreIdentity = { userPoolId, clientId }
|
|
41
|
+
if (typeof doc['region'] === 'string') identity.region = doc['region']
|
|
42
|
+
if (typeof doc['apiUrl'] === 'string') identity.apiUrl = doc['apiUrl']
|
|
43
|
+
if (typeof doc['portalUrl'] === 'string') identity.portalUrl = doc['portalUrl']
|
|
44
|
+
return identity
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fetchCoreIdentity(): Promise<CoreIdentity | null> {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(IDENTITY_DOCUMENT_PATH, { cache: 'no-store' })
|
|
50
|
+
if (res.ok) {
|
|
51
|
+
const identity = identityFromDocument(await res.json())
|
|
52
|
+
if (identity) return identity
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Network error or fetch unavailable — fall through to null.
|
|
56
|
+
}
|
|
57
|
+
console.warn(
|
|
58
|
+
`[biffo] could not resolve the core identity document at ${IDENTITY_DOCUMENT_PATH}; ` +
|
|
59
|
+
'treating the visitor as signed out.',
|
|
60
|
+
)
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve core's Cognito identity at runtime. Memoised; null when unreachable. */
|
|
65
|
+
export function resolveCoreIdentity(): Promise<CoreIdentity | null> {
|
|
66
|
+
cached ??= fetchCoreIdentity()
|
|
67
|
+
return cached
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Test-only: clear the memoised resolution. */
|
|
71
|
+
export function __resetCoreIdentityForTests(): void {
|
|
72
|
+
cached = null
|
|
73
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "bundler",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"jsx": "react-jsx",
|
|
14
|
+
"strict": true,
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"noFallthroughCasesInSwitch": true,
|
|
18
|
+
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
|
19
|
+
},
|
|
20
|
+
"include": ["src", "vite.config.ts"]
|
|
21
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
|
|
4
|
+
// Served by the shared plugin host at /api/v1/plugins/example-plugin/admin/*
|
|
5
|
+
// (the API Gateway path, not a separate CloudFront/S3 origin the way the
|
|
6
|
+
// founder-facing web/ app is) — every asset/link URL must carry that full
|
|
7
|
+
// prefix, INCLUDING THIS PLUGIN'S OWN NAME. `biffo plugin create` rewrites
|
|
8
|
+
// `example-plugin` to the real slug (see .scaffold-tokens.json); do not hand-edit
|
|
9
|
+
// this after scaffolding without updating BOTH this file and base-path.test.ts.
|
|
10
|
+
//
|
|
11
|
+
// This is not a theoretical trap. idea-scout's copy of this file was pasted
|
|
12
|
+
// from ideation's and kept ideation's base. The built index.html then requested
|
|
13
|
+
// idea-scout's own asset filenames under ideation's path — 503, blank page, and
|
|
14
|
+
// NOT ONE local gate caught it: lint, typecheck, unit tests and the production
|
|
15
|
+
// build all passed, because `base` only affects the URLs inside the emitted
|
|
16
|
+
// HTML. It was visible solely by loading the page and reading the network log.
|
|
17
|
+
//
|
|
18
|
+
// The neighbouring trap: with a short base like "/example-plugin/admin/",
|
|
19
|
+
// CloudFront's 404->index.html rule papers the miss over as a 200 serving the
|
|
20
|
+
// PORTAL's homepage, so the browser tries to parse HTML as JS. Both failure
|
|
21
|
+
// modes are silent in different ways — hence the full path, and hence
|
|
22
|
+
// base-path.test.ts asserting it stays correct rather than trusting eyes alone.
|
|
23
|
+
export default defineConfig({
|
|
24
|
+
base: '/api/v1/plugins/example-plugin/admin/',
|
|
25
|
+
plugins: [react()],
|
|
26
|
+
build: { outDir: 'dist' },
|
|
27
|
+
test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test-setup.ts'] },
|
|
28
|
+
})
|