@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.
@@ -0,0 +1,5 @@
1
+ # Dependencies
2
+ node_modules
3
+
4
+ # Build output
5
+ dist
@@ -0,0 +1,8 @@
1
+ import js from '@eslint/js'
2
+ import tseslint from 'typescript-eslint'
3
+
4
+ export default tseslint.config(
5
+ { ignores: ['dist'] },
6
+ js.configs.recommended,
7
+ ...tseslint.configs.recommended,
8
+ )
@@ -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,11 @@
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+
4
+ import App from './App'
5
+ import './index.css'
6
+
7
+ createRoot(document.getElementById('root')!).render(
8
+ <StrictMode>
9
+ <App />
10
+ </StrictMode>,
11
+ )
@@ -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
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.273.13",
3
+ "version": "0.274.0",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",