Compare commits
1
Commits
v81941888f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0da8a2a81b |
@@ -0,0 +1,5 @@
|
||||
# Deploy-time configuration for the platform (schema: crates/conusai-repo-config).
|
||||
health:
|
||||
# The app's health endpoint. Without this the deployer probes "/", which
|
||||
# renders the dashboard — the wrong probe for liveness.
|
||||
path: /api/health
|
||||
@@ -22,14 +22,24 @@ Pinned on 2026-10-06 with `npm view <package> version` (showcase §2.7):
|
||||
| eslint, eslint-config-next, @eslint/eslintrc | 9.39.5 (ESLint 10 breaks the React plugin bundled by eslint-config-next), 16.3.8, 3.3.7 |
|
||||
| steiger, @feature-sliced/steiger-plugin | 0.7.0, 0.8.0 |
|
||||
| @playwright/test | 1.63.0 |
|
||||
| playwright-core | 1.63.0 (`@axe-core/playwright`'s peer; must equal `@playwright/test`'s copy, or a lockfile-less `npm install` hoists the newest and the two `Page` types diverge under `tsc`) |
|
||||
| @types/node, @types/react, @types/react-dom | 26.6.4, 19.3.0, 19.3.0 |
|
||||
|
||||
The `@conusai-sdk/*` packages are not listed: `test-template.sh` installs them
|
||||
from the bundle built from this commit.
|
||||
The `@conusai-sdk/*` packages the template imports are pinned in
|
||||
`package.json` at the version the SDK source tree carries at the same commit;
|
||||
`test/sdk-deps.test.ts` fails when an imported package is missing or its pin
|
||||
differs. The template ships **no `package-lock.json`** (decision of
|
||||
2026-10-09, showcase plan D19): a lockfile without the scoped entries makes
|
||||
`npm ci` fail, and one carrying them records `resolved` URLs of one install's
|
||||
registry host, which is wrong on every other. The platform build runs
|
||||
`npm install` against the `.npmrc` the deployer mounts as a BuildKit secret
|
||||
(D6/D7), whose scoped registry serves exactly the pinned versions;
|
||||
`test-template.sh` proves the same locally from the bundle's tarballs.
|
||||
|
||||
## Layout
|
||||
|
||||
- `app/` routes only; each file re-exports from `src/views` or `src/app`.
|
||||
- `app/` routes only; each file re-exports from `src/views`, `src/app` or a
|
||||
feature's route handler.
|
||||
- `src/app`, `src/views`, `src/widgets`, `src/features`, `src/entities`,
|
||||
`src/shared`: the FSD layers, imports point downward only (checked by `steiger`).
|
||||
- `src/shared/config/env.ts`: the environment, parsed once with zod (D12).
|
||||
@@ -43,6 +53,7 @@ from the bundle built from this commit.
|
||||
| `features/create-note` | A note in Postgres, audited as `note.created`; NATS announces it and Qdrant indexes it when those are linked. |
|
||||
| `features/edit-note`, `features/delete-note` | Writes scoped to the author, each audited (`note.updated`, `note.deleted`). |
|
||||
| `features/upload-file`, `features/delete-file` | Attachments in Blob Storage, one usage event per upload with an idempotency key; a per-minute quota when KV is linked. |
|
||||
| `features/download-file` | An attachment's bytes at `/files/<id>`, downloaded server-side through the Blob API and streamed to the visitor who attached it: the platform's blob URL needs the deployment token, so the browser cannot open it. |
|
||||
| `features/publish-note-event` | An author re-announces a note on NATS (`note.announced` in the audit log); another visitor is refused. |
|
||||
| `features/search-notes` | Similar-note search in Qdrant, when Qdrant is linked. |
|
||||
| `features/export-notes` | The visitor's notes written as one JSON object to the dedicated S3 bucket, when S3 is linked. |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
export { GET } from '@/features/download-file'
|
||||
+28
-3
@@ -6,9 +6,9 @@ import { createServer } from 'node:http'
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
|
||||
const PORT = 4010
|
||||
const state = { audit: [], usage: [], blobs: [] }
|
||||
const state = { audit: [], usage: [], blobs: [], otel: { logs: [], traces: [] } }
|
||||
const catalog = {
|
||||
redis: { display_name: 'Redis', description: 'Counts uploads per visitor each minute.' },
|
||||
kv: { display_name: 'KV Store', description: 'Counts uploads per visitor each minute.' },
|
||||
nats: { display_name: 'NATS', description: 'Announces new notes live to subscribers.' },
|
||||
qdrant: { display_name: 'Qdrant', description: 'Finds similar notes by vector search.' },
|
||||
git: { display_name: 'Git & Packages', description: 'Installs packages from the platform registry.' },
|
||||
@@ -68,6 +68,16 @@ const server = createServer(async (req, res) => {
|
||||
return send(res, 200, { items, next_cursor: null, has_more: false })
|
||||
}
|
||||
|
||||
// @conusai-sdk/observe-node's BatchExporter (showcase D23): POSTs OTLP/HTTP JSON directly to
|
||||
// these `{CONUSAI_API_URL}/otel/v1/{signal}` routes, bearer-authenticated with CONUSAI_API_TOKEN
|
||||
// -- not the OTEL_EXPORTER_OTLP_* env vars, which the SDK never reads itself.
|
||||
if (method === 'POST' && (path === '/otel/v1/logs' || path === '/otel/v1/traces')) {
|
||||
const body = JSON.parse((await readBody(req)).toString('utf8'))
|
||||
const bucket = path.endsWith('/logs') ? state.otel.logs : state.otel.traces
|
||||
bucket.push({ authorization: req.headers['authorization'] ?? null, body })
|
||||
return send(res, 200, {})
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === '/_conusai/usage/events') {
|
||||
const body = JSON.parse((await readBody(req)).toString('utf8'))
|
||||
state.usage.push(...body.events)
|
||||
@@ -83,9 +93,24 @@ const server = createServer(async (req, res) => {
|
||||
contentType: req.headers['content-type'] ?? 'application/octet-stream',
|
||||
size: bytes.length,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
bytes,
|
||||
}
|
||||
state.blobs.push(info)
|
||||
return send(res, 200, info)
|
||||
return send(res, 200, {
|
||||
url: info.url,
|
||||
pathname: info.pathname,
|
||||
contentType: info.contentType,
|
||||
size: info.size,
|
||||
uploadedAt: info.uploadedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /blob/{pathname}, and the real API's /blob/{project_id}/{pathname} shape.
|
||||
if (method === 'GET' && path.startsWith('/blob/')) {
|
||||
const info = state.blobs.find((blob) => path === `/blob/${blob.pathname}` || path.endsWith(`/${blob.pathname}`))
|
||||
if (!info) return send(res, 404, { title: 'not found', path })
|
||||
res.writeHead(200, { 'content-type': info.contentType })
|
||||
return res.end(info.bytes)
|
||||
}
|
||||
|
||||
return send(res, 404, { title: 'not found', path })
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
// Showcase D23: register() in instrumentation.ts starts the OTLP exporters when the platform
|
||||
// injects OTEL_EXPORTER_OTLP_ENDPOINT, and every server action logs through shared/api/observe's
|
||||
// logger(). This proves the whole chain against a real `next start` production server (not dev,
|
||||
// not a mock of register()): a log a server action emits reaches the fake platform's
|
||||
// /otel/v1/logs route within the exporter's 5 s batch interval.
|
||||
|
||||
const platform = 'http://127.0.0.1:4010'
|
||||
|
||||
interface LogRecordJson {
|
||||
body?: { stringValue?: string }
|
||||
attributes?: Array<{ key: string; value: { stringValue?: string } }>
|
||||
}
|
||||
|
||||
type OtelState = { otel: { logs: Array<{ authorization: string | null; body: { resourceLogs: Array<{ scopeLogs: Array<{ logRecords: LogRecordJson[] }> }> } }> } }
|
||||
|
||||
async function otelLogRecords(): Promise<LogRecordJson[]> {
|
||||
const response = await fetch(`${platform}/__e2e/state`)
|
||||
const state = (await response.json()) as OtelState
|
||||
return state.otel.logs.flatMap((post) => post.body.resourceLogs.flatMap((r) => r.scopeLogs.flatMap((s) => s.logRecords)))
|
||||
}
|
||||
|
||||
function attr(record: LogRecordJson, key: string): string | undefined {
|
||||
return record.attributes?.find((kv) => kv.key === key)?.value.stringValue
|
||||
}
|
||||
|
||||
async function createNote(page: Page, title: string): Promise<void> {
|
||||
await page.goto('/')
|
||||
await page.getByLabel('Title').fill(title)
|
||||
await page.getByLabel('Body').fill('observability e2e')
|
||||
await page.getByRole('button', { name: 'Create note' }).click()
|
||||
await expect(page.getByRole('heading', { name: title })).toBeVisible()
|
||||
}
|
||||
|
||||
test('a note.created server action log reaches the OTLP receiver within the batch interval', async ({ page }) => {
|
||||
const title = `otlp ${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
await createNote(page, title)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await otelLogRecords()).some((r) => r.body?.stringValue === 'note.created' && attr(r, 'outcome') === 'ok'),
|
||||
// BatchExporter's default flush interval is 5 s (exporter.ts); this allows one full
|
||||
// interval plus slack for the request round trip and CI scheduling jitter.
|
||||
{ timeout: 9_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('the OTLP request carries the deployment token, not the public client', async ({ page }) => {
|
||||
const title = `otlp-auth ${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
await createNote(page, title)
|
||||
|
||||
await expect
|
||||
.poll(async () => (await otelLogRecords()).some((r) => r.body?.stringValue === 'note.created'), { timeout: 9_000 })
|
||||
.toBe(true)
|
||||
|
||||
const response = await fetch(`${platform}/__e2e/state`)
|
||||
const state = (await response.json()) as OtelState
|
||||
expect(state.otel.logs.every((post) => post.authorization === 'Bearer e2e-deployment-token')).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
// Proves, against a real production build (next build + next start, plan D18), that a
|
||||
// visit actually uploads a session replay segment — not just that the SDK is configured
|
||||
// to try. instrumentation-client.ts's /api/_conusai/* calls are same-origin and answered
|
||||
// by the platform's edge on a real deployment (docs/how-to/use-platform-analytics-in-an-
|
||||
// app.md §"Why this works"); this app has no edge in front of it here, so the test stands
|
||||
// in for it with the minimum the SDK needs: a session id from /ids, and a place to post
|
||||
// the envelope. This caught the stage regression (2026-10-11, showcase plan D24) where
|
||||
// mode: 'always' alone produced zero /api/_conusai/envelope requests because replay is a
|
||||
// 'consented' item and the default consent mode never sends those.
|
||||
|
||||
const SESSION_ID = '11111111-1111-1111-1111-111111111111'
|
||||
|
||||
test('a visit uploads a session replay segment within 15 s on a production build', async ({ page }) => {
|
||||
let envelopePosts = 0
|
||||
|
||||
// Analytics, vitals and errors also call the same-origin /api/_conusai/* paths; answer
|
||||
// them all generically so the run is quiet and deterministic, except the two the
|
||||
// recorder actually needs.
|
||||
await page.route('**/api/_conusai/**', (route) => {
|
||||
const url = route.request().url()
|
||||
if (url.includes('/api/_conusai/ids')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ v: 1, session_id: SESSION_ID }) })
|
||||
}
|
||||
if (url.includes('/api/_conusai/envelope')) {
|
||||
envelopePosts += 1
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||
}
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await page.getByLabel('Title').click()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await page.goto('/audit')
|
||||
await page.goto('/')
|
||||
|
||||
await expect.poll(() => envelopePosts, { timeout: 15_000, message: 'no POST to /api/_conusai/envelope (replay segment)' }).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -61,13 +61,21 @@ test('an attachment moves the usage meter and is metered on the platform', async
|
||||
await expect
|
||||
.poll(async () => (await platformState()).usage.length, { timeout: 15_000 })
|
||||
.toBeGreaterThan(usageBefore)
|
||||
|
||||
// The attachment downloads through the app: the platform's blob URL needs the
|
||||
// deployment token, which the browser does not have.
|
||||
const href = await note.getByRole('link', { name: 'notes.txt' }).getAttribute('href')
|
||||
expect(href).toMatch(/^\/files\/[0-9a-f-]+$/)
|
||||
const download = await page.request.get(href ?? '')
|
||||
expect(download.status()).toBe(200)
|
||||
expect(await download.text()).toBe('sixteen bytes!!!')
|
||||
})
|
||||
|
||||
test('each optional module without its variables shows its enable card', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.getByText(/to enable this\.$/)).toHaveCount(5)
|
||||
await expect(page.getByText('Link Redis to enable this.')).toBeVisible()
|
||||
await expect(page.getByText('Link KV Store to enable this.')).toBeVisible()
|
||||
await expect(page.getByText(/uploads left this minute/)).toHaveCount(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,14 @@ const config = [
|
||||
{ ignores: ['.next/**', 'node_modules/**', 'drizzle/**'] },
|
||||
...nextVitals,
|
||||
...nextTypescript,
|
||||
{
|
||||
rules: {
|
||||
// `useActionState`'s action takes (state, formData); an action that only
|
||||
// needs the latter still has to name the former. `_`-prefixed is the
|
||||
// call site's way of saying "unused on purpose".
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default config
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
// Runs in the browser before the app. On the platform the page's own origin is
|
||||
// the collector, so no configuration is needed here.
|
||||
//
|
||||
// Replay mode 'always' (showcase plan D22): the SDK's default ('onError') only uploads a
|
||||
// replay when an error is captured or replay.flush() is called, so a plain showcase visit
|
||||
// with no error produces nothing to show on Analytics > Session Replays. 'always' streams
|
||||
// from page start regardless of errors, so every visit is recorded.
|
||||
//
|
||||
// consent: 'opt-out' (showcase plan D24): replay (like identify) is a 'consented' item
|
||||
// (@conusai-sdk/observe's core/consent.ts verdict()), and the default consent mode,
|
||||
// 'anonymous', never sends consented items — mode: 'always' alone does not start the
|
||||
// recorder at all, because canRecord() gates on core.permits('consented'). The showcase
|
||||
// has no consent banner (plan D16: no user auth), so 'opt-out' starts the session granted;
|
||||
// GPC/DNT still revokes it (respectGPC defaults to true).
|
||||
//
|
||||
// Every module explicit, on purpose (showcase plan D25): @conusai-sdk/observe
|
||||
// enables only `errors` by default — analytics, vitals, traces, replay and logs
|
||||
// are each off unless named under `modules`. A showcase that forgot one would
|
||||
// show nothing on that console page after a real visit (first seen 2026-10-11,
|
||||
// stage: Analytics read 0 visitors/0 sessions and Observe read "Traces off"/
|
||||
// "Audit off" because only replay was enabled). test/instrumentation-client.test.ts
|
||||
// fails if any of the five is left off or set to a bare `false`.
|
||||
import { instrumentationClient } from '@conusai-sdk/observe-next'
|
||||
|
||||
instrumentationClient()
|
||||
instrumentationClient({
|
||||
consent: 'opt-out',
|
||||
modules: {
|
||||
analytics: true,
|
||||
vitals: true,
|
||||
traces: true,
|
||||
replay: { mode: 'always' },
|
||||
logs: true,
|
||||
},
|
||||
})
|
||||
|
||||
+3
-1
@@ -1,2 +1,4 @@
|
||||
// Next calls this once per server process. The Node error client starts here (plan §4.4).
|
||||
// Next calls this once per server process. The Node error client starts here (plan §4.4), and so
|
||||
// do the OTLP log/trace exporters, when the platform injected OTEL_EXPORTER_OTLP_ENDPOINT (D23);
|
||||
// shared/api/observe's `logger()` is how the rest of the app reaches the logger this starts.
|
||||
export { register } from '@conusai-sdk/observe-next'
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { NextConfig } from 'next'
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
poweredByHeader: false,
|
||||
experimental: {
|
||||
// Uploads post to a server action, whose body Next caps at 1 MB by default;
|
||||
// the app accepts attachments up to 5 MiB (entities/file MAX_UPLOAD_BYTES).
|
||||
serverActions: { bodySizeLimit: '6mb' },
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
||||
Generated
-10013
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1146.0",
|
||||
"@conusai-sdk/api": "0.1.1-dev.20261013.85463d22",
|
||||
"@conusai-sdk/blob": "0.0.5-dev.20261013.85463d22",
|
||||
"@conusai-sdk/kv": "0.0.5-dev.20261013.85463d22",
|
||||
"@conusai-sdk/node-sdk": "0.2.2-dev.20261013.85463d22",
|
||||
"@conusai-sdk/observe": "0.1.0-dev.20261013.85463d22",
|
||||
"@conusai-sdk/observe-next": "0.1.0-dev.20261013.85463d22",
|
||||
"@conusai-sdk/observe-node": "0.1.1-dev.20261013.85463d22",
|
||||
"@conusai-sdk/observe-react": "0.1.0-dev.20261013.85463d22",
|
||||
"drizzle-orm": "0.45.3",
|
||||
"nats": "2.29.3",
|
||||
"next": "16.3.8",
|
||||
@@ -28,6 +36,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@conusai-sdk/observe-testing": "0.1.0-dev.20261013.85463d22",
|
||||
"@eslint/eslintrc": "3.3.7",
|
||||
"@feature-sliced/steiger-plugin": "0.8.0",
|
||||
"@playwright/test": "1.63.0",
|
||||
@@ -37,6 +46,7 @@
|
||||
"drizzle-kit": "0.31.11",
|
||||
"eslint": "9.39.5",
|
||||
"eslint-config-next": "16.3.8",
|
||||
"playwright-core": "1.63.0",
|
||||
"steiger": "0.7.0",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "5.0.3"
|
||||
|
||||
@@ -42,6 +42,13 @@ export default defineConfig({
|
||||
CONUSAI_PROJECT_ID: 'e2e',
|
||||
CONUSAI_DSN: "http://e2e@127.0.0.1:4010/1",
|
||||
CONUSAI_RELEASE: 'e2e',
|
||||
// Mirrors env_resolver.rs's OTEL_* derivation from CONUSAI_API_URL/CONUSAI_API_TOKEN
|
||||
// (showcase D23): without these, register() never calls initOtlp() and the otlp e2e
|
||||
// spec would pass for the wrong reason (nothing ever attempted, rather than delivered).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: `${platform}/api/otel`,
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: 'http/protobuf',
|
||||
OTEL_EXPORTER_OTLP_HEADERS: 'Authorization=Bearer e2e-deployment-token',
|
||||
OTEL_SERVICE_NAME: 'showcase-e2e',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||
<rect width="32" height="32" rx="6" fill="#2563eb" />
|
||||
<text x="16" y="22" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#ffffff" text-anchor="middle">C</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 283 B |
@@ -0,0 +1,232 @@
|
||||
/* Global stylesheet for the Showcase.
|
||||
*
|
||||
* The template deliberately ships no Tailwind or CSS framework (D11 keeps
|
||||
* every layer dependency-light), so this file carries the whole design with
|
||||
* element selectors only — no class names exist in the markup. Style new
|
||||
* markup with semantic elements (main, section, aside, form, ol/ul, pre)
|
||||
* rather than adding utility classes.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--border: #e2e5ea;
|
||||
--text: #1a202c;
|
||||
--muted: #5b6472;
|
||||
--accent: #4f46e5;
|
||||
--accent-hover: #4338ca;
|
||||
--danger: #b91c1c;
|
||||
--radius: 10px;
|
||||
--shadow: 0 1px 2px rgb(16 24 40 / 0.06);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 56rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.25rem 4rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
margin: 1.75rem 0 0.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
main > p:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Forms: the create/edit note forms and every action button. */
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
button {
|
||||
align-self: flex-start;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
form[action=''] + button,
|
||||
main > button {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
/* Lists: notes (ul) and the audit timeline (ol). */
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
margin: 1rem 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
ul li,
|
||||
ol li {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
ul li h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
time {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The module panel: one card per optional service, enable cards included. */
|
||||
|
||||
section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
section > div {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
section > div > aside,
|
||||
section > div > form {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
section aside {
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
section aside p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
section aside p + p {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
section form {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
section button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* The platform page's raw description payload. */
|
||||
|
||||
pre {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.9rem 1rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
main {
|
||||
padding: 1.25rem 1rem 3rem;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Observe } from '@/app/observability/observe'
|
||||
import '../globals.css'
|
||||
|
||||
/** The document title every page inherits (axe: document-title). */
|
||||
export const metadata: Metadata = {
|
||||
title: 'ConusAI Showcase',
|
||||
description: 'Notes, attachments, audit and usage on the ConusAI platform',
|
||||
// `app/favicon.ico` and `app/icon.svg` already serve `/favicon.ico` and
|
||||
// `/icon.svg` through Next's own file convention; `icons.icon` here is what
|
||||
// makes the page's own <link> tag name `/favicon.svg` (served from
|
||||
// `public/favicon.svg`) instead of `/icon.svg`, since both paths 404'd on
|
||||
// every visit before this (stage sweep, 2026-10-11).
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
||||
{ url: '/favicon.ico', sizes: 'any' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export function RootLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -23,6 +23,16 @@ export function fileRepository(db: Db) {
|
||||
return db.select().from(files).where(eq(files.noteId, noteId))
|
||||
},
|
||||
|
||||
/** The attachment, or `undefined` when `authorId` did not attach it. */
|
||||
async get(id: string, authorId: string): Promise<FileRow | undefined> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(and(eq(files.id, id), eq(files.authorId, authorId)))
|
||||
.limit(1)
|
||||
return row
|
||||
},
|
||||
|
||||
/** Every attachment the visitor made, across their notes. */
|
||||
async listByAuthor(authorId: string): Promise<FileRow[]> {
|
||||
return db.select().from(files).where(eq(files.authorId, authorId))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { files, type FileRow } from './model/schema'
|
||||
export { fileId } from './model/id'
|
||||
export { MAX_UPLOAD_BYTES, ALLOWED_CONTENT_TYPES, uploadLimits, type UploadCandidate } from './model/limits'
|
||||
export { fileRepository, type NewFile } from './api/repository'
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/** A file id as it arrives in a URL or a form: a UUID, nothing else. */
|
||||
export const fileId = z.uuid()
|
||||
@@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => {
|
||||
publishNoteCreated: vi.fn(async () => {}),
|
||||
indexNote: vi.fn(async () => {}),
|
||||
revalidatePath: vi.fn(),
|
||||
loggerInfo: vi.fn(),
|
||||
loggerRecordException: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,6 +24,7 @@ vi.mock('@/shared/api', () => ({
|
||||
db: () => ({ insert: mocks.insert }),
|
||||
audit: () => ({ log: mocks.log }),
|
||||
serverErrors: () => ({ captureException: vi.fn() }),
|
||||
logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }),
|
||||
}))
|
||||
vi.mock('@/shared/lib/optional', () => ({
|
||||
publishNoteCreated: mocks.publishNoteCreated,
|
||||
@@ -43,6 +46,8 @@ describe('createNote', () => {
|
||||
mocks.insert.mockClear()
|
||||
mocks.log.mockClear()
|
||||
mocks.revalidatePath.mockClear()
|
||||
mocks.loggerInfo.mockClear()
|
||||
mocks.loggerRecordException.mockClear()
|
||||
})
|
||||
|
||||
it('writes the note under the visitor and audits exactly one note.created', async () => {
|
||||
@@ -55,12 +60,18 @@ describe('createNote', () => {
|
||||
target: { id: mocks.row.id },
|
||||
})
|
||||
expect(mocks.revalidatePath).toHaveBeenCalledWith('/')
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledWith('note.created', { 'note.id': mocks.row.id, outcome: 'ok' })
|
||||
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an invalid title before any write or audit', async () => {
|
||||
it('refuses an invalid title before any write or audit, and logs the failure', async () => {
|
||||
await expect(createNote(form({ title: ' ' }))).rejects.toThrow()
|
||||
expect(mocks.insert).not.toHaveBeenCalled()
|
||||
expect(mocks.log).not.toHaveBeenCalled()
|
||||
expect(mocks.loggerInfo).not.toHaveBeenCalled()
|
||||
expect(mocks.loggerRecordException).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'note.created' })
|
||||
})
|
||||
|
||||
it('announces and indexes the new note when the optional modules are linked', async () => {
|
||||
|
||||
@@ -2,21 +2,27 @@
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { noteInput, noteRepository } from '@/entities/note'
|
||||
import { audit, db, serverErrors } from '@/shared/api'
|
||||
import { audit, db, logger, serverErrors } from '@/shared/api'
|
||||
import { indexNote, publishNoteCreated } from '@/shared/lib/optional'
|
||||
import { ensureVisitor } from '@/shared/lib/visitor-session'
|
||||
|
||||
/** Parse, write, audit, revalidate (plan §4.4): the shape every mutation follows. */
|
||||
export async function createNote(form: FormData): Promise<void> {
|
||||
const input = noteInput.parse({ title: form.get('title'), body: form.get('body') ?? '' })
|
||||
const actor = await ensureVisitor()
|
||||
const note = await noteRepository(db()).create({ ...input, authorId: actor })
|
||||
await audit().log('note.created', { actor: { kind: 'human', id: actor }, target: { id: note.id } })
|
||||
// Optional modules: NATS hears of the note and Qdrant indexes it when linked. A failure is
|
||||
// reported, not raised: the note is saved.
|
||||
await Promise.all([
|
||||
publishNoteCreated({ id: note.id, title: note.title }),
|
||||
indexNote({ id: note.id, title: note.title, body: note.body }),
|
||||
]).catch((error: unknown) => serverErrors().captureException(error))
|
||||
revalidatePath('/')
|
||||
try {
|
||||
const input = noteInput.parse({ title: form.get('title'), body: form.get('body') ?? '' })
|
||||
const actor = await ensureVisitor()
|
||||
const note = await noteRepository(db()).create({ ...input, authorId: actor })
|
||||
await audit().log('note.created', { actor: { kind: 'human', id: actor }, target: { id: note.id } })
|
||||
logger()?.info('note.created', { 'note.id': note.id, outcome: 'ok' })
|
||||
// Optional modules: NATS hears of the note and Qdrant indexes it when linked. A failure is
|
||||
// reported, not raised: the note is saved.
|
||||
await Promise.all([
|
||||
publishNoteCreated({ id: note.id, title: note.title }),
|
||||
indexNote({ id: note.id, title: note.title, body: note.body }),
|
||||
]).catch((error: unknown) => serverErrors().captureException(error))
|
||||
revalidatePath('/')
|
||||
} catch (error) {
|
||||
logger()?.recordException(error, { 'conusai.action': 'note.created' })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ const mocks = vi.hoisted(() => ({
|
||||
del: vi.fn(async () => {}),
|
||||
log: vi.fn(async () => {}),
|
||||
revalidatePath: vi.fn(),
|
||||
loggerInfo: vi.fn(),
|
||||
loggerRecordException: vi.fn(),
|
||||
removed: [] as Array<{ id: string; url: string }>,
|
||||
}))
|
||||
|
||||
@@ -21,6 +23,7 @@ vi.mock('@/shared/api', () => ({
|
||||
db: () => fakeDb,
|
||||
audit: () => ({ log: mocks.log }),
|
||||
blob: () => ({ del: mocks.del }),
|
||||
logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }),
|
||||
}))
|
||||
vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => 'visitor-1' }))
|
||||
|
||||
@@ -31,10 +34,12 @@ describe('deleteFile', () => {
|
||||
mocks.del.mockClear()
|
||||
mocks.log.mockClear()
|
||||
mocks.revalidatePath.mockClear()
|
||||
mocks.loggerInfo.mockClear()
|
||||
mocks.loggerRecordException.mockClear()
|
||||
mocks.removed.length = 0
|
||||
})
|
||||
|
||||
it('removes the stored bytes and audits file.deleted once', async () => {
|
||||
it('removes the stored bytes and audits file.deleted once, and logs it', async () => {
|
||||
mocks.removed.push({ id: FILE, url: 'https://blob.example.com/notes/n/a.png' })
|
||||
await deleteFile(FILE)
|
||||
|
||||
@@ -44,11 +49,16 @@ describe('deleteFile', () => {
|
||||
actor: { kind: 'human', id: 'visitor-1' },
|
||||
target: { id: FILE },
|
||||
})
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledWith('file.deleted', { 'file.id': FILE, outcome: 'ok' })
|
||||
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('touches nothing when the visitor did not attach the file', async () => {
|
||||
it('touches nothing when the visitor did not attach the file, and logs it denied', async () => {
|
||||
await deleteFile(FILE)
|
||||
expect(mocks.del).not.toHaveBeenCalled()
|
||||
expect(mocks.log).not.toHaveBeenCalled()
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledWith('file.deleted', { 'file.id': FILE, outcome: 'denied' })
|
||||
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,16 +3,25 @@
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { fileRepository } from '@/entities/file'
|
||||
import { noteId } from '@/entities/note'
|
||||
import { audit, blob, db } from '@/shared/api'
|
||||
import { audit, blob, db, logger } from '@/shared/api'
|
||||
import { ensureVisitor } from '@/shared/lib/visitor-session'
|
||||
|
||||
/** Remove an attachment the visitor attached: its row first, then its stored bytes. */
|
||||
export async function deleteFile(id: string): Promise<void> {
|
||||
const key = noteId.parse(id)
|
||||
const actor = await ensureVisitor()
|
||||
const removed = await fileRepository(db()).remove(key, actor)
|
||||
if (!removed) return
|
||||
await blob().del(removed.url)
|
||||
await audit().log('file.deleted', { actor: { kind: 'human', id: actor }, target: { id: key } })
|
||||
revalidatePath('/')
|
||||
try {
|
||||
const key = noteId.parse(id)
|
||||
const actor = await ensureVisitor()
|
||||
const removed = await fileRepository(db()).remove(key, actor)
|
||||
if (!removed) {
|
||||
logger()?.info('file.deleted', { 'file.id': key, outcome: 'denied' })
|
||||
return
|
||||
}
|
||||
await blob().del(removed.url)
|
||||
await audit().log('file.deleted', { actor: { kind: 'human', id: actor }, target: { id: key } })
|
||||
logger()?.info('file.deleted', { 'file.id': key, outcome: 'ok' })
|
||||
revalidatePath('/')
|
||||
} catch (error) {
|
||||
logger()?.recordException(error, { 'conusai.action': 'file.deleted', 'file.id': id })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ const NOTE = '00000000-0000-4000-8000-0000000000aa'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
log: vi.fn(async () => {}),
|
||||
revalidatePath: vi.fn(),
|
||||
loggerInfo: vi.fn(),
|
||||
loggerRecordException: vi.fn(),
|
||||
removed: [] as Array<{ id: string }>,
|
||||
}))
|
||||
|
||||
@@ -12,7 +14,11 @@ const fakeDb = {
|
||||
}
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath }))
|
||||
vi.mock('@/shared/api', () => ({ db: () => fakeDb, audit: () => ({ log: mocks.log }) }))
|
||||
vi.mock('@/shared/api', () => ({
|
||||
db: () => fakeDb,
|
||||
audit: () => ({ log: mocks.log }),
|
||||
logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }),
|
||||
}))
|
||||
vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => 'visitor-1' }))
|
||||
|
||||
import { deleteNote } from '@/features/delete-note/api/actions'
|
||||
@@ -21,10 +27,12 @@ describe('deleteNote', () => {
|
||||
beforeEach(() => {
|
||||
mocks.log.mockClear()
|
||||
mocks.revalidatePath.mockClear()
|
||||
mocks.loggerInfo.mockClear()
|
||||
mocks.loggerRecordException.mockClear()
|
||||
mocks.removed.length = 0
|
||||
})
|
||||
|
||||
it('audits note.deleted exactly once with the actor and the note', async () => {
|
||||
it('audits note.deleted exactly once with the actor and the note, and logs it', async () => {
|
||||
mocks.removed.push({ id: NOTE })
|
||||
await deleteNote(NOTE)
|
||||
|
||||
@@ -33,10 +41,15 @@ describe('deleteNote', () => {
|
||||
actor: { kind: 'human', id: 'visitor-1' },
|
||||
target: { id: NOTE },
|
||||
})
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledWith('note.deleted', { 'note.id': NOTE, outcome: 'ok' })
|
||||
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('audits nothing when the visitor did not write the note', async () => {
|
||||
it('audits nothing when the visitor did not write the note, and logs it denied', async () => {
|
||||
await deleteNote(NOTE)
|
||||
expect(mocks.log).not.toHaveBeenCalled()
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledWith('note.deleted', { 'note.id': NOTE, outcome: 'denied' })
|
||||
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,16 +2,24 @@
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { noteId, noteRepository } from '@/entities/note'
|
||||
import { audit } from '@/shared/api'
|
||||
import { db } from '@/shared/api'
|
||||
import { audit, db, logger } from '@/shared/api'
|
||||
import { ensureVisitor } from '@/shared/lib/visitor-session'
|
||||
|
||||
/** Delete a note the visitor wrote. A note they did not write is left untouched. */
|
||||
export async function deleteNote(id: string): Promise<void> {
|
||||
const key = noteId.parse(id)
|
||||
const actor = await ensureVisitor()
|
||||
const removed = await noteRepository(db()).remove(key, actor)
|
||||
if (!removed) return
|
||||
await audit().log('note.deleted', { actor: { kind: 'human', id: actor }, target: { id: key } })
|
||||
revalidatePath('/')
|
||||
try {
|
||||
const key = noteId.parse(id)
|
||||
const actor = await ensureVisitor()
|
||||
const removed = await noteRepository(db()).remove(key, actor)
|
||||
if (!removed) {
|
||||
logger()?.info('note.deleted', { 'note.id': key, outcome: 'denied' })
|
||||
return
|
||||
}
|
||||
await audit().log('note.deleted', { actor: { kind: 'human', id: actor }, target: { id: key } })
|
||||
logger()?.info('note.deleted', { 'note.id': key, outcome: 'ok' })
|
||||
revalidatePath('/')
|
||||
} catch (error) {
|
||||
logger()?.recordException(error, { 'conusai.action': 'note.deleted', 'note.id': id })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The download route runs against fakes: a Blob Storage client whose download
|
||||
// answers with known bytes, and a database whose attachment row is set per test.
|
||||
const FILE = '00000000-0000-4000-8000-0000000000ff'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
download: vi.fn(async () => new Response('sixteen bytes!!!')),
|
||||
row: undefined as { id: string; name: string; contentType: string; url: string } | undefined,
|
||||
visitor: 'visitor-1' as string | null,
|
||||
}))
|
||||
|
||||
const fakeDb = {
|
||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => (mocks.row ? [mocks.row] : []) }) }) }),
|
||||
}
|
||||
|
||||
vi.mock('@/shared/api', () => ({
|
||||
db: () => fakeDb,
|
||||
blob: () => ({ download: mocks.download }),
|
||||
}))
|
||||
vi.mock('@/shared/lib/visitor-session', () => ({ currentVisitor: async () => mocks.visitor }))
|
||||
|
||||
import { downloadAttachment, GET } from '@/features/download-file/api/route'
|
||||
|
||||
describe('downloadAttachment', () => {
|
||||
beforeEach(() => {
|
||||
mocks.download.mockClear()
|
||||
mocks.visitor = 'visitor-1'
|
||||
mocks.row = {
|
||||
id: FILE,
|
||||
name: 'notes.txt',
|
||||
contentType: 'text/plain',
|
||||
url: 'https://blob.example.com/api/blob/7/notes/n/notes.txt',
|
||||
}
|
||||
})
|
||||
|
||||
it('streams the bytes with the stored content type and an attachment disposition', async () => {
|
||||
const response = await downloadAttachment(FILE)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toBe('sixteen bytes!!!')
|
||||
expect(response.headers.get('content-type')).toBe('text/plain')
|
||||
expect(response.headers.get('content-disposition')).toBe("attachment; filename*=UTF-8''notes.txt")
|
||||
expect(mocks.download).toHaveBeenCalledWith(mocks.row?.url)
|
||||
})
|
||||
|
||||
it('answers 404 for a file row the visitor does not own', async () => {
|
||||
mocks.row = undefined
|
||||
const response = await downloadAttachment(FILE)
|
||||
expect(response.status).toBe(404)
|
||||
expect(mocks.download).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers 404 when there is no visitor cookie', async () => {
|
||||
mocks.visitor = null
|
||||
expect((await downloadAttachment(FILE)).status).toBe(404)
|
||||
expect(mocks.download).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers 404 for an id that is not a UUID', async () => {
|
||||
expect((await downloadAttachment('../../etc/passwd')).status).toBe(404)
|
||||
expect(mocks.download).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('encodes a hostile file name into the disposition', async () => {
|
||||
if (mocks.row) mocks.row.name = 'a"\r\nb.txt'
|
||||
const response = await downloadAttachment(FILE)
|
||||
expect(response.headers.get('content-disposition')).toBe("attachment; filename*=UTF-8''a%22%0D%0Ab.txt")
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET', () => {
|
||||
it('takes the id from the route params', async () => {
|
||||
mocks.row = {
|
||||
id: FILE,
|
||||
name: 'notes.txt',
|
||||
contentType: 'text/plain',
|
||||
url: 'https://blob.example.com/api/blob/7/notes/n/notes.txt',
|
||||
}
|
||||
const response = await GET(new Request('http://localhost/files/x'), { params: Promise.resolve({ id: FILE }) })
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'server-only'
|
||||
import { fileId, fileRepository } from '@/entities/file'
|
||||
import { blob, db } from '@/shared/api'
|
||||
import { currentVisitor } from '@/shared/lib/visitor-session'
|
||||
|
||||
function notFound(): Response {
|
||||
return new Response('not found', { status: 404 })
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes of one attachment, for the visitor who attached it. The platform's
|
||||
* blob URL needs the deployment token, which the browser does not have, so the
|
||||
* app downloads server-side and streams the bytes back. Anything that is not
|
||||
* the visitor's own attachment answers 404, never 403.
|
||||
*/
|
||||
export async function downloadAttachment(id: string): Promise<Response> {
|
||||
const parsed = fileId.safeParse(id)
|
||||
if (!parsed.success) return notFound()
|
||||
const visitor = await currentVisitor()
|
||||
if (!visitor) return notFound()
|
||||
const file = await fileRepository(db()).get(parsed.data, visitor)
|
||||
if (!file) return notFound()
|
||||
const upstream = await blob().download(file.url)
|
||||
if (!upstream.body) return notFound()
|
||||
return new Response(upstream.body, {
|
||||
headers: {
|
||||
'content-type': file.contentType,
|
||||
// RFC 5987: the name is percent-encoded, so quotes and CR/LF cannot inject.
|
||||
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** `GET /files/<id>`: the route handler the app router re-exports. */
|
||||
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }): Promise<Response> {
|
||||
return downloadAttachment((await context.params).id)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GET } from './api/route'
|
||||
@@ -4,6 +4,8 @@ const NOTE = '00000000-0000-4000-8000-0000000000aa'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
log: vi.fn(async () => {}),
|
||||
revalidatePath: vi.fn(),
|
||||
loggerInfo: vi.fn(),
|
||||
loggerRecordException: vi.fn(),
|
||||
updated: undefined as { id: string } | undefined,
|
||||
}))
|
||||
|
||||
@@ -16,7 +18,11 @@ const fakeDb = {
|
||||
}
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath }))
|
||||
vi.mock('@/shared/api', () => ({ db: () => fakeDb, audit: () => ({ log: mocks.log }) }))
|
||||
vi.mock('@/shared/api', () => ({
|
||||
db: () => fakeDb,
|
||||
audit: () => ({ log: mocks.log }),
|
||||
logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }),
|
||||
}))
|
||||
vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => 'visitor-1' }))
|
||||
|
||||
import { updateNote } from '@/features/edit-note/api/actions'
|
||||
@@ -32,10 +38,12 @@ describe('updateNote', () => {
|
||||
beforeEach(() => {
|
||||
mocks.log.mockClear()
|
||||
mocks.revalidatePath.mockClear()
|
||||
mocks.loggerInfo.mockClear()
|
||||
mocks.loggerRecordException.mockClear()
|
||||
mocks.updated = undefined
|
||||
})
|
||||
|
||||
it('audits note.updated exactly once with the actor and the note', async () => {
|
||||
it('audits note.updated exactly once with the actor and the note, and logs the outcome', async () => {
|
||||
mocks.updated = { id: NOTE }
|
||||
await updateNote(NOTE, edit('Renamed'))
|
||||
|
||||
@@ -44,10 +52,23 @@ describe('updateNote', () => {
|
||||
actor: { kind: 'human', id: 'visitor-1' },
|
||||
target: { id: NOTE },
|
||||
})
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledWith('note.updated', { 'note.id': NOTE, outcome: 'ok' })
|
||||
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('audits nothing when the visitor did not write the note', async () => {
|
||||
it('audits nothing when the visitor did not write the note, and logs it denied', async () => {
|
||||
await updateNote(NOTE, edit('Renamed'))
|
||||
expect(mocks.log).not.toHaveBeenCalled()
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledWith('note.updated', { 'note.id': NOTE, outcome: 'denied' })
|
||||
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs the failure when the input is invalid', async () => {
|
||||
await expect(updateNote(NOTE, edit(' '))).rejects.toThrow()
|
||||
expect(mocks.log).not.toHaveBeenCalled()
|
||||
expect(mocks.loggerInfo).not.toHaveBeenCalled()
|
||||
expect(mocks.loggerRecordException).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'note.updated', 'note.id': NOTE })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,17 +2,25 @@
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { noteId, noteInput, noteRepository } from '@/entities/note'
|
||||
import { audit } from '@/shared/api'
|
||||
import { db } from '@/shared/api'
|
||||
import { audit, db, logger } from '@/shared/api'
|
||||
import { ensureVisitor } from '@/shared/lib/visitor-session'
|
||||
|
||||
/** Edit a note the visitor wrote. A note they did not write is left untouched. */
|
||||
export async function updateNote(id: string, form: FormData): Promise<void> {
|
||||
const key = noteId.parse(id)
|
||||
const input = noteInput.parse({ title: form.get('title'), body: form.get('body') ?? '' })
|
||||
const actor = await ensureVisitor()
|
||||
const note = await noteRepository(db()).update(key, actor, input)
|
||||
if (!note) return
|
||||
await audit().log('note.updated', { actor: { kind: 'human', id: actor }, target: { id: note.id } })
|
||||
revalidatePath('/')
|
||||
try {
|
||||
const key = noteId.parse(id)
|
||||
const input = noteInput.parse({ title: form.get('title'), body: form.get('body') ?? '' })
|
||||
const actor = await ensureVisitor()
|
||||
const note = await noteRepository(db()).update(key, actor, input)
|
||||
if (!note) {
|
||||
logger()?.info('note.updated', { 'note.id': key, outcome: 'denied' })
|
||||
return
|
||||
}
|
||||
await audit().log('note.updated', { actor: { kind: 'human', id: actor }, target: { id: note.id } })
|
||||
logger()?.info('note.updated', { 'note.id': note.id, outcome: 'ok' })
|
||||
revalidatePath('/')
|
||||
} catch (error) {
|
||||
logger()?.recordException(error, { 'conusai.action': 'note.updated', 'note.id': id })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
exportToS3: vi.fn(async () => 'exports/visitor-1/now.json' as string | null),
|
||||
notes: [{ id: 'n-1', title: 'Plan' }],
|
||||
loggerInfo: vi.fn(),
|
||||
loggerRecordException: vi.fn(),
|
||||
log: vi.fn(async () => {}),
|
||||
captureException: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/api', () => ({
|
||||
db: () => ({
|
||||
select: () => ({ from: () => ({ where: () => ({ orderBy: () => ({ limit: async () => mocks.notes }) }) }) }),
|
||||
}),
|
||||
logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }),
|
||||
audit: () => ({ log: mocks.log }),
|
||||
serverErrors: () => ({ captureException: mocks.captureException }),
|
||||
}))
|
||||
vi.mock('@/shared/lib/optional', () => ({ exportToS3: mocks.exportToS3 }))
|
||||
vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => 'visitor-1' }))
|
||||
@@ -18,11 +25,39 @@ import { exportNotes } from '@/features/export-notes/api/actions'
|
||||
describe('exportNotes', () => {
|
||||
beforeEach(() => {
|
||||
mocks.exportToS3.mockClear()
|
||||
mocks.loggerInfo.mockClear()
|
||||
mocks.loggerRecordException.mockClear()
|
||||
mocks.log.mockClear()
|
||||
mocks.captureException.mockClear()
|
||||
})
|
||||
|
||||
it('writes the visitor’s own notes to S3 once', async () => {
|
||||
await exportNotes()
|
||||
it('writes the visitor’s own notes to S3 once, logs the outcome, and audits notes.exported with the key', async () => {
|
||||
const result = await exportNotes(null, new FormData())
|
||||
expect(mocks.exportToS3).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.exportToS3).toHaveBeenCalledWith('visitor-1', mocks.notes)
|
||||
expect(result).toEqual({ ok: true, key: 'exports/visitor-1/now.json', size: expect.any(Number) })
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledWith('notes.exported', { 'actor.id': 'visitor-1', 'export.count': 1, outcome: 'ok' })
|
||||
expect(mocks.log).toHaveBeenCalledWith('notes.exported', {
|
||||
actor: { kind: 'human', id: 'visitor-1' },
|
||||
target: { id: 'exports/visitor-1/now.json' },
|
||||
})
|
||||
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports S3 as not linked, logs it as skipped, and audits nothing, when exportToS3 returns null', async () => {
|
||||
mocks.exportToS3.mockResolvedValueOnce(null)
|
||||
const result = await exportNotes(null, new FormData())
|
||||
expect(result).toEqual({ ok: false, message: 'S3 is not linked; nothing was exported.' })
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledWith('notes.exported', { 'actor.id': 'visitor-1', 'export.count': 1, outcome: 'skipped' })
|
||||
expect(mocks.log).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports the error, logs and captures it, when the S3 write throws', async () => {
|
||||
mocks.exportToS3.mockRejectedValueOnce(new Error('bucket unreachable'))
|
||||
const result = await exportNotes(null, new FormData())
|
||||
expect(result).toEqual({ ok: false, message: 'bucket unreachable' })
|
||||
expect(mocks.loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'notes.exported', 'actor.id': 'visitor-1' })
|
||||
expect(mocks.captureException).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.log).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
'use server'
|
||||
|
||||
import { noteRepository } from '@/entities/note'
|
||||
import { db } from '@/shared/api'
|
||||
import { audit, db, logger, serverErrors } from '@/shared/api'
|
||||
import { exportToS3 } from '@/shared/lib/optional'
|
||||
import { ensureVisitor } from '@/shared/lib/visitor-session'
|
||||
|
||||
/** Writes the visitor's notes to the S3 bucket as one JSON object (S3 module). */
|
||||
export async function exportNotes(): Promise<void> {
|
||||
export type ExportResult = { ok: true; key: string; size: number } | { ok: false; message: string }
|
||||
|
||||
/**
|
||||
* Writes the visitor's notes to the S3 bucket as one JSON object (S3 module).
|
||||
*
|
||||
* The caller (a `useActionState` form) needs the object key/size or the error to show
|
||||
* the visitor, and the write needs an audit event like every other action here — before
|
||||
* this, the button gave no feedback at all and nothing landed in the audit timeline
|
||||
* (stage sweep, 2026-10-11). The `ExportResult | null` / `FormData` signature is
|
||||
* `useActionState`'s action shape, not a plain server action.
|
||||
*/
|
||||
export async function exportNotes(_state: ExportResult | null, _form: FormData): Promise<ExportResult> {
|
||||
const actor = await ensureVisitor()
|
||||
const notes = await noteRepository(db()).listByAuthor(actor)
|
||||
await exportToS3(actor, notes)
|
||||
try {
|
||||
const key = await exportToS3(actor, notes)
|
||||
logger()?.info('notes.exported', { 'actor.id': actor, 'export.count': notes.length, outcome: key ? 'ok' : 'skipped' })
|
||||
if (!key) return { ok: false, message: 'S3 is not linked; nothing was exported.' }
|
||||
await audit().log('notes.exported', { actor: { kind: 'human', id: actor }, target: { id: key } })
|
||||
return { ok: true, key, size: Buffer.byteLength(JSON.stringify(notes)) }
|
||||
} catch (error) {
|
||||
logger()?.recordException(error, { 'conusai.action': 'notes.exported', 'actor.id': actor })
|
||||
serverErrors().captureException(error)
|
||||
return { ok: false, message: error instanceof Error ? error.message : 'the export failed' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ExportNotesButton } from '@/features/export-notes/ui/ExportNotesButton'
|
||||
|
||||
// The button gave no feedback at all after submit: no message, no object key, and
|
||||
// nothing in the audit timeline (stage sweep, 2026-10-11; the audit half is covered by
|
||||
// exportNotes's own tests). This only checks the button's initial render — before any
|
||||
// submission `useActionState`'s state is `null` — so neither the success nor the error
|
||||
// branch has leaked into the markup by mistake.
|
||||
describe('ExportNotesButton', () => {
|
||||
it('renders the form with no result before any submission', () => {
|
||||
const markup = renderToStaticMarkup(<ExportNotesButton />)
|
||||
expect(markup).toContain('<form')
|
||||
expect(markup).toContain('Export my notes to S3')
|
||||
expect(markup).not.toContain('Exported')
|
||||
expect(markup).not.toMatch(/role="alert"/)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,21 @@
|
||||
import { exportNotes } from '@/features/export-notes/api/actions'
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { exportNotes, type ExportResult } from '@/features/export-notes/api/actions'
|
||||
|
||||
export function ExportNotesButton() {
|
||||
const [result, action, pending] = useActionState<ExportResult | null, FormData>(exportNotes, null)
|
||||
return (
|
||||
<form action={exportNotes}>
|
||||
<button type="submit">Export my notes to S3</button>
|
||||
<form action={action}>
|
||||
<button type="submit" disabled={pending}>
|
||||
Export my notes to S3
|
||||
</button>
|
||||
{result?.ok && (
|
||||
<p>
|
||||
Exported {result.size} bytes to <code>{result.key}</code>.
|
||||
</p>
|
||||
)}
|
||||
{result && !result.ok && <p role="alert">{result.message}</p>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const publishNoteCreated = vi.fn<(note: { id: string; title: string }) => Promise<void>>(async () => undefined)
|
||||
const log = vi.fn<(...args: unknown[]) => Promise<void>>(async () => undefined)
|
||||
const get = vi.fn<(id: string) => Promise<{ id: string; title: string; authorId: string } | undefined>>()
|
||||
const loggerInfo = vi.fn()
|
||||
const loggerRecordException = vi.fn()
|
||||
let visitor = 'visitor-a'
|
||||
|
||||
vi.mock('@/shared/lib/optional', () => ({ publishNoteCreated: (note: { id: string; title: string }) => publishNoteCreated(note) }))
|
||||
vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => visitor }))
|
||||
vi.mock('@/entities/note', () => ({ noteRepository: () => ({ get }) }))
|
||||
vi.mock('@/shared/api', () => ({ db: () => ({}), audit: () => ({ log }) }))
|
||||
vi.mock('@/shared/api', () => ({
|
||||
db: () => ({}),
|
||||
audit: () => ({ log }),
|
||||
logger: () => ({ info: loggerInfo, recordException: loggerRecordException }),
|
||||
}))
|
||||
|
||||
const { announceNote } = await import('@/features/publish-note-event/api/actions')
|
||||
|
||||
@@ -17,6 +23,8 @@ describe('announceNote', () => {
|
||||
publishNoteCreated.mockClear()
|
||||
log.mockClear()
|
||||
get.mockReset()
|
||||
loggerInfo.mockClear()
|
||||
loggerRecordException.mockClear()
|
||||
visitor = 'visitor-a'
|
||||
})
|
||||
|
||||
@@ -26,18 +34,25 @@ describe('announceNote', () => {
|
||||
expect(publishNoteCreated).toHaveBeenCalledTimes(1)
|
||||
expect(publishNoteCreated).toHaveBeenCalledWith({ id: 'note-1', title: 'Budget' })
|
||||
expect(log).toHaveBeenCalledWith('note.announced', expect.objectContaining({ target: { id: 'note-1' } }))
|
||||
expect(loggerInfo).toHaveBeenCalledTimes(1)
|
||||
expect(loggerInfo).toHaveBeenCalledWith('note.announced', { 'note.id': 'note-1', outcome: 'ok' })
|
||||
expect(loggerRecordException).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses another visitor\'s note and publishes nothing', async () => {
|
||||
it('refuses another visitor\'s note and publishes nothing, and logs the refusal', async () => {
|
||||
get.mockResolvedValue({ id: 'note-1', title: 'Budget', authorId: 'visitor-b' })
|
||||
await expect(announceNote('note-1')).rejects.toThrow('only the author')
|
||||
expect(publishNoteCreated).not.toHaveBeenCalled()
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
expect(loggerInfo).not.toHaveBeenCalled()
|
||||
expect(loggerRecordException).toHaveBeenCalledTimes(1)
|
||||
expect(loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'note.announced', 'note.id': 'note-1' })
|
||||
})
|
||||
|
||||
it('refuses a note that does not exist', async () => {
|
||||
get.mockResolvedValue(undefined)
|
||||
await expect(announceNote('missing')).rejects.toThrow('only the author')
|
||||
expect(publishNoteCreated).not.toHaveBeenCalled()
|
||||
expect(loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'note.announced', 'note.id': 'missing' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use server'
|
||||
|
||||
import { noteRepository } from '@/entities/note'
|
||||
import { audit, db } from '@/shared/api'
|
||||
import { audit, db, logger } from '@/shared/api'
|
||||
import { publishNoteCreated } from '@/shared/lib/optional'
|
||||
import { ensureVisitor } from '@/shared/lib/visitor-session'
|
||||
|
||||
@@ -11,11 +11,17 @@ import { ensureVisitor } from '@/shared/lib/visitor-session'
|
||||
* and no event. A no-op when NATS is not linked.
|
||||
*/
|
||||
export async function announceNote(noteId: string): Promise<void> {
|
||||
const actor = await ensureVisitor()
|
||||
const note = await noteRepository(db()).get(noteId)
|
||||
if (!note || note.authorId !== actor) throw new Error('only the author can announce this note')
|
||||
await publishNoteCreated({ id: note.id, title: note.title })
|
||||
await audit().log('note.announced', { actor: { kind: 'human', id: actor }, target: { id: note.id } })
|
||||
try {
|
||||
const actor = await ensureVisitor()
|
||||
const note = await noteRepository(db()).get(noteId)
|
||||
if (!note || note.authorId !== actor) throw new Error('only the author can announce this note')
|
||||
await publishNoteCreated({ id: note.id, title: note.title })
|
||||
await audit().log('note.announced', { actor: { kind: 'human', id: actor }, target: { id: note.id } })
|
||||
logger()?.info('note.announced', { 'note.id': note.id, outcome: 'ok' })
|
||||
} catch (error) {
|
||||
logger()?.recordException(error, { 'conusai.action': 'note.announced', 'note.id': noteId })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** The form action behind the button: the note id travels as a hidden field. */
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AnnounceNoteButton } from '@/features/publish-note-event/ui/AnnounceNoteButton'
|
||||
|
||||
// The note list rendered this button unconditionally while a nearby card read
|
||||
// "Link NATS to enable this" (stage sweep, 2026-10-11): clicking it with NATS
|
||||
// unlinked submitted a form whose action is a no-op (`publishNoteCreated` returns
|
||||
// early without a connection). The button must agree with the module panel's own
|
||||
// `nats` slot about whether the module is linked, consistent with the enable-card
|
||||
// pattern used everywhere else in the template.
|
||||
describe('AnnounceNoteButton', () => {
|
||||
it('renders the live form when NATS is linked', () => {
|
||||
const markup = renderToStaticMarkup(AnnounceNoteButton({ id: 'note-1', linked: true }))
|
||||
expect(markup).toContain('<form')
|
||||
expect(markup).toContain('Announce')
|
||||
expect(markup).not.toContain('disabled')
|
||||
})
|
||||
|
||||
it('renders a disabled button with the enable-card explanation when NATS is not linked', () => {
|
||||
const markup = renderToStaticMarkup(AnnounceNoteButton({ id: 'note-1', linked: false }))
|
||||
expect(markup).not.toContain('<form')
|
||||
expect(markup).toContain('disabled')
|
||||
expect(markup).toContain('Link NATS to enable this.')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,23 @@
|
||||
import { announceFromForm } from '@/features/publish-note-event/api/actions'
|
||||
|
||||
/** "Announce" on one of the visitor's notes: publishes its `note.created` event again. */
|
||||
export function AnnounceNoteButton({ id }: { id: string }) {
|
||||
/**
|
||||
* "Announce" on one of the visitor's notes: publishes its `note.created` event again.
|
||||
* `linked` is the NATS module's own linked state (plan D13): the action is a no-op
|
||||
* without NATS, so the button must not invite a click that does nothing — it renders
|
||||
* disabled with the same "Link ... to enable this" explanation the enable-card gives
|
||||
* the NATS slot in the module panel, rather than quietly succeeding at nothing.
|
||||
*/
|
||||
export function AnnounceNoteButton({ id, linked }: { id: string; linked: boolean }) {
|
||||
if (!linked) {
|
||||
return (
|
||||
<p>
|
||||
<button type="button" disabled>
|
||||
Announce
|
||||
</button>{' '}
|
||||
Link NATS to enable this.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<form action={announceFromForm}>
|
||||
<input type="hidden" name="id" value={id} />
|
||||
|
||||
@@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({
|
||||
recordUsage: vi.fn(async () => ({ accepted: 1, duplicates: 0, rejected: [] })),
|
||||
consumeUploadQuota: vi.fn(async () => null as { allowed: boolean; remaining: number } | null),
|
||||
revalidatePath: vi.fn(),
|
||||
loggerInfo: vi.fn(),
|
||||
loggerRecordException: vi.fn(),
|
||||
note: undefined as { id: string; authorId: string } | undefined,
|
||||
inserted: [] as Record<string, unknown>[],
|
||||
}))
|
||||
@@ -32,6 +34,7 @@ vi.mock('@/shared/api', () => ({
|
||||
blob: () => ({ put: mocks.put }),
|
||||
recordUsage: mocks.recordUsage,
|
||||
serverErrors: () => ({ captureException: vi.fn() }),
|
||||
logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }),
|
||||
}))
|
||||
vi.mock('@/shared/config', () => ({
|
||||
getEnv: () => ({ CONUSAI_API_URL: 'https://console.example.com', CONUSAI_API_TOKEN: 'dt_fixture' }),
|
||||
@@ -56,13 +59,18 @@ describe('uploadFile', () => {
|
||||
mocks.consumeUploadQuota.mockResolvedValue(null)
|
||||
mocks.inserted.length = 0
|
||||
mocks.note = { id: NOTE, authorId: 'visitor-1' }
|
||||
mocks.loggerInfo.mockClear()
|
||||
mocks.loggerRecordException.mockClear()
|
||||
})
|
||||
|
||||
it('refuses a file over the size limit before any upload', async () => {
|
||||
it('refuses a file over the size limit before any upload, and logs the failure', async () => {
|
||||
const big = new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'big.png', { type: 'image/png' })
|
||||
await expect(uploadFile(NOTE, attach(big))).rejects.toThrow(/5 MiB/)
|
||||
expect(mocks.put).not.toHaveBeenCalled()
|
||||
expect(mocks.log).not.toHaveBeenCalled()
|
||||
expect(mocks.loggerInfo).not.toHaveBeenCalled()
|
||||
expect(mocks.loggerRecordException).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'file.uploaded', 'note.id': NOTE })
|
||||
})
|
||||
|
||||
it('refuses a disallowed type before any upload', async () => {
|
||||
@@ -71,12 +79,13 @@ describe('uploadFile', () => {
|
||||
expect(mocks.put).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a note the visitor did not write, without uploading', async () => {
|
||||
it('refuses a note the visitor did not write, without uploading or spending quota', async () => {
|
||||
mocks.note = { id: NOTE, authorId: 'visitor-2' }
|
||||
const png = new File(['png'], 'a.png', { type: 'image/png' })
|
||||
await expect(uploadFile(NOTE, attach(png))).rejects.toThrow(/not yours/)
|
||||
expect(mocks.put).not.toHaveBeenCalled()
|
||||
expect(mocks.inserted).toHaveLength(0)
|
||||
expect(mocks.consumeUploadQuota).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stores the bytes, records the attachment, and audits file.uploaded once', async () => {
|
||||
@@ -98,6 +107,13 @@ describe('uploadFile', () => {
|
||||
actor: { kind: 'human', id: 'visitor-1' },
|
||||
target: { id: '00000000-0000-4000-8000-0000000000ff' },
|
||||
})
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.loggerInfo).toHaveBeenCalledWith('file.uploaded', {
|
||||
'file.id': '00000000-0000-4000-8000-0000000000ff',
|
||||
'note.id': NOTE,
|
||||
outcome: 'ok',
|
||||
})
|
||||
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends exactly one usage event, keyed by the attachment, for one upload', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache'
|
||||
import { fileRepository, uploadLimits } from '@/entities/file'
|
||||
import { noteId, noteRepository } from '@/entities/note'
|
||||
import { getEnv } from '@/shared/config'
|
||||
import { audit, blob, db, recordUsage, serverErrors } from '@/shared/api'
|
||||
import { audit, blob, db, logger, recordUsage, serverErrors } from '@/shared/api'
|
||||
import { consumeUploadQuota } from '@/shared/lib/optional'
|
||||
import { ensureVisitor } from '@/shared/lib/visitor-session'
|
||||
|
||||
@@ -13,45 +13,52 @@ import { ensureVisitor } from '@/shared/lib/visitor-session'
|
||||
* upload, so a refused file never reaches Blob Storage.
|
||||
*/
|
||||
export async function uploadFile(note: string, form: FormData): Promise<void> {
|
||||
const key = noteId.parse(note)
|
||||
const upload = form.get('file')
|
||||
if (!(upload instanceof File)) throw new Error('choose a file to upload')
|
||||
const limits = uploadLimits.parse({
|
||||
name: upload.name,
|
||||
size: upload.size,
|
||||
contentType: upload.type,
|
||||
})
|
||||
try {
|
||||
const key = noteId.parse(note)
|
||||
const upload = form.get('file')
|
||||
if (!(upload instanceof File)) throw new Error('choose a file to upload')
|
||||
const limits = uploadLimits.parse({
|
||||
name: upload.name,
|
||||
size: upload.size,
|
||||
contentType: upload.type,
|
||||
})
|
||||
|
||||
const actor = await ensureVisitor()
|
||||
// With KV linked, each visitor has a per-minute upload allowance; without it, none.
|
||||
const quota = await consumeUploadQuota(actor)
|
||||
if (quota && !quota.allowed) throw new Error('the upload limit for this minute is reached')
|
||||
const owner = await noteRepository(db()).get(key)
|
||||
if (!owner || owner.authorId !== actor) throw new Error('that note is not yours to attach to')
|
||||
const actor = await ensureVisitor()
|
||||
const owner = await noteRepository(db()).get(key)
|
||||
if (!owner || owner.authorId !== actor) throw new Error('that note is not yours to attach to')
|
||||
// With KV linked, each visitor has a per-minute upload allowance; without it, none.
|
||||
// Checked after ownership: an attempt on someone else's note spends no quota.
|
||||
const quota = await consumeUploadQuota(actor)
|
||||
if (quota && !quota.allowed) throw new Error('the upload limit for this minute is reached')
|
||||
|
||||
const stored = await blob().put(`notes/${key}/${limits.name}`, Buffer.from(await upload.arrayBuffer()), {
|
||||
contentType: limits.contentType,
|
||||
})
|
||||
const row = await fileRepository(db()).create({
|
||||
noteId: key,
|
||||
authorId: actor,
|
||||
name: limits.name,
|
||||
contentType: limits.contentType,
|
||||
size: limits.size,
|
||||
url: stored.url,
|
||||
})
|
||||
await audit().log('file.uploaded', { actor: { kind: 'human', id: actor }, target: { id: row.id } })
|
||||
// One upload, one usage event. The key is the attachment's id, so a resend stores nothing twice.
|
||||
// A failed send is reported, not raised: the upload itself succeeded.
|
||||
const env = getEnv()
|
||||
await recordUsage({ baseUrl: env.CONUSAI_API_URL, token: env.CONUSAI_API_TOKEN }, [
|
||||
{
|
||||
idempotency_key: `upload:${row.id}`,
|
||||
customer: { external_id: 'showcase' },
|
||||
meter: 'uploads',
|
||||
quantity: '1',
|
||||
properties: { bytes: limits.size, content_type: limits.contentType },
|
||||
},
|
||||
]).catch((error: unknown) => serverErrors().captureException(error))
|
||||
revalidatePath('/')
|
||||
const stored = await blob().put(`notes/${key}/${limits.name}`, Buffer.from(await upload.arrayBuffer()), {
|
||||
contentType: limits.contentType,
|
||||
})
|
||||
const row = await fileRepository(db()).create({
|
||||
noteId: key,
|
||||
authorId: actor,
|
||||
name: limits.name,
|
||||
contentType: limits.contentType,
|
||||
size: limits.size,
|
||||
url: stored.url,
|
||||
})
|
||||
await audit().log('file.uploaded', { actor: { kind: 'human', id: actor }, target: { id: row.id } })
|
||||
logger()?.info('file.uploaded', { 'file.id': row.id, 'note.id': key, outcome: 'ok' })
|
||||
// One upload, one usage event. The key is the attachment's id, so a resend stores nothing twice.
|
||||
// A failed send is reported, not raised: the upload itself succeeded.
|
||||
const env = getEnv()
|
||||
await recordUsage({ baseUrl: env.CONUSAI_API_URL, token: env.CONUSAI_API_TOKEN }, [
|
||||
{
|
||||
idempotency_key: `upload:${row.id}`,
|
||||
customer: { external_id: 'showcase' },
|
||||
meter: 'uploads',
|
||||
quantity: '1',
|
||||
properties: { bytes: limits.size, content_type: limits.contentType },
|
||||
},
|
||||
]).catch((error: unknown) => serverErrors().captureException(error))
|
||||
revalidatePath('/')
|
||||
} catch (error) {
|
||||
logger()?.recordException(error, { 'conusai.action': 'file.uploaded', 'note.id': note })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export const events = defineAuditEvents({
|
||||
'note.announced': { target: 'note' },
|
||||
'file.uploaded': { target: 'file' },
|
||||
'file.deleted': { target: 'file' },
|
||||
'notes.exported': { target: 'export' },
|
||||
})
|
||||
|
||||
export type AppAuditClient = AuditClient<typeof events>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The NATS client module is loaded fresh per test: its memoized connection is
|
||||
// module state. `connect` is scripted per test.
|
||||
const mocks = vi.hoisted(() => ({ connect: vi.fn() }))
|
||||
|
||||
vi.mock('nats', () => ({ connect: mocks.connect }))
|
||||
vi.mock('@/shared/config/env', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/shared/config/env')>('@/shared/config/env')
|
||||
return { ...actual, getEnv: () => ({ NATS_URL: 'nats://nats.example.test:4222', NATS_TOKEN: undefined }) }
|
||||
})
|
||||
|
||||
async function freshNats() {
|
||||
vi.resetModules()
|
||||
const client = await import('@/shared/api/nats/server')
|
||||
return client.nats
|
||||
}
|
||||
|
||||
describe('nats', () => {
|
||||
beforeEach(() => mocks.connect.mockReset())
|
||||
|
||||
it('retries the connection after a failed connect instead of serving the rejection forever', async () => {
|
||||
mocks.connect.mockRejectedValueOnce(new Error('nats down')).mockResolvedValueOnce({ closed: false })
|
||||
const nats = await freshNats()
|
||||
|
||||
await expect(nats()).rejects.toThrow('nats down')
|
||||
// The rejection must not be memoized: the next call connects again.
|
||||
await expect(nats()).resolves.toEqual({ closed: false })
|
||||
expect(mocks.connect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('memoizes a successful connection', async () => {
|
||||
mocks.connect.mockResolvedValue({ closed: false })
|
||||
const nats = await freshNats()
|
||||
|
||||
await expect(nats()).resolves.toEqual({ closed: false })
|
||||
expect(nats()).toBe(nats())
|
||||
expect(mocks.connect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,14 @@ export function nats(): Promise<NatsConnection> | null {
|
||||
const env = getEnv()
|
||||
const servers = env.NATS_URL
|
||||
if (!modules(env).nats || !servers) return null
|
||||
if (!connection) connection = connect(env.NATS_TOKEN ? { servers, token: env.NATS_TOKEN } : { servers })
|
||||
if (!connection) {
|
||||
const pending = connect(env.NATS_TOKEN ? { servers, token: env.NATS_TOKEN } : { servers })
|
||||
connection = pending
|
||||
// A failed connect must not wedge the module: once it settles rejected, the
|
||||
// next call connects again. The caller still sees the original rejection.
|
||||
pending.catch(() => {
|
||||
if (connection === pending) connection = undefined
|
||||
})
|
||||
}
|
||||
return connection
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { serverErrors, withRequest } from './server'
|
||||
export { logger, serverErrors, withRequest } from './server'
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createServer, type IncomingMessage, type Server } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// logger() wraps observe-next's getLogger() (showcase plan D23): undefined until register()
|
||||
// starts the OTLP exporters, which only happens when the platform injects OTEL env.
|
||||
describe('logger', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('is undefined in local dev, without OTEL_EXPORTER_OTLP_ENDPOINT', async () => {
|
||||
vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', '')
|
||||
vi.resetModules()
|
||||
const { logger } = await import('@/shared/api/observe/server')
|
||||
expect(logger()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the logger of the OTLP exporters register() started, once OTEL_EXPORTER_OTLP_ENDPOINT is set', async () => {
|
||||
vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', 'https://app.example.com/api/otel')
|
||||
vi.stubEnv('CONUSAI_API_URL', 'https://app.example.com/api')
|
||||
vi.stubEnv('CONUSAI_API_TOKEN', 'dt_fixture')
|
||||
vi.resetModules()
|
||||
const { register } = await import('@conusai-sdk/observe-next')
|
||||
const { logger } = await import('@/shared/api/observe/server')
|
||||
|
||||
const started = register({ dsn: 'https://pub123@errors.example.com/42', handlers: false })
|
||||
|
||||
expect(logger()).toBeDefined()
|
||||
expect(logger()).toBe(started.otlp?.logger)
|
||||
await started.otlp?.shutdown(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Stage regression (deployment #41): register()'s gate and getLogger() both resolved fine, but
|
||||
// nothing a server action logged ever reached the platform's /otel/v1/logs route -- no real fetch,
|
||||
// no error, no retry, just silence. The two tests above would have stayed green through that
|
||||
// regression: they only check that an Otlp instance exists, never that it sends anything. This
|
||||
// test starts a real HTTP receiver (no mocked fetch, no faked timers) and proves a log a server
|
||||
// action calls logger().info() with actually leaves the process and arrives within the exporter's
|
||||
// 5 s batch interval, exactly as the "note.created, outcome: ok" log in create-note's action does.
|
||||
describe('logger network delivery (D23 regression: a started logger must actually send)', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it(
|
||||
'a log emitted through the logger register() started reaches a real receiver within 5 s',
|
||||
async () => {
|
||||
const posts: Array<{ authorization: string | undefined; body: string }> = []
|
||||
const server: Server = createServer((req: IncomingMessage, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', (chunk) => chunks.push(chunk))
|
||||
req.on('end', () => {
|
||||
posts.push({ authorization: req.headers.authorization, body: Buffer.concat(chunks).toString('utf8') })
|
||||
res.writeHead(200, { 'content-type': 'application/json' }).end('{}')
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const { port } = server.address() as { port: number }
|
||||
|
||||
try {
|
||||
vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', `http://127.0.0.1:${port}/otel`)
|
||||
vi.stubEnv('CONUSAI_API_URL', `http://127.0.0.1:${port}`)
|
||||
vi.stubEnv('CONUSAI_API_TOKEN', 'dt_fixture')
|
||||
vi.resetModules()
|
||||
const { register } = await import('@conusai-sdk/observe-next')
|
||||
const { logger } = await import('@/shared/api/observe/server')
|
||||
|
||||
const started = register({ dsn: 'https://pub123@errors.example.com/42', handlers: false })
|
||||
expect(started.otlp?.enabled).toBe(true)
|
||||
|
||||
logger()?.info('note.created', { 'note.id': 'note-e2e-1', outcome: 'ok' })
|
||||
|
||||
// No fake timers: BatchExporter's own real setInterval (default 5 s, exporter.ts) must fire.
|
||||
await vi.waitFor(() => expect(posts).toHaveLength(1), { timeout: 7000, interval: 100 })
|
||||
const [post] = posts
|
||||
if (!post) throw new Error('unreachable: waitFor already asserted posts.length === 1')
|
||||
expect(post.authorization).toBe('Bearer dt_fixture')
|
||||
const doc = JSON.parse(post.body)
|
||||
const [record] = doc.resourceLogs[0].scopeLogs[0].logRecords
|
||||
expect(record.body).toEqual({ stringValue: 'note.created' })
|
||||
expect(record.attributes).toEqual(
|
||||
expect.arrayContaining([{ key: 'outcome', value: { stringValue: 'ok' } }, { key: 'note.id', value: { stringValue: 'note-e2e-1' } }]),
|
||||
)
|
||||
|
||||
await started.otlp?.shutdown(1000)
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
},
|
||||
8000,
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'server-only'
|
||||
import { init, withRequest, type ErrorTrackingClient } from '@conusai-sdk/observe-node'
|
||||
import { getLogger, type OtlpLogger } from '@conusai-sdk/observe-next'
|
||||
import { getEnv } from '@/shared/config/env'
|
||||
|
||||
let client: ErrorTrackingClient | undefined
|
||||
@@ -17,3 +18,12 @@ export function serverErrors(): ErrorTrackingClient {
|
||||
}
|
||||
|
||||
export { withRequest }
|
||||
|
||||
/**
|
||||
* The structured OTLP logger `register()` started in instrumentation.ts (showcase plan D23) —
|
||||
* `undefined` in local dev, where the platform does not inject `OTEL_EXPORTER_OTLP_ENDPOINT`.
|
||||
* Every Server Action logs through this, so a call never throws on its own account.
|
||||
*/
|
||||
export function logger(): OtlpLogger | undefined {
|
||||
return getLogger()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/shared/config/env', () => ({
|
||||
getEnv: () => ({
|
||||
CONUSAI_API_URL: 'https://console.example.com',
|
||||
CONUSAI_API_TOKEN: 'dt_fixture',
|
||||
}),
|
||||
}))
|
||||
|
||||
// The platform answering 403 (a deployment token the catalog routes reject)
|
||||
// throws inside the SDK call when throwOnError is set.
|
||||
vi.mock('@conusai-sdk/api', () => ({
|
||||
getPlatformInfo: vi.fn(async () => {
|
||||
throw Object.assign(new Error('forbidden'), { status: 403 })
|
||||
}),
|
||||
getServiceType: vi.fn(async () => {
|
||||
throw Object.assign(new Error('forbidden'), { status: 403 })
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('platform reads when the platform refuses them', () => {
|
||||
it('serviceCatalogEntry returns null on a 403 instead of throwing during render', async () => {
|
||||
const { serviceCatalogEntry } = await import('@/shared/api/platform/server')
|
||||
await expect(serviceCatalogEntry('redis')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('platformInfo returns null on a 403 instead of throwing during render', async () => {
|
||||
const { platformInfo } = await import('@/shared/api/platform/server')
|
||||
await expect(platformInfo()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('logs the failure once per read, not once per request', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { serviceCatalogEntry } = await import('@/shared/api/platform/server')
|
||||
await serviceCatalogEntry('qdrant')
|
||||
await serviceCatalogEntry('qdrant')
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -7,19 +7,40 @@ function auth() {
|
||||
return { baseUrl: env.CONUSAI_API_URL, headers: { Authorization: `Bearer ${env.CONUSAI_API_TOKEN}` } }
|
||||
}
|
||||
|
||||
/** The platform's own description, read with this project's deployment token. */
|
||||
// Pages render on every request; a refused platform read is logged once per
|
||||
// read, not once per request.
|
||||
const warned = new Set<string>()
|
||||
function warnOnce(what: string, error: unknown) {
|
||||
if (warned.has(what)) return
|
||||
warned.add(what)
|
||||
console.warn(`showcase: ${what} unavailable, rendering without it:`, error)
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's own description, read with this project's deployment token.
|
||||
* Null when the platform refuses or fails the read — the page renders without it.
|
||||
*/
|
||||
export async function platformInfo() {
|
||||
const { data } = await getPlatformInfo({ ...auth(), throwOnError: true })
|
||||
if (!data) throw new Error('the platform returned no description')
|
||||
return data
|
||||
try {
|
||||
const { data } = await getPlatformInfo({ ...auth(), throwOnError: true })
|
||||
return data ?? null
|
||||
} catch (error) {
|
||||
warnOnce('platform description', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog entry of a service type: the text an enable card shows, read from the
|
||||
* platform rather than written here (plan D13).
|
||||
* platform rather than written here (plan D13). Null when the platform refuses or
|
||||
* fails the read — the card renders without the catalog details.
|
||||
*/
|
||||
export async function serviceCatalogEntry(serviceType: ServiceType) {
|
||||
const { data } = await getServiceType({ ...auth(), path: { service_type: serviceType }, throwOnError: true })
|
||||
if (!data) throw new Error(`the platform returned no catalog entry for ${serviceType}`)
|
||||
return data
|
||||
try {
|
||||
const { data } = await getServiceType({ ...auth(), path: { service_type: serviceType }, throwOnError: true })
|
||||
return data ?? null
|
||||
} catch (error) {
|
||||
warnOnce(`catalog entry for ${serviceType}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The platform description read failed (401/403/5xx from the platform): the
|
||||
// page must still render the linked-services list it owns.
|
||||
vi.mock('@/shared/api', () => ({
|
||||
platformInfo: vi.fn(async () => null),
|
||||
}))
|
||||
vi.mock('@/shared/config', () => ({
|
||||
getEnv: () => ({}),
|
||||
modules: () => ({ kv: true, nats: false, qdrant: false, git: false, s3: false }),
|
||||
}))
|
||||
|
||||
describe('PlatformPage', () => {
|
||||
it('renders the linked services and a placeholder when the description is unavailable', async () => {
|
||||
const { PlatformPage } = await import('@/views/platform/ui/PlatformPage')
|
||||
const markup = renderToStaticMarkup(await PlatformPage())
|
||||
expect(markup).toContain('Linked services')
|
||||
// Every module is listed, linked or not (React splits text nodes with
|
||||
// comments in static markup, so match the elements, not the joined text).
|
||||
expect(markup.match(/<li>/g)).toHaveLength(5)
|
||||
expect(markup).toContain('not linked')
|
||||
expect(markup).toContain('unavailable')
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,8 @@ import { getEnv, modules } from '@/shared/config'
|
||||
* The platform's own description, read through the typed API (plan §4.4), and the
|
||||
* optional services this deployment is linked to. The platform has no token-accessible
|
||||
* listing of a project's services (D8), so the linked list is read from the variables
|
||||
* the platform injected at deploy time.
|
||||
* the platform injected at deploy time. When the description read fails the page
|
||||
* renders the list it owns and a placeholder.
|
||||
*/
|
||||
export async function PlatformPage() {
|
||||
const info = await platformInfo()
|
||||
@@ -22,7 +23,11 @@ export async function PlatformPage() {
|
||||
))}
|
||||
</ul>
|
||||
<h2>Description</h2>
|
||||
<pre>{JSON.stringify(info, null, 2)}</pre>
|
||||
{info ? (
|
||||
<pre>{JSON.stringify(info, null, 2)}</pre>
|
||||
) : (
|
||||
<p>The platform description is unavailable.</p>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ export async function Attachments({ noteId }: { noteId: string }) {
|
||||
<ul>
|
||||
{attached.map((file) => (
|
||||
<li key={file.id}>
|
||||
<a href={file.url}>{file.name}</a> ({file.size} bytes)
|
||||
{/* Served by the app: the platform's blob URL needs the deployment token, which the browser does not have. */}
|
||||
<a href={`/files/${file.id}`}>{file.name}</a> ({file.size} bytes)
|
||||
<DeleteFileButton id={file.id} />
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { serviceCatalogEntry } from '@/shared/api'
|
||||
|
||||
vi.mock('@/shared/api', () => ({
|
||||
serviceCatalogEntry: vi.fn(async (service: string) => ({
|
||||
@@ -15,4 +16,12 @@ describe('EnableCard', () => {
|
||||
expect(markup).toContain('Link Catalog redis to enable this.')
|
||||
expect(markup).toContain('Counts uploads per visitor.')
|
||||
})
|
||||
|
||||
it('renders the card without catalog details when the platform read fails', async () => {
|
||||
vi.mocked(serviceCatalogEntry).mockResolvedValueOnce(null)
|
||||
const { EnableCard } = await import('@/widgets/enable-card/ui/EnableCard')
|
||||
const markup = renderToStaticMarkup(await EnableCard({ service: 'redis' }))
|
||||
expect(markup).toContain('Link redis to enable this.')
|
||||
expect(markup).not.toContain('Counts uploads per visitor.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,14 +3,15 @@ import { serviceCatalogEntry } from '@/shared/api'
|
||||
|
||||
/**
|
||||
* "Link <service> to enable": the text comes from the platform's service catalog
|
||||
* (plan D13), so the card never carries copy of its own.
|
||||
* (plan D13), so the card never carries copy of its own. When the platform read
|
||||
* fails the card still renders, without the catalog details.
|
||||
*/
|
||||
export async function EnableCard({ service }: { service: ServiceType }) {
|
||||
const entry = await serviceCatalogEntry(service)
|
||||
return (
|
||||
<aside>
|
||||
<p>Link {entry.display_name} to enable this.</p>
|
||||
<p>{entry.description}</p>
|
||||
<p>Link {entry?.display_name ?? service} to enable this.</p>
|
||||
{entry ? <p>{entry.description}</p> : null}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ function slotFor(name: ModuleName, linked: Record<ModuleName, boolean>) {
|
||||
|
||||
describe('panelSlots', () => {
|
||||
it.each([
|
||||
['kv', 'redis'],
|
||||
['kv', 'kv'],
|
||||
['nats', 'nats'],
|
||||
['qdrant', 'qdrant'],
|
||||
['git', 'git'],
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SearchNotes } from '@/features/search-notes'
|
||||
import { moduleView, type ModuleName } from '@/widgets/module-panel/model/view'
|
||||
|
||||
/** The service an optional module is linked through, as the platform's catalog names it. */
|
||||
export type ModuleService = 'redis' | 'nats' | 'qdrant' | 'git' | 's3'
|
||||
export type ModuleService = 'kv' | 'nats' | 'qdrant' | 'git' | 's3'
|
||||
|
||||
export type ModuleSlot = { name: ModuleName; service: ModuleService; content: ReactNode }
|
||||
|
||||
@@ -26,7 +26,7 @@ export function panelSlots({
|
||||
enable: (service: ModuleService) => ReactNode
|
||||
}): ModuleSlot[] {
|
||||
const features: Array<{ name: ModuleName; service: ModuleService; feature: ReactNode }> = [
|
||||
{ name: 'kv', service: 'redis', feature: <UploadsLeft /> },
|
||||
{ name: 'kv', service: 'kv', feature: <UploadsLeft /> },
|
||||
{ name: 'nats', service: 'nats', feature: <p>New notes are announced live on NATS.</p> },
|
||||
{ name: 'qdrant', service: 'qdrant', feature: <SearchNotes query={query} /> },
|
||||
{ name: 's3', service: 's3', feature: <ExportNotesButton /> },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AnnounceNoteButton } from '@/features/publish-note-event'
|
||||
import { DeleteNoteButton } from '@/features/delete-note'
|
||||
import { EditNoteForm } from '@/features/edit-note'
|
||||
import { db } from '@/shared/api'
|
||||
import { getEnv, modules } from '@/shared/config'
|
||||
import { currentVisitor } from '@/shared/lib/visitor-session'
|
||||
|
||||
/**
|
||||
@@ -15,6 +16,9 @@ export async function NoteList({ extra }: { extra?: (noteId: string) => ReactNod
|
||||
if (!visitor) return <p>No notes yet.</p>
|
||||
const notes = await noteRepository(db()).listByAuthor(visitor)
|
||||
if (notes.length === 0) return <p>No notes yet.</p>
|
||||
// The same linked state the module panel's `nats` slot reads (D13): the Announce
|
||||
// button must agree with the panel about whether NATS is on.
|
||||
const natsLinked = modules(getEnv()).nats
|
||||
return (
|
||||
<ul>
|
||||
{notes.map((note) => (
|
||||
@@ -22,7 +26,7 @@ export async function NoteList({ extra }: { extra?: (noteId: string) => ReactNod
|
||||
<h2>{note.title}</h2>
|
||||
<EditNoteForm note={note} />
|
||||
<DeleteNoteButton id={note.id} />
|
||||
<AnnounceNoteButton id={note.id} />
|
||||
<AnnounceNoteButton id={note.id} linked={natsLinked} />
|
||||
{extra?.(note.id)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// The deployer's default health probe is "/" (deploy_image.rs), which renders
|
||||
// this app's dashboard; the template must ship its real health route in
|
||||
// .conusai.yaml (schema: crates/conusai-repo-config, `health.path`).
|
||||
describe('.conusai.yaml', () => {
|
||||
const yaml = readFileSync(new URL('../.conusai.yaml', import.meta.url), 'utf8')
|
||||
|
||||
it('declares the health check path the app actually serves', () => {
|
||||
expect(yaml).toMatch(/^health:\n(\s{2}.*\n)*\s{2}path: \/api\/health\s*$/m)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Every showcase visit 404'd on both /favicon.ico and /favicon.svg (stage sweep,
|
||||
// 2026-10-11) because the template shipped no favicon at all. `app/favicon.ico` is
|
||||
// Next's own file convention for /favicon.ico; `public/favicon.svg` is a plain static
|
||||
// file for /favicon.svg, which Next's convention does not cover (it only serves
|
||||
// `icon.svg` at /icon.svg). The root layout's `metadata.icons` must name both, so the
|
||||
// rendered <link> tags match what is actually shipped.
|
||||
describe('favicon', () => {
|
||||
it('ships a valid favicon.ico for Next’s /favicon.ico convention', () => {
|
||||
const path = fileURLToPath(new URL('../app/favicon.ico', import.meta.url))
|
||||
expect(existsSync(path)).toBe(true)
|
||||
const bytes = readFileSync(path)
|
||||
// ICO header: reserved=0, type=1 (icon), at least one image.
|
||||
expect(bytes.readUInt16LE(0)).toBe(0)
|
||||
expect(bytes.readUInt16LE(2)).toBe(1)
|
||||
expect(bytes.readUInt16LE(4)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('ships public/favicon.svg, served verbatim at /favicon.svg', () => {
|
||||
const path = fileURLToPath(new URL('../public/favicon.svg', import.meta.url))
|
||||
expect(existsSync(path)).toBe(true)
|
||||
expect(readFileSync(path, 'utf8')).toContain('<svg')
|
||||
})
|
||||
|
||||
it('names both favicons in the root layout’s metadata', () => {
|
||||
const layout = readFileSync(
|
||||
new URL('../src/app/layouts/root.tsx', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
expect(layout).toMatch(/\/favicon\.svg/)
|
||||
expect(layout).toMatch(/\/favicon\.ico/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// The template renders with browser-default styling unless the root layout
|
||||
// imports the global stylesheet (seen on stage 2026-10-10: the deployed
|
||||
// dashboard answered 200 with zero CSS linked). The layout is the one module
|
||||
// every page passes through, so its import is the whole contract.
|
||||
describe('global stylesheet', () => {
|
||||
const layout = readFileSync(
|
||||
new URL('../src/app/layouts/root.tsx', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
it('is imported by the root layout', () => {
|
||||
expect(layout).toMatch(/import\s+['"]\.\.\/globals\.css['"]/)
|
||||
})
|
||||
|
||||
it('exists and carries the design tokens', () => {
|
||||
const css = readFileSync(new URL('../src/app/globals.css', import.meta.url), 'utf8')
|
||||
expect(css).toContain(':root')
|
||||
expect(css).toContain('--accent')
|
||||
expect(css).toContain('main')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// @conusai-sdk/observe's replay module is off unless modules.replay is `true` or a
|
||||
// ReplayOptions object (sdks/node/packages/observe/src/index.ts). The default mode,
|
||||
// 'onError', only uploads a replay when an error is captured, so a plain showcase visit
|
||||
// produced nothing on Analytics > Session Replays (showcase plan D22). The shipped
|
||||
// instrumentation-client.ts must enable replay in a mode that records from page start.
|
||||
//
|
||||
// mode: 'always' alone is not enough (showcase plan D24): replay is a 'consented' item
|
||||
// (core/consent.ts verdict()), and the default consent mode 'anonymous' never sends
|
||||
// consented items, so the recorder never starts regardless of mode. Verified live on
|
||||
// stage 2026-10-11: 'always' with no consent option produced zero /api/_conusai/envelope
|
||||
// requests in 40+ seconds of interaction. The shipped file must also opt out of consent.
|
||||
//
|
||||
// @conusai-sdk/observe enables only `errors` by default (sdks/node/packages/observe/src/index.ts):
|
||||
// every other module — analytics, vitals, traces, replay, logs — is off unless named truthy under
|
||||
// `modules`. The showcase exists to exercise every SDK module, so this test fails whenever the
|
||||
// shipped instrumentation-client.ts leaves one off or sets it to a bare `false` (showcase plan D25).
|
||||
describe('instrumentation-client.ts', () => {
|
||||
const source = readFileSync(new URL('../instrumentation-client.ts', import.meta.url), 'utf8')
|
||||
|
||||
// A tiny parser rather than one regex per module: a module switch can be `true` or an options
|
||||
// object (replay's `{ mode: 'always' }`), and a regex permissive enough for both shapes would
|
||||
// also match a module spelled out in a comment, which defeats the point of this test.
|
||||
function moduleSwitches(src: string): Record<string, unknown> {
|
||||
const match = src.match(/modules:\s*{([\s\S]*?)}\s*,?\s*}\)/)
|
||||
const body = match?.[1]
|
||||
if (!body) throw new Error('no modules: {...} block found in instrumentation-client.ts')
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const entry of body.matchAll(/(\w+):\s*(true|false|\{[^}]*\})/g)) {
|
||||
const name = entry[1]
|
||||
const value = entry[2]
|
||||
if (!name || !value) continue
|
||||
result[name] = value === 'true' ? true : value === 'false' ? false : value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
it.each(['analytics', 'vitals', 'traces', 'replay', 'logs'])('enables the %s module', (name) => {
|
||||
const switches = moduleSwitches(source)
|
||||
expect(switches, `modules.${name} is missing`).toHaveProperty(name)
|
||||
expect(switches[name], `modules.${name} must not be false`).not.toBe(false)
|
||||
})
|
||||
|
||||
it('enables replay in a mode that records every visit, not only on error', () => {
|
||||
expect(source).toMatch(/modules:\s*{[\s\S]*?replay:\s*{\s*mode:\s*['"]always['"]/)
|
||||
})
|
||||
|
||||
it('opts consent out, so replay (a consented item) is actually allowed to send', () => {
|
||||
expect(source).toMatch(/consent:\s*['"]opt-out['"]/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MAX_UPLOAD_BYTES } from '@/entities/file'
|
||||
import nextConfig from '../next.config'
|
||||
|
||||
// The upload form posts to a server action, whose body Next caps at 1 MB unless
|
||||
// configured (next.config `experimental.serverActions.bodySizeLimit`). The cap must
|
||||
// admit the largest attachment the app accepts, plus multipart overhead.
|
||||
function parseLimit(limit: string | number | undefined): number | undefined {
|
||||
if (typeof limit === 'number') return limit
|
||||
const match = /^(\d+)mb$/.exec(limit ?? '')
|
||||
return match ? Number(match[1]) * 1024 * 1024 : undefined
|
||||
}
|
||||
|
||||
describe('next.config server action body limit', () => {
|
||||
it('admits the largest attachment the app accepts', () => {
|
||||
const limit = parseLimit(nextConfig.experimental?.serverActions?.bodySizeLimit)
|
||||
expect(limit).toBeGreaterThanOrEqual(MAX_UPLOAD_BYTES)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Every @conusai-sdk/* package the template imports must be pinned in the
|
||||
// shipped package.json at the version the SDK source tree carries at the same
|
||||
// commit (showcase plan §2.7 "Pin versions", D19). An import the manifest does
|
||||
// not declare builds nowhere: `npm install` cannot know it and `next build`
|
||||
// fails with "Module not found" (KI-55; stage deployment #5, 2026-10-09).
|
||||
//
|
||||
// The pin comparison needs the monorepo: CONUSAI_REPO_ROOT when set
|
||||
// (test-template.sh exports it), else the nearest ancestor holding
|
||||
// sdks/node/packages. A standalone project created from the template has
|
||||
// neither — no source tree exists there to compare against, so only the
|
||||
// presence check runs.
|
||||
const templateRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const thisFile = fileURLToPath(import.meta.url)
|
||||
|
||||
const IMPORT_PATTERN = /@conusai-sdk\/[a-z0-9-]+/g
|
||||
const SOURCE_PATTERN = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/
|
||||
const SKIP_DIRS = new Set(['node_modules', '.next'])
|
||||
|
||||
function collectSdkImports(dir: string, found = new Set<string>()): Set<string> {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_DIRS.has(entry.name)) collectSdkImports(path, found)
|
||||
} else if (SOURCE_PATTERN.test(entry.name) && path !== thisFile) {
|
||||
for (const match of readFileSync(path, 'utf8').matchAll(IMPORT_PATTERN)) found.add(match[0])
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
const sdkImports = [...collectSdkImports(templateRoot)].sort()
|
||||
const manifest = JSON.parse(readFileSync(join(templateRoot, 'package.json'), 'utf8')) as {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}
|
||||
const declared: Record<string, string> = { ...manifest.dependencies, ...manifest.devDependencies }
|
||||
|
||||
function monorepoRoot(): string | undefined {
|
||||
const candidates = process.env.CONUSAI_REPO_ROOT ? [process.env.CONUSAI_REPO_ROOT] : []
|
||||
let dir = templateRoot
|
||||
for (let depth = 0; depth <= 4; depth += 1) {
|
||||
candidates.push(dir)
|
||||
dir = join(dir, '..')
|
||||
}
|
||||
return candidates.find((candidate) => existsSync(join(candidate, 'sdks/node/packages')))
|
||||
}
|
||||
|
||||
function sdkSourceVersions(root: string): Map<string, string> {
|
||||
const versions = new Map<string, string>()
|
||||
for (const group of ['sdks/node/packages', 'packages']) {
|
||||
for (const entry of readdirSync(join(root, group), { withFileTypes: true })) {
|
||||
const manifestPath = join(root, group, entry.name, 'package.json')
|
||||
if (!entry.isDirectory() || !existsSync(manifestPath)) continue
|
||||
const source = JSON.parse(readFileSync(manifestPath, 'utf8')) as { name?: string; version?: string }
|
||||
if (source.name?.startsWith('@conusai-sdk/') && source.version) versions.set(source.name, source.version)
|
||||
}
|
||||
}
|
||||
return versions
|
||||
}
|
||||
|
||||
// A dev vendor bundle rewrites every package's own version and the template's
|
||||
// @conusai-sdk/* pins with the same `-dev.<yyyymmdd>.<sha8>` suffix, inside the
|
||||
// bundle only (scripts/vendor/build-bundle.sh, scripts/vendor/rewrite-version.py;
|
||||
// showcase plan D29). test-template.sh now builds this file's package.json from
|
||||
// the bundle's own rewritten template archive, so a dev build's declared pin
|
||||
// carries that suffix; strip it before comparing against this commit's plain
|
||||
// SDK source version. A release build's pin carries no suffix, so the strip is
|
||||
// a no-op there.
|
||||
const DEV_SUFFIX = /[-.]dev\.\d{8}\.[0-9a-f]+$/
|
||||
|
||||
describe('the shipped package.json pins every imported @conusai-sdk package (D19)', () => {
|
||||
it('finds @conusai-sdk imports in the template (negative control)', () => {
|
||||
expect(sdkImports.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each(sdkImports)('declares %s', (name) => {
|
||||
expect(declared, `${name} is imported but missing from package.json`).toHaveProperty(name)
|
||||
})
|
||||
|
||||
it('pins each at the version the SDK source tree carries at this commit (plain, or dev-bundle-suffixed)', () => {
|
||||
const root = monorepoRoot()
|
||||
if (!root) return // standalone checkout: no SDK source tree to compare against
|
||||
const versions = sdkSourceVersions(root)
|
||||
for (const name of sdkImports) {
|
||||
expect(versions.has(name), `${name} has no source package under sdks/node/packages or packages`).toBe(true)
|
||||
const pinnedBase = (declared[name] ?? '').replace(DEV_SUFFIX, '')
|
||||
expect(pinnedBase, `${name} pin differs from the SDK source version at this commit`).toBe(versions.get(name))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// `next build` rewrites tsconfig.json when the values differ from what Next 16
|
||||
// mandates (jsx: react-jsx, .next/dev/types in include), dirtying the checkout on
|
||||
// every build. The shipped file must already hold them.
|
||||
const tsconfig = JSON.parse(readFileSync(new URL('../tsconfig.json', import.meta.url), 'utf8')) as {
|
||||
compilerOptions: { jsx?: string }
|
||||
include: string[]
|
||||
}
|
||||
|
||||
describe('tsconfig holds the values Next 16 mandates', () => {
|
||||
it('uses the React automatic runtime jsx setting', () => {
|
||||
expect(tsconfig.compilerOptions.jsx).toBe('react-jsx')
|
||||
})
|
||||
|
||||
it('includes the dev types directory', () => {
|
||||
expect(tsconfig.include).toContain('.next/dev/types/**/*.ts')
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -15,12 +15,12 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
+2
-2
@@ -8,10 +8,10 @@ export default defineConfig({
|
||||
{ find: /^server-only$/, replacement: fileURLToPath(new URL('./test/server-only.ts', import.meta.url)) },
|
||||
],
|
||||
},
|
||||
// Next's tsconfig sets jsx to preserve; the tests transform JSX themselves.
|
||||
// The tests transform JSX themselves (oxc automatic runtime).
|
||||
oxc: { jsx: { runtime: 'automatic' } },
|
||||
test: {
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
include: ['src/**/*.test.{ts,tsx}', 'test/*.test.ts'],
|
||||
environment: 'node',
|
||||
// The SDK's dist imports sibling modules without extensions, which plain Node ESM rejects.
|
||||
server: { deps: { inline: [/@conusai-sdk\//] } },
|
||||
|
||||
Reference in New Issue
Block a user