@biffo/cli 0.38.0 → 0.41.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/.github/workflows/ci.yml +124 -0
- package/_skeletons/plugin-template/.github/workflows/release.yml +114 -0
- package/_skeletons/plugin-template/README.md +232 -0
- package/_skeletons/plugin-template/biffo.plugin.json +89 -0
- package/_skeletons/plugin-template/pyproject.toml +100 -0
- package/_skeletons/plugin-template/registry-schema.json +294 -0
- package/_skeletons/plugin-template/src/__init__.py +0 -0
- package/_skeletons/plugin-template/src/example_plugin/__init__.py +10 -0
- package/_skeletons/plugin-template/src/example_plugin/main.py +63 -0
- package/_skeletons/plugin-template/src/example_plugin/manifest.py +30 -0
- package/_skeletons/plugin-template/src/example_plugin/plugin.py +92 -0
- package/_skeletons/plugin-template/terraform/README.md +146 -0
- package/_skeletons/plugin-template/terraform/main.tf +167 -0
- package/_skeletons/plugin-template/terraform/outputs.tf +33 -0
- package/_skeletons/plugin-template/terraform/variables.tf +133 -0
- package/_skeletons/plugin-template/tests/conftest.py +17 -0
- package/_skeletons/plugin-template/tests/fakes.py +74 -0
- package/_skeletons/plugin-template/tests/test_example_plugin.py +108 -0
- package/_skeletons/registry/README.md +27 -0
- package/_skeletons/registry/plugins.json +5 -0
- package/_skeletons/registry/registry-schema.json +294 -0
- package/_skeletons/sibling-template/.github/renovate.json +38 -0
- package/_skeletons/sibling-template/.github/workflows/ci.yml +185 -0
- package/_skeletons/sibling-template/.github/workflows/codeql.yml +56 -0
- package/_skeletons/sibling-template/.github/workflows/deploy.yml +219 -0
- package/_skeletons/sibling-template/.github/workflows/destroy-infra.yml +73 -0
- package/_skeletons/sibling-template/README.md +164 -0
- package/_skeletons/sibling-template/_gitignore +71 -0
- package/_skeletons/sibling-template/apps/frontend/.env.example +14 -0
- package/_skeletons/sibling-template/apps/frontend/eslint.config.mjs +10 -0
- package/_skeletons/sibling-template/apps/frontend/next.config.ts +20 -0
- package/_skeletons/sibling-template/apps/frontend/package.json +42 -0
- package/_skeletons/sibling-template/apps/frontend/pnpm-lock.yaml +5268 -0
- package/_skeletons/sibling-template/apps/frontend/pnpm-workspace.yaml +19 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/globals.css +34 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/layout.tsx +15 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/page.test.tsx +75 -0
- package/_skeletons/sibling-template/apps/frontend/src/app/page.tsx +85 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/api-client.ts +58 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/auth.test.ts +135 -0
- package/_skeletons/sibling-template/apps/frontend/src/lib/auth.ts +87 -0
- package/_skeletons/sibling-template/apps/frontend/src/test-setup.ts +1 -0
- package/_skeletons/sibling-template/apps/frontend/tsconfig.json +29 -0
- package/_skeletons/sibling-template/apps/frontend/vitest.config.ts +18 -0
- package/_skeletons/sibling-template/biffo.sibling.json +7 -0
- package/_skeletons/sibling-template/infra/backend.tf +23 -0
- package/_skeletons/sibling-template/infra/main.tf +79 -0
- package/_skeletons/sibling-template/infra/outputs.tf +17 -0
- package/_skeletons/sibling-template/infra/variables.tf +63 -0
- package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/main.tf +114 -0
- package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/outputs.tf +12 -0
- package/_skeletons/sibling-template/modules/cloud/aws/api-gateway/variables.tf +41 -0
- package/_skeletons/sibling-template/modules/cloud/aws/compute/main.tf +157 -0
- package/_skeletons/sibling-template/modules/cloud/aws/compute/outputs.tf +4 -0
- package/_skeletons/sibling-template/modules/cloud/aws/compute/variables.tf +70 -0
- package/_skeletons/sibling-template/modules/cloud/aws/storage/main.tf +78 -0
- package/_skeletons/sibling-template/modules/cloud/aws/storage/outputs.tf +4 -0
- package/_skeletons/sibling-template/modules/cloud/aws/storage/variables.tf +12 -0
- package/_skeletons/sibling-template/services/api/pyproject.toml +69 -0
- package/_skeletons/sibling-template/services/api/src/api/__init__.py +0 -0
- package/_skeletons/sibling-template/services/api/src/api/config.py +31 -0
- package/_skeletons/sibling-template/services/api/src/api/core_client.py +64 -0
- package/_skeletons/sibling-template/services/api/src/api/main.py +43 -0
- package/_skeletons/sibling-template/services/api/src/api/middleware/__init__.py +0 -0
- package/_skeletons/sibling-template/services/api/src/api/middleware/auth.py +111 -0
- package/_skeletons/sibling-template/services/api/src/api/routers/__init__.py +0 -0
- package/_skeletons/sibling-template/services/api/src/api/routers/whoami.py +21 -0
- package/_skeletons/sibling-template/services/api/tests/conftest.py +24 -0
- package/_skeletons/sibling-template/services/api/tests/test_whoami.py +21 -0
- package/_skeletons/sibling-template/services/api/uv.lock +1160 -0
- package/core.version +1 -1
- package/dist/index.js +113 -87
- package/package.json +5 -4
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# This file exists to STOP pnpm, not to configure a workspace.
|
|
2
|
+
#
|
|
3
|
+
# `pnpm install` walks up the directory tree looking for a workspace root. In a
|
|
4
|
+
# scaffolded sibling repo there is nothing above this directory to find, so this
|
|
5
|
+
# file changes nothing there. Inside biffo-template, however, this skeleton sits
|
|
6
|
+
# under `_skeletons/` — deliberately outside the root `pnpm-workspace.yaml`'s
|
|
7
|
+
# package globs — and without this file `pnpm install` run from here walks all
|
|
8
|
+
# the way up and installs *biffo-template's own root workspace* instead. It
|
|
9
|
+
# reports success, leaves no `node_modules` here at all, and the next command
|
|
10
|
+
# then fails for a reason that looks unrelated.
|
|
11
|
+
#
|
|
12
|
+
# A `preinstall` guard cannot fix this: because `_skeletons/` is excluded from
|
|
13
|
+
# the root workspace globs, pnpm never treats this package as part of that
|
|
14
|
+
# install and so never runs its lifecycle scripts. Declaring this directory a
|
|
15
|
+
# workspace root of its own is what actually terminates the upward walk.
|
|
16
|
+
#
|
|
17
|
+
# `packages: []` is intentional — this app has no sub-packages. pnpm always
|
|
18
|
+
# installs the workspace root package itself, which is this one.
|
|
19
|
+
packages: []
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
body {
|
|
6
|
+
margin: 0;
|
|
7
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
8
|
+
background: #f9fafb;
|
|
9
|
+
color: #111827;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.center-screen {
|
|
13
|
+
min-height: 100vh;
|
|
14
|
+
display: flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
justify-content: center;
|
|
17
|
+
text-align: center;
|
|
18
|
+
padding: 1.5rem;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.spinner {
|
|
22
|
+
width: 2rem;
|
|
23
|
+
height: 2rem;
|
|
24
|
+
border-radius: 9999px;
|
|
25
|
+
border: 4px solid #d1d5db;
|
|
26
|
+
border-top-color: #2563eb;
|
|
27
|
+
animation: spin 0.8s linear infinite;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@keyframes spin {
|
|
31
|
+
to {
|
|
32
|
+
transform: rotate(360deg);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import './globals.css'
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: 'Sibling App',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
10
|
+
return (
|
|
11
|
+
<html lang="en">
|
|
12
|
+
<body>{children}</body>
|
|
13
|
+
</html>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
vi.mock('@/lib/auth', () => ({
|
|
5
|
+
getCurrentSession: vi.fn(),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
vi.mock('@/lib/api-client', () => ({
|
|
9
|
+
ApiError: class ApiError extends Error {},
|
|
10
|
+
createApiClient: vi.fn(),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
// process.env is read at module-evaluation time (page.tsx isn't run through
|
|
14
|
+
// Next's build-time NEXT_PUBLIC_ inlining here, just plain Vite/Node) — stub
|
|
15
|
+
// it and dynamically import afterwards so HomePage sees the value.
|
|
16
|
+
process.env['NEXT_PUBLIC_CORE_PORTAL_URL'] = 'https://core.example.com'
|
|
17
|
+
process.env['NEXT_PUBLIC_SIBLING_PATH_PREFIX'] = '/my-sibling'
|
|
18
|
+
|
|
19
|
+
const { getCurrentSession } = await import('@/lib/auth')
|
|
20
|
+
const { createApiClient } = await import('@/lib/api-client')
|
|
21
|
+
const { default: HomePage } = await import('./page')
|
|
22
|
+
|
|
23
|
+
function fakeSession(idToken: string) {
|
|
24
|
+
return {
|
|
25
|
+
getIdToken: () => ({ getJwtToken: () => idToken }),
|
|
26
|
+
} as unknown as Awaited<ReturnType<typeof getCurrentSession>>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.clearAllMocks()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('HomePage', () => {
|
|
34
|
+
it('renders "Hello <username>" once the session and whoami call resolve', async () => {
|
|
35
|
+
vi.mocked(getCurrentSession).mockResolvedValueOnce(fakeSession('a-jwt'))
|
|
36
|
+
vi.mocked(createApiClient).mockReturnValue({
|
|
37
|
+
get: vi.fn().mockResolvedValueOnce({ username: 'keiran' }),
|
|
38
|
+
post: vi.fn(),
|
|
39
|
+
put: vi.fn(),
|
|
40
|
+
delete: vi.fn(),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
render(<HomePage />)
|
|
44
|
+
|
|
45
|
+
await waitFor(() => {
|
|
46
|
+
expect(screen.getByText(/Hello keiran/)).toBeInTheDocument()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('redirects to the core portal login when there is no session', async () => {
|
|
51
|
+
vi.mocked(getCurrentSession).mockResolvedValueOnce(null)
|
|
52
|
+
|
|
53
|
+
// jsdom's real window.location throws "Not implemented: navigation" if
|
|
54
|
+
// .href is actually set to a new URL — replace it with a plain object
|
|
55
|
+
// so we can just inspect what the page tried to navigate to.
|
|
56
|
+
const originalLocation = window.location
|
|
57
|
+
Object.defineProperty(window, 'location', {
|
|
58
|
+
value: { href: '' },
|
|
59
|
+
writable: true,
|
|
60
|
+
configurable: true,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
render(<HomePage />)
|
|
64
|
+
|
|
65
|
+
await waitFor(() => {
|
|
66
|
+
expect(window.location.href).toContain('/login?return_to=')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
Object.defineProperty(window, 'location', {
|
|
70
|
+
value: originalLocation,
|
|
71
|
+
writable: true,
|
|
72
|
+
configurable: true,
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { getCurrentSession } from '@/lib/auth'
|
|
5
|
+
import { createApiClient, ApiError } from '@/lib/api-client'
|
|
6
|
+
|
|
7
|
+
const SIBLING_NAME = process.env['NEXT_PUBLIC_SIBLING_NAME'] ?? 'Sibling'
|
|
8
|
+
const SIBLING_PATH_PREFIX = process.env['NEXT_PUBLIC_SIBLING_PATH_PREFIX'] ?? ''
|
|
9
|
+
const CORE_PORTAL_URL = process.env['NEXT_PUBLIC_CORE_PORTAL_URL'] ?? ''
|
|
10
|
+
|
|
11
|
+
type State =
|
|
12
|
+
| { kind: 'loading' }
|
|
13
|
+
| { kind: 'signed_out' }
|
|
14
|
+
| { kind: 'ready'; username: string }
|
|
15
|
+
| { kind: 'error'; message: string }
|
|
16
|
+
|
|
17
|
+
// This page's only job is to prove the shared-Cognito-session SSO (ADR-0007)
|
|
18
|
+
// actually works end to end: no session on this origin → bounce to the core
|
|
19
|
+
// portal's login with return_to set to come straight back here; a session →
|
|
20
|
+
// call this sibling's OWN backend (never the core API directly, ADR-0002),
|
|
21
|
+
// which independently re-verifies the JWT and echoes back the username.
|
|
22
|
+
export default function HomePage() {
|
|
23
|
+
const [state, setState] = useState<State>({ kind: 'loading' })
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
let cancelled = false
|
|
27
|
+
|
|
28
|
+
void getCurrentSession().then(async (session) => {
|
|
29
|
+
if (cancelled) return
|
|
30
|
+
if (session === null) {
|
|
31
|
+
setState({ kind: 'signed_out' })
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const idToken = session.getIdToken().getJwtToken()
|
|
36
|
+
const api = createApiClient(() => idToken)
|
|
37
|
+
try {
|
|
38
|
+
const me = await api.get<{ username: string }>('/api/v1/whoami')
|
|
39
|
+
if (!cancelled) setState({ kind: 'ready', username: me.username })
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (!cancelled) {
|
|
42
|
+
setState({
|
|
43
|
+
kind: 'error',
|
|
44
|
+
message: err instanceof ApiError ? err.message : 'Failed to reach the API',
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
return () => {
|
|
51
|
+
cancelled = true
|
|
52
|
+
}
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (state.kind === 'signed_out' && CORE_PORTAL_URL) {
|
|
57
|
+
const returnTo = encodeURIComponent(`${SIBLING_PATH_PREFIX}/`)
|
|
58
|
+
window.location.href = `${CORE_PORTAL_URL}/login?return_to=${returnTo}`
|
|
59
|
+
}
|
|
60
|
+
}, [state])
|
|
61
|
+
|
|
62
|
+
if (state.kind === 'loading' || state.kind === 'signed_out') {
|
|
63
|
+
return (
|
|
64
|
+
<main className="center-screen">
|
|
65
|
+
<div className="spinner" />
|
|
66
|
+
</main>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (state.kind === 'error') {
|
|
71
|
+
return (
|
|
72
|
+
<main className="center-screen">
|
|
73
|
+
<p>Signed in, but could not reach the API: {state.message}</p>
|
|
74
|
+
</main>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<main className="center-screen">
|
|
80
|
+
<h1>
|
|
81
|
+
{SIBLING_NAME} - Hello {state.username}
|
|
82
|
+
</h1>
|
|
83
|
+
</main>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Calls THIS sibling's own backend only (never the core's database directly —
|
|
2
|
+
// ADR-0002/ADR-0007). If this sibling needs core-owned data, its own backend
|
|
3
|
+
// is responsible for calling the core API server-side (see
|
|
4
|
+
// services/api/src/api/core_client.py) — the frontend never talks to the
|
|
5
|
+
// core API directly.
|
|
6
|
+
const API_URL = process.env['NEXT_PUBLIC_API_URL'] ?? ''
|
|
7
|
+
|
|
8
|
+
export class ApiError extends Error {
|
|
9
|
+
constructor(
|
|
10
|
+
public readonly status: number,
|
|
11
|
+
message: string,
|
|
12
|
+
) {
|
|
13
|
+
super(message)
|
|
14
|
+
this.name = 'ApiError'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function handleResponse<T>(res: Response): Promise<T> {
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
const body = await res.text().catch(() => res.statusText)
|
|
21
|
+
throw new ApiError(res.status, body)
|
|
22
|
+
}
|
|
23
|
+
return res.json() as Promise<T>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createApiClient(getIdToken: () => string | null) {
|
|
27
|
+
function authHeaders(): HeadersInit {
|
|
28
|
+
const token = getIdToken()
|
|
29
|
+
return {
|
|
30
|
+
'Content-Type': 'application/json',
|
|
31
|
+
...(token != null ? { Authorization: `Bearer ${token}` } : {}),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
get: <T>(path: string): Promise<T> =>
|
|
37
|
+
fetch(`${API_URL}${path}`, { headers: authHeaders() }).then((r) => handleResponse<T>(r)),
|
|
38
|
+
|
|
39
|
+
post: <T>(path: string, body: unknown): Promise<T> =>
|
|
40
|
+
fetch(`${API_URL}${path}`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: authHeaders(),
|
|
43
|
+
body: JSON.stringify(body),
|
|
44
|
+
}).then((r) => handleResponse<T>(r)),
|
|
45
|
+
|
|
46
|
+
put: <T>(path: string, body: unknown): Promise<T> =>
|
|
47
|
+
fetch(`${API_URL}${path}`, {
|
|
48
|
+
method: 'PUT',
|
|
49
|
+
headers: authHeaders(),
|
|
50
|
+
body: JSON.stringify(body),
|
|
51
|
+
}).then((r) => handleResponse<T>(r)),
|
|
52
|
+
|
|
53
|
+
delete: <T>(path: string): Promise<T> =>
|
|
54
|
+
fetch(`${API_URL}${path}`, { method: 'DELETE', headers: authHeaders() }).then((r) =>
|
|
55
|
+
handleResponse<T>(r),
|
|
56
|
+
),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const getCurrentUser = vi.fn()
|
|
4
|
+
const poolConstructor = vi.fn()
|
|
5
|
+
|
|
6
|
+
vi.mock('amazon-cognito-identity-js', () => ({
|
|
7
|
+
CognitoUserPool: class {
|
|
8
|
+
constructor(...args: unknown[]) {
|
|
9
|
+
poolConstructor(...args)
|
|
10
|
+
}
|
|
11
|
+
getCurrentUser = getCurrentUser
|
|
12
|
+
},
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
type SessionCallback = (err: Error | null, session: unknown) => void
|
|
16
|
+
|
|
17
|
+
function userWithSession(session: unknown, err: Error | null = null) {
|
|
18
|
+
return {
|
|
19
|
+
getSession: (cb: SessionCallback) => {
|
|
20
|
+
cb(err, session)
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function configureCognitoEnv() {
|
|
26
|
+
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', 'us-east-1_TESTPOOL')
|
|
27
|
+
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', 'testclientid')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Each test re-imports the module fresh: the pool is memoised after its first
|
|
31
|
+
// construction, so a module instance left over from a previous test would carry
|
|
32
|
+
// that test's env into the next one.
|
|
33
|
+
async function loadAuth() {
|
|
34
|
+
vi.resetModules()
|
|
35
|
+
return await import('@/lib/auth')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('getCurrentSession', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.clearAllMocks()
|
|
41
|
+
configureCognitoEnv()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.unstubAllEnvs()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns null when no user is stored in the shared localStorage session', async () => {
|
|
49
|
+
const { getCurrentSession } = await loadAuth()
|
|
50
|
+
getCurrentUser.mockReturnValue(null)
|
|
51
|
+
await expect(getCurrentSession()).resolves.toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns the session the portal established when it is valid', async () => {
|
|
55
|
+
const { getCurrentSession } = await loadAuth()
|
|
56
|
+
const session = { isValid: () => true }
|
|
57
|
+
getCurrentUser.mockReturnValue(userWithSession(session))
|
|
58
|
+
await expect(getCurrentSession()).resolves.toBe(session)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns null when the stored session is expired/invalid', async () => {
|
|
62
|
+
const { getCurrentSession } = await loadAuth()
|
|
63
|
+
getCurrentUser.mockReturnValue(userWithSession({ isValid: () => false }))
|
|
64
|
+
await expect(getCurrentSession()).resolves.toBeNull()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns null when getSession errors', async () => {
|
|
68
|
+
const { getCurrentSession } = await loadAuth()
|
|
69
|
+
getCurrentUser.mockReturnValue(userWithSession(null, new Error('nope')))
|
|
70
|
+
await expect(getCurrentSession()).resolves.toBeNull()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// The CognitoUserPool constructor throws ("Both UserPoolId and ClientId are
|
|
75
|
+
// required") when either value is missing, and `next build` prerenders `/` in
|
|
76
|
+
// Node — which imports this module. Building the pool at module scope therefore
|
|
77
|
+
// made the skeleton un-buildable without real Cognito credentials in scope.
|
|
78
|
+
// These tests pin that shut.
|
|
79
|
+
describe('lazy pool construction', () => {
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
vi.clearAllMocks()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
vi.unstubAllEnvs()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('does not construct the pool merely by importing the module', async () => {
|
|
89
|
+
configureCognitoEnv()
|
|
90
|
+
await loadAuth()
|
|
91
|
+
expect(poolConstructor).not.toHaveBeenCalled()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('imports cleanly with no Cognito env vars set', async () => {
|
|
95
|
+
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', '')
|
|
96
|
+
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', '')
|
|
97
|
+
await expect(loadAuth()).resolves.toBeDefined()
|
|
98
|
+
expect(poolConstructor).not.toHaveBeenCalled()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('resolves null instead of throwing when Cognito env vars are absent', async () => {
|
|
102
|
+
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID', '')
|
|
103
|
+
vi.stubEnv('NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID', '')
|
|
104
|
+
const { getCurrentSession } = await loadAuth()
|
|
105
|
+
await expect(getCurrentSession()).resolves.toBeNull()
|
|
106
|
+
expect(poolConstructor).not.toHaveBeenCalled()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('constructs the pool once, on first session read, from the core env vars', async () => {
|
|
110
|
+
configureCognitoEnv()
|
|
111
|
+
const { getCurrentSession } = await loadAuth()
|
|
112
|
+
getCurrentUser.mockReturnValue(null)
|
|
113
|
+
|
|
114
|
+
await getCurrentSession()
|
|
115
|
+
await getCurrentSession()
|
|
116
|
+
|
|
117
|
+
expect(poolConstructor).toHaveBeenCalledTimes(1)
|
|
118
|
+
expect(poolConstructor).toHaveBeenCalledWith({
|
|
119
|
+
UserPoolId: 'us-east-1_TESTPOOL',
|
|
120
|
+
ClientId: 'testclientid',
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// ADR-0007: the core portal owns authentication; a sibling only reads the
|
|
126
|
+
// shared session. Exporting sign-in machinery from a sibling invites a second
|
|
127
|
+
// login path that bypasses the portal and silently breaks single-sign-on.
|
|
128
|
+
// If you are here because you deliberately added one of these, that decision
|
|
129
|
+
// belongs in an ADR — not in a green test suite.
|
|
130
|
+
describe('sibling auth surface', () => {
|
|
131
|
+
it('exposes only session reading — no sign-in/sign-out machinery', async () => {
|
|
132
|
+
const authModule = await loadAuth()
|
|
133
|
+
expect(Object.keys(authModule).sort()).toEqual(['getCurrentSession'])
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CognitoUserPool,
|
|
3
|
+
type CognitoUserSession,
|
|
4
|
+
type ICognitoUserPoolData,
|
|
5
|
+
} from 'amazon-cognito-identity-js'
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// SHARED-SESSION INVARIANT — read this before changing anything below.
|
|
9
|
+
//
|
|
10
|
+
// A sibling NEVER signs anyone in. The core portal owns authentication
|
|
11
|
+
// (ADR-0007): a sibling that finds no valid session redirects to the portal's
|
|
12
|
+
// login with `?return_to=/<name>/`. This module therefore only *reads* the
|
|
13
|
+
// session the portal already established.
|
|
14
|
+
//
|
|
15
|
+
// That works because this points at the SAME Cognito User Pool / App Client ID
|
|
16
|
+
// as the core portal. The sibling and the portal live on the same origin
|
|
17
|
+
// (baseurl.com/ vs baseurl.com/<name>/) and share one Cognito App Client, so
|
|
18
|
+
// amazon-cognito-identity-js's own localStorage keys — keyed only by Client
|
|
19
|
+
// ID, not by path — transparently carry the portal's session over here with
|
|
20
|
+
// zero extra code.
|
|
21
|
+
//
|
|
22
|
+
// Do NOT point this at a different pool/client: that would silently break
|
|
23
|
+
// single-sign-on between the portal and this sibling.
|
|
24
|
+
//
|
|
25
|
+
// Do NOT add signIn/signOut/completeNewPassword here either. A second login
|
|
26
|
+
// path in a sibling bypasses the portal and breaks the SSO model ADR-0007
|
|
27
|
+
// depends on. If this sibling ever genuinely needs sign-out, add it
|
|
28
|
+
// deliberately — and decide first whether it must sign the user out of the
|
|
29
|
+
// portal too.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Module-private on purpose: the pool is an implementation detail of
|
|
32
|
+
// getCurrentSession(), not part of this sibling's auth surface.
|
|
33
|
+
//
|
|
34
|
+
// Constructed LAZILY, on first session read — never at module scope. The
|
|
35
|
+
// CognitoUserPool constructor throws ("Both UserPoolId and ClientId are
|
|
36
|
+
// required") when either value is missing, and `next build` prerenders `/` in
|
|
37
|
+
// Node, which imports this module. Constructing eagerly therefore made the
|
|
38
|
+
// whole app un-buildable without the real Cognito env vars in scope — a build
|
|
39
|
+
// is not a sign-in, and it has no business needing pool credentials. Deferring
|
|
40
|
+
// keeps `pnpm run build` (and any import of this module) working with no env
|
|
41
|
+
// configured, while an actual session read in a misconfigured deployment
|
|
42
|
+
// surfaces as "signed out" rather than a hard crash.
|
|
43
|
+
let userPool: CognitoUserPool | null = null
|
|
44
|
+
|
|
45
|
+
function getUserPool(): CognitoUserPool | null {
|
|
46
|
+
if (userPool) return userPool
|
|
47
|
+
|
|
48
|
+
const poolData: ICognitoUserPoolData = {
|
|
49
|
+
UserPoolId: process.env['NEXT_PUBLIC_CORE_COGNITO_USER_POOL_ID'] ?? '',
|
|
50
|
+
ClientId: process.env['NEXT_PUBLIC_CORE_COGNITO_CLIENT_ID'] ?? '',
|
|
51
|
+
}
|
|
52
|
+
if (!poolData.UserPoolId || !poolData.ClientId) return null
|
|
53
|
+
|
|
54
|
+
userPool = new CognitoUserPool(poolData)
|
|
55
|
+
return userPool
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read the shared portal session, or null if there isn't a valid one.
|
|
60
|
+
*
|
|
61
|
+
* Callers pass the returned session's ID token to this sibling's own backend
|
|
62
|
+
* (see `createApiClient`, which takes a `getIdToken` callback). A null result
|
|
63
|
+
* means "redirect the user to the portal's login".
|
|
64
|
+
*/
|
|
65
|
+
export function getCurrentSession(): Promise<CognitoUserSession | null> {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
// No pool means no core Cognito config, which is indistinguishable from
|
|
68
|
+
// "not signed in" as far as callers are concerned.
|
|
69
|
+
const pool = getUserPool()
|
|
70
|
+
if (!pool) {
|
|
71
|
+
resolve(null)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
const user = pool.getCurrentUser()
|
|
75
|
+
if (!user) {
|
|
76
|
+
resolve(null)
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
user.getSession((err: Error | null, session: CognitoUserSession | null) => {
|
|
80
|
+
if (err ?? !session?.isValid()) {
|
|
81
|
+
resolve(null)
|
|
82
|
+
} else {
|
|
83
|
+
resolve(session)
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom/vitest'
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"exactOptionalPropertyTypes": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"module": "esnext",
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"isolatedModules": true,
|
|
15
|
+
"jsx": "preserve",
|
|
16
|
+
"incremental": true,
|
|
17
|
+
"baseUrl": ".",
|
|
18
|
+
"paths": {
|
|
19
|
+
"@/*": ["./src/*"]
|
|
20
|
+
},
|
|
21
|
+
"plugins": [
|
|
22
|
+
{
|
|
23
|
+
"name": "next"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
28
|
+
"exclude": ["node_modules"]
|
|
29
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
import { defineConfig } from 'vitest/config'
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [react()],
|
|
7
|
+
resolve: {
|
|
8
|
+
alias: {
|
|
9
|
+
'@': path.resolve(__dirname, './src'),
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
test: {
|
|
13
|
+
globals: true,
|
|
14
|
+
environment: 'jsdom',
|
|
15
|
+
include: ['src/**/*.test.{ts,tsx}'],
|
|
16
|
+
setupFiles: ['src/test-setup.ts'],
|
|
17
|
+
},
|
|
18
|
+
})
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "example-sibling",
|
|
3
|
+
"core_project": "example-core-project",
|
|
4
|
+
"path_prefix": "example-sibling",
|
|
5
|
+
"description": "Rewritten by `biffo sibling create` (ADR-0007) with this sibling's real name, its paired core project's name, and its registered path prefix. The identity fields above are not meant to be hand-edited afterwards — path_prefix in particular must always match the entry this sibling was registered under in the core project's infra/siblings.auto.tfvars.json. `routes` below, however, IS yours to maintain.",
|
|
6
|
+
"routes": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
terraform {
|
|
2
|
+
required_version = ">= 1.9"
|
|
3
|
+
|
|
4
|
+
required_providers {
|
|
5
|
+
aws = {
|
|
6
|
+
source = "hashicorp/aws"
|
|
7
|
+
version = "~> 5.0"
|
|
8
|
+
}
|
|
9
|
+
http = {
|
|
10
|
+
source = "hashicorp/http"
|
|
11
|
+
version = "~> 3.4"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# Bucket/key/region supplied at `terraform init -backend-config=backend.hcl`
|
|
16
|
+
# time (generated by CI, or by `biffo sibling create`'s bootstrap step for
|
|
17
|
+
# local dev) — never committed. Matches the core project's own convention.
|
|
18
|
+
backend "s3" {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
provider "aws" {
|
|
22
|
+
region = var.aws_region
|
|
23
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
locals {
|
|
2
|
+
name_prefix = "${var.project_name}-${var.environment}"
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
module "storage" {
|
|
6
|
+
source = "../modules/cloud/aws/storage"
|
|
7
|
+
|
|
8
|
+
project_name = var.project_name
|
|
9
|
+
environment = var.environment
|
|
10
|
+
tags = var.tags
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
# Fetched at apply time and baked into the Lambda's own env vars, so JWT
|
|
14
|
+
# verification never needs an outbound call to Cognito at request time
|
|
15
|
+
# (matches the core project's no-NAT dev environment pattern). Key rotation
|
|
16
|
+
# requires a `terraform apply` to refresh this.
|
|
17
|
+
data "http" "cognito_jwks" {
|
|
18
|
+
url = "https://cognito-idp.${var.aws_region}.amazonaws.com/${var.core_cognito_user_pool_id}/.well-known/jwks.json"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module "api" {
|
|
22
|
+
source = "../modules/cloud/aws/compute"
|
|
23
|
+
|
|
24
|
+
project_name = var.project_name
|
|
25
|
+
environment = var.environment
|
|
26
|
+
function_name = "api"
|
|
27
|
+
handler = "src.api.main.lambda_handler"
|
|
28
|
+
|
|
29
|
+
environment_variables = {
|
|
30
|
+
SIBLING_ENVIRONMENT = var.environment
|
|
31
|
+
SIBLING_CORE_API_URL = var.core_api_url
|
|
32
|
+
SIBLING_COGNITO_JWKS_JSON = data.http.cognito_jwks.response_body
|
|
33
|
+
SIBLING_COGNITO_USER_POOL_ID = var.core_cognito_user_pool_id
|
|
34
|
+
SIBLING_COGNITO_CLIENT_ID = var.core_cognito_client_id
|
|
35
|
+
SIBLING_COGNITO_REGION = var.aws_region
|
|
36
|
+
SIBLING_CORS_ORIGINS = jsonencode(var.cors_origins)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
tags = var.tags
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module "api_gateway" {
|
|
43
|
+
source = "../modules/cloud/aws/api-gateway"
|
|
44
|
+
|
|
45
|
+
project_name = var.project_name
|
|
46
|
+
environment = var.environment
|
|
47
|
+
lambda_function_arn = module.api.function_arn
|
|
48
|
+
lambda_function_name = module.api.function_name
|
|
49
|
+
cognito_user_pool_id = var.core_cognito_user_pool_id
|
|
50
|
+
cognito_client_id = var.core_cognito_client_id
|
|
51
|
+
aws_region = var.aws_region
|
|
52
|
+
cors_origins = var.cors_origins
|
|
53
|
+
tags = var.tags
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# See variables.tf's parent_cloudfront_distribution_arn comment: this is
|
|
57
|
+
# deliberately skipped (count = 0) until the core project's registration PR
|
|
58
|
+
# has merged and redeployed with this sibling's origin — there's no
|
|
59
|
+
# distribution ARN to trust before then. Two-phase apply, not a bug.
|
|
60
|
+
resource "aws_s3_bucket_policy" "site" {
|
|
61
|
+
count = var.parent_cloudfront_distribution_arn != "" ? 1 : 0
|
|
62
|
+
bucket = module.storage.site_bucket_name
|
|
63
|
+
|
|
64
|
+
policy = jsonencode({
|
|
65
|
+
Version = "2012-10-17"
|
|
66
|
+
Statement = [{
|
|
67
|
+
Sid = "AllowCoreCloudFrontOAC"
|
|
68
|
+
Effect = "Allow"
|
|
69
|
+
Principal = { Service = "cloudfront.amazonaws.com" }
|
|
70
|
+
Action = "s3:GetObject"
|
|
71
|
+
Resource = "${module.storage.site_bucket_arn}/*"
|
|
72
|
+
Condition = {
|
|
73
|
+
StringEquals = {
|
|
74
|
+
"AWS:SourceArn" = var.parent_cloudfront_distribution_arn
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}]
|
|
78
|
+
})
|
|
79
|
+
}
|