commit f23f4e535d6a194d866a802e92292109a8635a31
Author: ConusAI vendor v0.1.0-dev.20261027+9b1131fb
Date: Tue Oct 27 18:05:18 2026 +0000
ConusAI vendor v0.1.0-dev.20261027+9b1131fb
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9e7903e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.next/
+.env*
+!.env.example
+next-env.d.ts
+tsconfig.tsbuildinfo
+playwright-report/
+test-results/
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..521a9f7
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+legacy-peer-deps=true
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e2732df
--- /dev/null
+++ b/README.md
@@ -0,0 +1,89 @@
+# ConusAI Showcase
+
+Notes with attachments on Postgres and Blob Storage, audited, observed and
+metered: every ConusAI SDK in one Next.js app built with Feature-Sliced Design
+(showcase plan D11). The template is proven in this repository by
+`scripts/vendor/test-template.sh` at the same commit as the SDKs it uses (D18).
+
+## Pinned versions
+
+Pinned on 2026-10-06 with `npm view version` (showcase §2.7):
+
+| Package | Version |
+|---|---|
+| next | 16.3.8 |
+| react, react-dom | 19.3.0 |
+| typescript | 5.9.3 (5.x: the `@conusai-sdk/plugin` peer requires ^5) |
+| drizzle-orm, drizzle-kit | 0.45.3, 0.31.11 |
+| postgres | 3.4.9 |
+| zod | 4.6.5 |
+| server-only | 0.0.1 |
+| vitest | 5.0.3 |
+| 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 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`, `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).
+- `src/shared/api/*/server.ts`: server-only SDK clients (`server-only`).
+- `src/shared/lib/visitor.ts`: the signed `sc_vid` visitor cookie (D16).
+
+## What each slice demonstrates
+
+| Slice | Demonstrates |
+|---|---|
+| `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/`, 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. |
+| `features/rate-limit` | The uploads left this minute, read from KV, when KV is linked. |
+| `features/simulate-traffic` | A scripted visitor journey that produces observed page views, a started journey and an error. |
+| `widgets/note-list`, `widgets/attachments` | The visitor's notes and their attachments, with the controls each feature provides. |
+| `widgets/audit-timeline`, `widgets/usage-meter` | The platform's audit events for this project, and the visitor's metered uploads and bytes. |
+| `widgets/enable-card`, `widgets/module-panel` | One slot per optional module: its feature when linked, its enable card (text from the platform's catalog) when not (D13). |
+| `views/dashboard`, `views/audit`, `views/platform` | The composed pages; the platform page lists each optional service as linked or not. |
+
+## Optional modules
+
+The showcase reads the variables the platform injects for each linked service
+(D8, D13). A module without its variables shows its enable card instead of its
+feature. Its variables are `REDIS_URL` (KV), `NATS_URL` and `NATS_TOKEN`,
+`QDRANT_URL` and `QDRANT_API_KEY`, `GIT_URL`, and `AWS_ENDPOINT_URL`, `S3_BUCKET`,
+`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. The S3 names are the ones the platform
+injects for an S3 service.
+
+## Commands
+
+- `npm run typecheck`, `npm run steiger`, `npm run lint`, `npm test`, `npm run build`
+- `npm run e2e` runs the Playwright suite against a production build. It starts
+ `e2e/fake-platform.mjs` in place of the platform and withholds the optional
+ module variables, so every enable card shows. The suite covers creating a note
+ and its audit entry, an attachment moving the usage meter, the enable cards,
+ axe (no serious or critical issues on each page) and the Core Web Vitals
+ budgets (LCP at most 2.5 s, INP at most 200 ms) measured in Chromium.
+- `npm test` also runs `src/shared/lib/optional.live.test.ts` against live NATS,
+ Qdrant and S3 when `NATS_URL`, `QDRANT_URL` and `AWS_ENDPOINT_URL` are set; without
+ them it is skipped.
+- `bash scripts/vendor/test-template.sh` from the repository root runs all of them against the bundle;
+ `bash scripts/vendor/test-template.sh --e2e` adds the Playwright suite.
diff --git a/app/api/health/route.ts b/app/api/health/route.ts
new file mode 100644
index 0000000..86e7497
--- /dev/null
+++ b/app/api/health/route.ts
@@ -0,0 +1,17 @@
+import { sql } from 'drizzle-orm'
+import { db, serverErrors, withRequest } from '@/shared/api'
+
+// 200 when Postgres answers; the platform probes this path (health_check_path).
+export const dynamic = 'force-dynamic'
+
+export async function GET(request: Request) {
+ return withRequest(request, async () => {
+ try {
+ await db().execute(sql`select 1`)
+ return Response.json({ status: 'ok' })
+ } catch (error) {
+ serverErrors().captureException(error)
+ return Response.json({ status: 'unavailable' }, { status: 503 })
+ }
+ })
+}
diff --git a/app/audit/page.tsx b/app/audit/page.tsx
new file mode 100644
index 0000000..7425114
--- /dev/null
+++ b/app/audit/page.tsx
@@ -0,0 +1,3 @@
+export const dynamic = 'force-dynamic'
+
+export { AuditPage as default } from '@/views/audit'
diff --git a/app/favicon.ico b/app/favicon.ico
new file mode 100644
index 0000000..a8bf1bf
Binary files /dev/null and b/app/favicon.ico differ
diff --git a/app/files/[id]/route.ts b/app/files/[id]/route.ts
new file mode 100644
index 0000000..fe6b5a8
--- /dev/null
+++ b/app/files/[id]/route.ts
@@ -0,0 +1 @@
+export { GET } from '@/features/download-file'
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..b9c9eac
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1 @@
+export { RootLayout as default, metadata } from '@/app/layouts/root'
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..8568c97
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1 @@
+export { DashboardPage as default } from '@/views/dashboard'
diff --git a/app/platform/page.tsx b/app/platform/page.tsx
new file mode 100644
index 0000000..3fe1ef0
--- /dev/null
+++ b/app/platform/page.tsx
@@ -0,0 +1,3 @@
+export const dynamic = 'force-dynamic'
+
+export { PlatformPage as default } from '@/views/platform'
diff --git a/conusai.json b/conusai.json
new file mode 100644
index 0000000..b681710
--- /dev/null
+++ b/conusai.json
@@ -0,0 +1,6 @@
+{
+ "schema_version": 2,
+ "health_check": {
+ "path": "/api/health"
+ }
+}
diff --git a/drizzle.config.ts b/drizzle.config.ts
new file mode 100644
index 0000000..a4b6f1c
--- /dev/null
+++ b/drizzle.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'drizzle-kit'
+
+export default defineConfig({
+ dialect: 'postgresql',
+ schema: ['./src/entities/note/model/schema.ts', './src/entities/file/model/schema.ts'],
+ out: './drizzle',
+})
diff --git a/drizzle/0000_init.sql b/drizzle/0000_init.sql
new file mode 100644
index 0000000..e488f17
--- /dev/null
+++ b/drizzle/0000_init.sql
@@ -0,0 +1,8 @@
+CREATE TABLE "notes" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "title" text NOT NULL,
+ "body" text DEFAULT '' NOT NULL,
+ "author_id" text NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
diff --git a/drizzle/0001_files.sql b/drizzle/0001_files.sql
new file mode 100644
index 0000000..190a917
--- /dev/null
+++ b/drizzle/0001_files.sql
@@ -0,0 +1,13 @@
+CREATE TABLE "files" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "note_id" uuid NOT NULL,
+ "author_id" text NOT NULL,
+ "name" text NOT NULL,
+ "content_type" text NOT NULL,
+ "size" integer NOT NULL,
+ "url" text NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+
+--> statement-breakpoint
+ALTER TABLE "files" ADD CONSTRAINT "files_note_id_notes_id_fk" FOREIGN KEY ("note_id") REFERENCES "notes"("id") ON DELETE cascade ON UPDATE no action;
diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json
new file mode 100644
index 0000000..6b41d2f
--- /dev/null
+++ b/drizzle/meta/0000_snapshot.json
@@ -0,0 +1,72 @@
+{
+ "id": "cbd1580b-e469-4710-a530-13305ad7a627",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.notes": {
+ "name": "notes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json
new file mode 100644
index 0000000..b457b21
--- /dev/null
+++ b/drizzle/meta/0001_snapshot.json
@@ -0,0 +1,135 @@
+{
+ "id": "6c019c3b-9836-4d91-99a6-a1f20b9fe445",
+ "prevId": "cbd1580b-e469-4710-a530-13305ad7a627",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.notes": {
+ "name": "notes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.files": {
+ "name": "files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "note_id": {
+ "name": "note_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
new file mode 100644
index 0000000..e536d2e
--- /dev/null
+++ b/drizzle/meta/_journal.json
@@ -0,0 +1,20 @@
+{
+ "version": "7",
+ "dialect": "postgresql",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "7",
+ "when": 1791259122531,
+ "tag": "0000_init",
+ "breakpoints": true
+ },
+ {
+ "idx": 1,
+ "version": "7",
+ "when": 1791259684816,
+ "tag": "0001_files",
+ "breakpoints": true
+ }
+ ]
+}
\ No newline at end of file
diff --git a/e2e/observability.spec.ts b/e2e/observability.spec.ts
new file mode 100644
index 0000000..f7450b1
--- /dev/null
+++ b/e2e/observability.spec.ts
@@ -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 {
+ 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 {
+ 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)
+})
diff --git a/e2e/replay.spec.ts b/e2e/replay.spec.ts
new file mode 100644
index 0000000..74e8efc
--- /dev/null
+++ b/e2e/replay.spec.ts
@@ -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)
+})
diff --git a/e2e/showcase.spec.ts b/e2e/showcase.spec.ts
new file mode 100644
index 0000000..4d5c639
--- /dev/null
+++ b/e2e/showcase.spec.ts
@@ -0,0 +1,122 @@
+import AxeBuilder from '@axe-core/playwright'
+import { expect, test, type Page } from '@playwright/test'
+
+// The end-to-end run of the showcase (plan S4.8): a note, an attachment, the audit
+// entry and usage on the platform, the enable cards, accessibility and the Core Web
+// Vitals budgets. The platform is the fake in e2e/fake-platform.mjs.
+
+const platform = 'http://127.0.0.1:4010'
+
+type PlatformState = {
+ audit: Array<{ action: string; resource_id: string | null }>
+ usage: Array<{ idempotency_key: string; meter: string }>
+}
+
+async function platformState(): Promise {
+ const response = await fetch(`${platform}/__e2e/state`)
+ return (await response.json()) as PlatformState
+}
+
+function uniqueTitle(label: string): string {
+ return `${label} ${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
+}
+
+async function createNote(page: Page, title: string): Promise {
+ await page.goto('/')
+ await page.getByLabel('Title').fill(title)
+ await page.getByLabel('Body').fill('written by the end-to-end run')
+ await page.getByRole('button', { name: 'Create note' }).click()
+ await expect(page.getByRole('heading', { name: title })).toBeVisible()
+}
+
+test('a note is audited on the platform and listed on the audit page', async ({ page }) => {
+ const title = uniqueTitle('audited')
+ await createNote(page, title)
+
+ await expect
+ .poll(async () => (await platformState()).audit.some((event) => event.action === 'note.created'), { timeout: 15_000 })
+ .toBe(true)
+
+ await page.goto('/audit')
+ await expect(page.getByText('note.created').first()).toBeVisible()
+})
+
+test('an attachment moves the usage meter and is metered on the platform', async ({ page }) => {
+ const title = uniqueTitle('attached')
+ await createNote(page, title)
+
+ const meter = page.getByText(/uploads, \d+ bytes stored/)
+ await expect(meter).toHaveText(/^0 uploads, 0 bytes stored$/)
+ const usageBefore = (await platformState()).usage.length
+
+ const note = page.getByRole('listitem').filter({ hasText: title })
+ await note.getByLabel('Attachment').setInputFiles({
+ name: 'notes.txt',
+ mimeType: 'text/plain',
+ buffer: Buffer.from('sixteen bytes!!!'),
+ })
+ await note.getByRole('button', { name: 'Attach', exact: true }).click()
+
+ await expect(meter).toHaveText(/^1 uploads, 16 bytes stored$/)
+ 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 KV Store to enable this.')).toBeVisible()
+ await expect(page.getByText(/uploads left this minute/)).toHaveCount(0)
+})
+
+for (const path of ['/', '/audit', '/platform']) {
+ test(`axe finds no serious or critical issues on ${path}`, async ({ page }) => {
+ await page.goto(path)
+ const results = await new AxeBuilder({ page }).analyze()
+ const blocking = results.violations
+ .filter((violation) => violation.impact === 'serious' || violation.impact === 'critical')
+ .map((violation) => `${violation.id} (${violation.nodes.length} node(s))`)
+ expect(blocking).toEqual([])
+ })
+}
+
+test('the dashboard meets the Core Web Vitals budgets: LCP at most 2.5 s, INP at most 200 ms', async ({ page }) => {
+ await page.addInitScript(() => {
+ const vitals = window as unknown as { lcp: number; inp: number }
+ vitals.lcp = 0
+ vitals.inp = 0
+ new PerformanceObserver((list) => {
+ for (const entry of list.getEntries()) vitals.lcp = entry.startTime
+ }).observe({ type: 'largest-contentful-paint', buffered: true })
+ // Event Timing entries carry the interaction id and the duration INP is built from.
+ new PerformanceObserver((list) => {
+ for (const entry of list.getEntries() as Array) {
+ if ((entry.interactionId ?? 0) > 0) vitals.inp = Math.max(vitals.inp, entry.duration)
+ }
+ }).observe({ type: 'event', buffered: true, durationThreshold: 16 } as PerformanceObserverInit)
+ })
+
+ await page.goto('/', { waitUntil: 'load' })
+ await page.getByLabel('Title').fill(uniqueTitle('vitals'))
+ await page.getByLabel('Body').fill('an interaction for the input delay budget')
+ await page.getByRole('button', { name: 'Create note' }).click()
+ await expect(page.getByText('an interaction for the input delay budget')).toBeVisible()
+
+ const vitals = await page.evaluate(() => {
+ const measured = window as unknown as { lcp: number; inp: number }
+ return { lcp: measured.lcp, inp: measured.inp }
+ })
+ expect(vitals.lcp).toBeGreaterThan(0)
+ expect(vitals.lcp).toBeLessThanOrEqual(2500)
+ expect(vitals.inp).toBeLessThanOrEqual(200)
+})
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000..8fb73a6
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,18 @@
+import nextVitals from 'eslint-config-next/core-web-vitals'
+import nextTypescript from 'eslint-config-next/typescript'
+
+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
diff --git a/instrumentation-client.ts b/instrumentation-client.ts
new file mode 100644
index 0000000..38c76d8
--- /dev/null
+++ b/instrumentation-client.ts
@@ -0,0 +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({
+ consent: 'opt-out',
+ modules: {
+ analytics: true,
+ vitals: true,
+ traces: true,
+ replay: { mode: 'always' },
+ logs: true,
+ },
+})
diff --git a/instrumentation.ts b/instrumentation.ts
new file mode 100644
index 0000000..78c2f39
--- /dev/null
+++ b/instrumentation.ts
@@ -0,0 +1,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'
diff --git a/migrate.mjs b/migrate.mjs
new file mode 100644
index 0000000..5e0a29c
--- /dev/null
+++ b/migrate.mjs
@@ -0,0 +1,16 @@
+// Applies the SQL migrations in drizzle/ before the app starts (plan D15).
+// `npm start` runs it first, so a deploy never serves against an old schema.
+import { drizzle } from 'drizzle-orm/postgres-js'
+import { migrate } from 'drizzle-orm/postgres-js/migrator'
+import postgres from 'postgres'
+
+const url = process.env.POSTGRES_URL
+if (!url) {
+ console.error('POSTGRES_URL is required to apply migrations')
+ process.exit(1)
+}
+
+const client = postgres(url, { max: 1 })
+await migrate(drizzle(client), { migrationsFolder: 'drizzle' })
+await client.end()
+console.log('migrations applied')
diff --git a/next.config.ts b/next.config.ts
new file mode 100644
index 0000000..ca54436
--- /dev/null
+++ b/next.config.ts
@@ -0,0 +1,13 @@
+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
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..5af1a2d
--- /dev/null
+++ b/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "conusai-showcase",
+ "version": "0.1.0",
+ "private": true,
+ "description": "ConusAI Showcase: notes with attachments on Postgres and Blob Storage, every ConusAI SDK in one Next.js app.",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "node migrate.mjs && next start",
+ "typecheck": "tsc --noEmit",
+ "lint": "eslint .",
+ "steiger": "steiger src",
+ "test": "vitest run",
+ "e2e": "playwright test",
+ "migrate": "node migrate.mjs",
+ "db:generate": "drizzle-kit generate"
+ },
+ "dependencies": {
+ "@aws-sdk/client-s3": "3.1146.0",
+ "@conusai-sdk/api": "0.1.4-dev.20261027.g9b1131fb",
+ "@conusai-sdk/blob": "0.0.7-dev.20261027.g9b1131fb",
+ "@conusai-sdk/kv": "0.0.5-dev.20261027.g9b1131fb",
+ "@conusai-sdk/node-sdk": "0.2.6-dev.20261027.g9b1131fb",
+ "@conusai-sdk/observe": "0.1.0-dev.20261027.g9b1131fb",
+ "@conusai-sdk/observe-next": "0.1.0-dev.20261027.g9b1131fb",
+ "@conusai-sdk/observe-node": "0.1.1-dev.20261027.g9b1131fb",
+ "@conusai-sdk/observe-react": "0.1.0-dev.20261027.g9b1131fb",
+ "@conusai-sdk/usage": "0.1.1-dev.20261027.g9b1131fb",
+ "drizzle-orm": "0.45.3",
+ "nats": "2.29.3",
+ "next": "16.4.2",
+ "postgres": "3.4.9",
+ "react": "19.3.0",
+ "react-dom": "19.3.0",
+ "server-only": "0.0.1",
+ "zod": "4.6.5"
+ },
+ "devDependencies": {
+ "@axe-core/playwright": "4.13.0",
+ "@conusai-sdk/observe-testing": "0.1.0-dev.20261027.g9b1131fb",
+ "@eslint/eslintrc": "3.3.7",
+ "@feature-sliced/steiger-plugin": "0.8.0",
+ "@playwright/test": "1.63.0",
+ "@types/node": "26.6.4",
+ "@types/react": "19.3.0",
+ "@types/react-dom": "19.3.0",
+ "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",
+ "vite": "8.3.4",
+ "vitest": "5.0.3"
+ }
+}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..f39204b
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,60 @@
+import { defineConfig } from '@playwright/test'
+
+// The app runs against @conusai-kit/testing's fake platform. The optional
+// module variables are withheld, so the dashboard shows every enable card (plan D13).
+const platform = 'http://127.0.0.1:4010'
+const fakePlatformCommand = process.env.CONUSAI_FAKE_PLATFORM_COMMAND
+if (!fakePlatformCommand) {
+ throw new Error('CONUSAI_FAKE_PLATFORM_COMMAND must run @conusai-kit/testing/fake-platform')
+}
+// The variables of the optional modules, withheld from the app under test.
+const optional = new Set([
+ 'NATS_URL',
+ 'NATS_TOKEN',
+ 'QDRANT_URL',
+ 'QDRANT_API_KEY',
+ 'REDIS_URL',
+ 'GIT_URL',
+ 'AWS_ENDPOINT_URL',
+ 'AWS_ACCESS_KEY_ID',
+ 'AWS_SECRET_ACCESS_KEY',
+])
+const base = Object.fromEntries(
+ Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined && !optional.has(entry[0])),
+)
+
+export default defineConfig({
+ testDir: 'e2e',
+ workers: 1,
+ reporter: [['list']],
+ use: { baseURL: 'http://localhost:3000' },
+ webServer: [
+ {
+ command: fakePlatformCommand,
+ url: `${platform}/__e2e/health`,
+ reuseExistingServer: false,
+ },
+ {
+ command: 'npm run start',
+ url: 'http://localhost:3000/api/health',
+ reuseExistingServer: false,
+ timeout: 120_000,
+ env: {
+ ...base,
+ CONUSAI_API_URL: `${platform}/api`,
+ CONUSAI_API_TOKEN: 'e2e-deployment-token',
+ CONUSAI_PROJECT_ID: 'e2e',
+ CONUSAI_DSN: "http://e2e@127.0.0.1:4010/1",
+ CONUSAI_RELEASE: 'e2e',
+ USAGE_ORG_SLUG: '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',
+ },
+ },
+ ],
+})
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..34ac1a6
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,4 @@
+
+
+ C
+
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..93afbad
--- /dev/null
+++ b/src/app/globals.css
@@ -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;
+ }
+}
diff --git a/src/app/layouts/root.tsx b/src/app/layouts/root.tsx
new file mode 100644
index 0000000..11a52f0
--- /dev/null
+++ b/src/app/layouts/root.tsx
@@ -0,0 +1,31 @@
+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 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 }) {
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/src/app/observability/observe.tsx b/src/app/observability/observe.tsx
new file mode 100644
index 0000000..87ebf14
--- /dev/null
+++ b/src/app/observability/observe.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import type { ReactNode } from 'react'
+import { ErrorBoundary, ObserveProvider } from '@conusai-sdk/observe-react'
+
+/** The browser observability root: the SDK instance, and a boundary that reports what React catches. */
+export function Observe({ children }: { children: ReactNode }) {
+ return (
+
+ Something went wrong. The error has been reported.
}>{children}
+
+ )
+}
diff --git a/src/entities/file/api/repository.ts b/src/entities/file/api/repository.ts
new file mode 100644
index 0000000..7390165
--- /dev/null
+++ b/src/entities/file/api/repository.ts
@@ -0,0 +1,50 @@
+import { and, eq } from 'drizzle-orm'
+import type { drizzle } from 'drizzle-orm/postgres-js'
+import { files, type FileRow } from '@/entities/file/model/schema'
+
+/** The handle the shared `db()` returns (`@/shared/api`). */
+type Db = ReturnType
+
+export type NewFile = Omit
+
+/**
+ * Attachment persistence. Writes name their author, as the notes repository does:
+ * a visitor removes only the files they attached.
+ */
+export function fileRepository(db: Db) {
+ return {
+ async create(input: NewFile): Promise {
+ const [row] = await db.insert(files).values(input).returning()
+ if (!row) throw new Error('the insert returned no row')
+ return row
+ },
+
+ async listByNote(noteId: string): Promise {
+ 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 {
+ 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 {
+ return db.select().from(files).where(eq(files.authorId, authorId))
+ },
+
+ /** The removed file, or `undefined` when `authorId` did not attach it. */
+ async remove(id: string, authorId: string): Promise {
+ const [row] = await db
+ .delete(files)
+ .where(and(eq(files.id, id), eq(files.authorId, authorId)))
+ .returning()
+ return row
+ },
+ }
+}
diff --git a/src/entities/file/index.ts b/src/entities/file/index.ts
new file mode 100644
index 0000000..e30e753
--- /dev/null
+++ b/src/entities/file/index.ts
@@ -0,0 +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'
diff --git a/src/entities/file/model/id.ts b/src/entities/file/model/id.ts
new file mode 100644
index 0000000..0d79c01
--- /dev/null
+++ b/src/entities/file/model/id.ts
@@ -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()
diff --git a/src/entities/file/model/limits.test.ts b/src/entities/file/model/limits.test.ts
new file mode 100644
index 0000000..a02d99d
--- /dev/null
+++ b/src/entities/file/model/limits.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import { MAX_UPLOAD_BYTES, uploadLimits } from '@/entities/file'
+
+describe('uploadLimits', () => {
+ const ok = { name: 'plan.pdf', size: 1024, contentType: 'application/pdf' }
+
+ it('accepts an allowed type within the size limit', () => {
+ expect(uploadLimits.parse(ok)).toEqual(ok)
+ })
+
+ it('refuses a file over the size limit', () => {
+ expect(() => uploadLimits.parse({ ...ok, size: MAX_UPLOAD_BYTES + 1 })).toThrow(/5 MiB/)
+ })
+
+ it('refuses an empty file', () => {
+ expect(() => uploadLimits.parse({ ...ok, size: 0 })).toThrow(/empty/)
+ })
+
+ it('refuses a content type that is not on the list', () => {
+ expect(() => uploadLimits.parse({ ...ok, contentType: 'application/x-msdownload' })).toThrow(/not accepted/)
+ })
+})
diff --git a/src/entities/file/model/limits.ts b/src/entities/file/model/limits.ts
new file mode 100644
index 0000000..801a9d5
--- /dev/null
+++ b/src/entities/file/model/limits.ts
@@ -0,0 +1,22 @@
+import { z } from 'zod'
+
+/** The largest attachment accepted, in bytes (5 MiB). */
+export const MAX_UPLOAD_BYTES = 5 * 1024 * 1024
+
+/** The content types an attachment may have. Anything else is refused before upload. */
+export const ALLOWED_CONTENT_TYPES = [
+ 'image/png',
+ 'image/jpeg',
+ 'image/gif',
+ 'application/pdf',
+ 'text/plain',
+ 'text/markdown',
+] as const
+
+export const uploadLimits = z.object({
+ name: z.string().trim().min(1, 'A file needs a name').max(255),
+ size: z.number().int().min(1, 'The file is empty').max(MAX_UPLOAD_BYTES, 'The file is larger than 5 MiB'),
+ contentType: z.enum(ALLOWED_CONTENT_TYPES, { error: 'That file type is not accepted' }),
+})
+
+export type UploadCandidate = z.infer
diff --git a/src/entities/file/model/schema.ts b/src/entities/file/model/schema.ts
new file mode 100644
index 0000000..3b94caa
--- /dev/null
+++ b/src/entities/file/model/schema.ts
@@ -0,0 +1,19 @@
+import { integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
+
+/**
+ * An attachment on a note, stored in Blob Storage. `note_id` is a foreign key to
+ * notes(id) with ON DELETE CASCADE, added by the migration: an entity cannot
+ * import another entity's table (FSD), so the schema here carries the column only.
+ */
+export const files = pgTable('files', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ noteId: uuid('note_id').notNull(),
+ authorId: text('author_id').notNull(),
+ name: text('name').notNull(),
+ contentType: text('content_type').notNull(),
+ size: integer('size').notNull(),
+ url: text('url').notNull(),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+})
+
+export type FileRow = typeof files.$inferSelect
diff --git a/src/entities/note/api/repository.test.ts b/src/entities/note/api/repository.test.ts
new file mode 100644
index 0000000..09d4e23
--- /dev/null
+++ b/src/entities/note/api/repository.test.ts
@@ -0,0 +1,68 @@
+import { randomUUID } from 'node:crypto'
+import { drizzle } from 'drizzle-orm/postgres-js'
+import { migrate } from 'drizzle-orm/postgres-js/migrator'
+import postgres from 'postgres'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { noteRepository, type NoteDb } from '@/entities/note'
+
+// Runs against a real Postgres: POSTGRES_URL must name one. Each run migrates a
+// schema of its own and drops it afterwards, so runs never share rows.
+const url = process.env.POSTGRES_URL
+const schema = `note_test_${randomUUID().replaceAll('-', '')}`
+let admin: postgres.Sql
+let client: postgres.Sql
+let repo: ReturnType
+
+const ALICE = 'visitor-alice'
+const BOB = 'visitor-bob'
+
+beforeAll(async () => {
+ expect(url, 'POSTGRES_URL must name a test Postgres').toBeTruthy()
+ admin = postgres(url as string, { max: 1 })
+ await admin.unsafe(`create schema "${schema}"`)
+ client = postgres(url as string, { max: 1, connection: { search_path: schema } })
+ const db = drizzle(client) as NoteDb
+ await migrate(db, { migrationsFolder: 'drizzle', migrationsSchema: schema })
+ repo = noteRepository(db)
+})
+
+afterAll(async () => {
+ await client?.end()
+ await admin?.unsafe(`drop schema if exists "${schema}" cascade`)
+ await admin?.end()
+})
+
+describe('noteRepository', () => {
+ it('creates a note and reads it back with its author', async () => {
+ const created = await repo.create({ title: 'Plan', body: 'draft', authorId: ALICE })
+ const read = await repo.get(created.id)
+ expect(read).toMatchObject({ title: 'Plan', body: 'draft', authorId: ALICE })
+ })
+
+ it('lists only the notes of the asked-for author', async () => {
+ await repo.create({ title: 'Bob note', body: '', authorId: BOB })
+ const aliceNotes = await repo.listByAuthor(ALICE)
+ expect(aliceNotes.length).toBeGreaterThan(0)
+ expect(aliceNotes.every((note) => note.authorId === ALICE)).toBe(true)
+ })
+
+ it('lets the author update their own note', async () => {
+ const created = await repo.create({ title: 'Old', body: '', authorId: ALICE })
+ const updated = await repo.update(created.id, ALICE, { title: 'New', body: 'edited' })
+ expect(updated).toMatchObject({ id: created.id, title: 'New', body: 'edited' })
+ })
+
+ it('does not let another visitor update the note', async () => {
+ const created = await repo.create({ title: 'Mine', body: '', authorId: ALICE })
+ const attempt = await repo.update(created.id, BOB, { title: 'Hijacked', body: '' })
+ expect(attempt).toBeUndefined()
+ expect((await repo.get(created.id))?.title).toBe('Mine')
+ })
+
+ it('removes only for the author', async () => {
+ const created = await repo.create({ title: 'Doomed', body: '', authorId: ALICE })
+ expect(await repo.remove(created.id, BOB)).toBe(false)
+ expect(await repo.remove(created.id, ALICE)).toBe(true)
+ expect(await repo.get(created.id)).toBeUndefined()
+ })
+})
diff --git a/src/entities/note/api/repository.ts b/src/entities/note/api/repository.ts
new file mode 100644
index 0000000..83202d8
--- /dev/null
+++ b/src/entities/note/api/repository.ts
@@ -0,0 +1,59 @@
+import { and, desc, eq } from 'drizzle-orm'
+import type { drizzle } from 'drizzle-orm/postgres-js'
+import { notes, type Note } from '@/entities/note/model/schema'
+import type { NoteInput } from '@/entities/note/model/input'
+
+/** The handle the shared `db()` returns (`@/shared/api/db/server`). */
+export type NoteDb = ReturnType
+
+/**
+ * Note persistence over one database handle. The handle is a parameter so a test
+ * can run the same code against its own Postgres.
+ *
+ * Every write names its author in the `where`: a visitor can change only the
+ * notes they wrote (tenancy, D16).
+ */
+export function noteRepository(db: NoteDb) {
+ return {
+ async create(input: NoteInput & { authorId: string }): Promise {
+ const [row] = await db.insert(notes).values(input).returning()
+ if (!row) throw new Error('the insert returned no row')
+ return row
+ },
+
+ async get(id: string): Promise {
+ const [row] = await db.select().from(notes).where(eq(notes.id, id)).limit(1)
+ return row
+ },
+
+ async listByAuthor(authorId: string, limit = 50): Promise {
+ return db
+ .select()
+ .from(notes)
+ .where(eq(notes.authorId, authorId))
+ .orderBy(desc(notes.createdAt))
+ .limit(limit)
+ },
+
+ /** The updated note, or `undefined` when `authorId` did not write it. */
+ async update(id: string, authorId: string, input: NoteInput): Promise {
+ const [row] = await db
+ .update(notes)
+ .set({ ...input, updatedAt: new Date() })
+ .where(and(eq(notes.id, id), eq(notes.authorId, authorId)))
+ .returning()
+ return row
+ },
+
+ /** `true` when the note existed and `authorId` wrote it. */
+ async remove(id: string, authorId: string): Promise {
+ const rows = await db
+ .delete(notes)
+ .where(and(eq(notes.id, id), eq(notes.authorId, authorId)))
+ .returning({ id: notes.id })
+ return rows.length > 0
+ },
+ }
+}
+
+export type NoteRepository = ReturnType
diff --git a/src/entities/note/index.ts b/src/entities/note/index.ts
new file mode 100644
index 0000000..22a86ba
--- /dev/null
+++ b/src/entities/note/index.ts
@@ -0,0 +1,3 @@
+export { notes, type Note } from './model/schema'
+export { noteInput, noteId, type NoteInput } from './model/input'
+export { noteRepository, type NoteDb, type NoteRepository } from './api/repository'
diff --git a/src/entities/note/model/input.test.ts b/src/entities/note/model/input.test.ts
new file mode 100644
index 0000000..004eec1
--- /dev/null
+++ b/src/entities/note/model/input.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+import { noteId, noteInput } from '@/entities/note'
+
+describe('noteInput', () => {
+ it('trims the title and defaults the body', () => {
+ expect(noteInput.parse({ title: ' Plan ' })).toEqual({ title: 'Plan', body: '' })
+ })
+
+ it('refuses a blank title', () => {
+ expect(() => noteInput.parse({ title: ' ' })).toThrow(/title/)
+ })
+
+ it('refuses a title past 200 characters', () => {
+ expect(() => noteInput.parse({ title: 'x'.repeat(201) })).toThrow()
+ })
+})
+
+describe('noteId', () => {
+ it('accepts a UUID and refuses anything else', () => {
+ expect(noteId.parse('00000000-0000-4000-8000-000000000001')).toBe('00000000-0000-4000-8000-000000000001')
+ expect(() => noteId.parse('not-a-uuid')).toThrow()
+ })
+})
diff --git a/src/entities/note/model/input.ts b/src/entities/note/model/input.ts
new file mode 100644
index 0000000..819888b
--- /dev/null
+++ b/src/entities/note/model/input.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod'
+
+/** The fields a visitor writes. Parsed at the boundary, before any write (plan §4.4). */
+export const noteInput = z.object({
+ title: z.string().trim().min(1, 'A note needs a title').max(200),
+ body: z.string().max(20000).default(''),
+})
+
+export type NoteInput = z.infer
+
+/** A note id as it arrives in a form or a URL: a UUID, nothing else. */
+export const noteId = z.uuid()
diff --git a/src/entities/note/model/schema.ts b/src/entities/note/model/schema.ts
new file mode 100644
index 0000000..76c6923
--- /dev/null
+++ b/src/entities/note/model/schema.ts
@@ -0,0 +1,13 @@
+import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'
+
+/** A note. `author_id` is the visitor's id from the signed `sc_vid` cookie (D16). */
+export const notes = pgTable('notes', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ title: text('title').notNull(),
+ body: text('body').notNull().default(''),
+ authorId: text('author_id').notNull(),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+})
+
+export type Note = typeof notes.$inferSelect
diff --git a/src/features/create-note/api/actions.test.ts b/src/features/create-note/api/actions.test.ts
new file mode 100644
index 0000000..d4f10c3
--- /dev/null
+++ b/src/features/create-note/api/actions.test.ts
@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// The action is the unit under test: its collaborators are replaced, so each
+// test asserts what the action wrote and audited, and nothing else.
+const mocks = vi.hoisted(() => {
+ const row = { id: '00000000-0000-4000-8000-000000000001', title: 'Plan', body: '', authorId: 'visitor-1' }
+ const values = vi.fn(() => ({ returning: async () => [row] }))
+ const insert = vi.fn(() => ({ values }))
+ return {
+ row,
+ values,
+ insert,
+ log: vi.fn(async () => {}),
+ publishNoteCreated: vi.fn(async () => {}),
+ indexNote: vi.fn(async () => {}),
+ revalidatePath: vi.fn(),
+ loggerInfo: vi.fn(),
+ loggerRecordException: vi.fn(),
+ }
+})
+
+vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath }))
+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,
+ indexNote: mocks.indexNote,
+}))
+vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => 'visitor-1' }))
+
+import { createNote } from '@/features/create-note/api/actions'
+
+function form(fields: Record): FormData {
+ const data = new FormData()
+ for (const [key, value] of Object.entries(fields)) data.set(key, value)
+ return data
+}
+
+describe('createNote', () => {
+ beforeEach(() => {
+ mocks.values.mockClear()
+ 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 () => {
+ await createNote(form({ title: 'Plan', body: '' }))
+
+ expect(mocks.values).toHaveBeenCalledWith({ title: 'Plan', body: '', authorId: 'visitor-1' })
+ expect(mocks.log).toHaveBeenCalledTimes(1)
+ expect(mocks.log).toHaveBeenCalledWith('note.created', {
+ actor: { kind: 'human', id: 'visitor-1' },
+ 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, 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 () => {
+ await createNote(form({ title: 'Plan', body: 'draft' }))
+ expect(mocks.publishNoteCreated).toHaveBeenCalledWith({ id: mocks.row.id, title: 'Plan' })
+ expect(mocks.indexNote).toHaveBeenCalledWith({ id: mocks.row.id, title: 'Plan', body: '' })
+ })
+})
diff --git a/src/features/create-note/api/actions.ts b/src/features/create-note/api/actions.ts
new file mode 100644
index 0000000..c308598
--- /dev/null
+++ b/src/features/create-note/api/actions.ts
@@ -0,0 +1,28 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { noteInput, noteRepository } from '@/entities/note'
+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 {
+ 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
+ }
+}
diff --git a/src/features/create-note/index.ts b/src/features/create-note/index.ts
new file mode 100644
index 0000000..c0f13fa
--- /dev/null
+++ b/src/features/create-note/index.ts
@@ -0,0 +1,2 @@
+export { createNote } from './api/actions'
+export { CreateNoteForm } from './ui/CreateNoteForm'
diff --git a/src/features/create-note/ui/CreateNoteForm.tsx b/src/features/create-note/ui/CreateNoteForm.tsx
new file mode 100644
index 0000000..31ebc21
--- /dev/null
+++ b/src/features/create-note/ui/CreateNoteForm.tsx
@@ -0,0 +1,17 @@
+import { createNote } from '@/features/create-note/api/actions'
+
+export function CreateNoteForm() {
+ return (
+
+ )
+}
diff --git a/src/features/delete-file/api/actions.test.ts b/src/features/delete-file/api/actions.test.ts
new file mode 100644
index 0000000..1abb398
--- /dev/null
+++ b/src/features/delete-file/api/actions.test.ts
@@ -0,0 +1,64 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const FILE = '00000000-0000-4000-8000-0000000000ff'
+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 }>,
+}))
+
+const fakeDb = {
+ delete: () => ({
+ where: () => ({
+ returning: async () => mocks.removed,
+ }),
+ }),
+}
+
+vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath }))
+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' }))
+
+import { deleteFile } from '@/features/delete-file/api/actions'
+
+describe('deleteFile', () => {
+ beforeEach(() => {
+ 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, and logs it', async () => {
+ mocks.removed.push({ id: FILE, url: 'https://blob.example.com/notes/n/a.png' })
+ await deleteFile(FILE)
+
+ expect(mocks.del).toHaveBeenCalledWith('https://blob.example.com/notes/n/a.png')
+ expect(mocks.log).toHaveBeenCalledTimes(1)
+ expect(mocks.log).toHaveBeenCalledWith('file.deleted', {
+ 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, 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()
+ })
+})
diff --git a/src/features/delete-file/api/actions.ts b/src/features/delete-file/api/actions.ts
new file mode 100644
index 0000000..74bb330
--- /dev/null
+++ b/src/features/delete-file/api/actions.ts
@@ -0,0 +1,27 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { fileRepository } from '@/entities/file'
+import { noteId } from '@/entities/note'
+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 {
+ 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
+ }
+}
diff --git a/src/features/delete-file/index.ts b/src/features/delete-file/index.ts
new file mode 100644
index 0000000..67f64d3
--- /dev/null
+++ b/src/features/delete-file/index.ts
@@ -0,0 +1,2 @@
+export { deleteFile } from './api/actions'
+export { DeleteFileButton } from './ui/DeleteFileButton'
diff --git a/src/features/delete-file/ui/DeleteFileButton.tsx b/src/features/delete-file/ui/DeleteFileButton.tsx
new file mode 100644
index 0000000..b0c1fd8
--- /dev/null
+++ b/src/features/delete-file/ui/DeleteFileButton.tsx
@@ -0,0 +1,9 @@
+import { deleteFile } from '@/features/delete-file/api/actions'
+
+export function DeleteFileButton({ id }: { id: string }) {
+ return (
+
+ Remove attachment
+
+ )
+}
diff --git a/src/features/delete-note/api/actions.test.ts b/src/features/delete-note/api/actions.test.ts
new file mode 100644
index 0000000..11add6b
--- /dev/null
+++ b/src/features/delete-note/api/actions.test.ts
@@ -0,0 +1,55 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+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 }>,
+}))
+
+const fakeDb = {
+ delete: () => ({ where: () => ({ returning: async () => mocks.removed }) }),
+}
+
+vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath }))
+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'
+
+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, and logs it', async () => {
+ mocks.removed.push({ id: NOTE })
+ await deleteNote(NOTE)
+
+ expect(mocks.log).toHaveBeenCalledTimes(1)
+ expect(mocks.log).toHaveBeenCalledWith('note.deleted', {
+ 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, 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()
+ })
+})
diff --git a/src/features/delete-note/api/actions.ts b/src/features/delete-note/api/actions.ts
new file mode 100644
index 0000000..26a612d
--- /dev/null
+++ b/src/features/delete-note/api/actions.ts
@@ -0,0 +1,25 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { noteId, noteRepository } from '@/entities/note'
+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 {
+ 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
+ }
+}
diff --git a/src/features/delete-note/index.ts b/src/features/delete-note/index.ts
new file mode 100644
index 0000000..f2c2620
--- /dev/null
+++ b/src/features/delete-note/index.ts
@@ -0,0 +1,2 @@
+export { deleteNote } from './api/actions'
+export { DeleteNoteButton } from './ui/DeleteNoteButton'
diff --git a/src/features/delete-note/ui/DeleteNoteButton.tsx b/src/features/delete-note/ui/DeleteNoteButton.tsx
new file mode 100644
index 0000000..831300f
--- /dev/null
+++ b/src/features/delete-note/ui/DeleteNoteButton.tsx
@@ -0,0 +1,9 @@
+import { deleteNote } from '@/features/delete-note/api/actions'
+
+export function DeleteNoteButton({ id }: { id: string }) {
+ return (
+
+ Delete
+
+ )
+}
diff --git a/src/features/download-file/api/route.test.ts b/src/features/download-file/api/route.test.ts
new file mode 100644
index 0000000..3638986
--- /dev/null
+++ b/src/features/download-file/api/route.test.ts
@@ -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)
+ })
+})
diff --git a/src/features/download-file/api/route.ts b/src/features/download-file/api/route.ts
new file mode 100644
index 0000000..569ecfb
--- /dev/null
+++ b/src/features/download-file/api/route.ts
@@ -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 {
+ 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/`: the route handler the app router re-exports. */
+export async function GET(_request: Request, context: { params: Promise<{ id: string }> }): Promise {
+ return downloadAttachment((await context.params).id)
+}
diff --git a/src/features/download-file/index.ts b/src/features/download-file/index.ts
new file mode 100644
index 0000000..3dbbebd
--- /dev/null
+++ b/src/features/download-file/index.ts
@@ -0,0 +1 @@
+export { GET } from './api/route'
diff --git a/src/features/edit-note/api/actions.test.ts b/src/features/edit-note/api/actions.test.ts
new file mode 100644
index 0000000..8e1521c
--- /dev/null
+++ b/src/features/edit-note/api/actions.test.ts
@@ -0,0 +1,74 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+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,
+}))
+
+const fakeDb = {
+ update: () => ({
+ set: () => ({
+ where: () => ({ returning: async () => (mocks.updated ? [mocks.updated] : []) }),
+ }),
+ }),
+}
+
+vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath }))
+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'
+
+function edit(title: string): FormData {
+ const data = new FormData()
+ data.set('title', title)
+ data.set('body', '')
+ return data
+}
+
+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, and logs the outcome', async () => {
+ mocks.updated = { id: NOTE }
+ await updateNote(NOTE, edit('Renamed'))
+
+ expect(mocks.log).toHaveBeenCalledTimes(1)
+ expect(mocks.log).toHaveBeenCalledWith('note.updated', {
+ 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, 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 })
+ })
+})
diff --git a/src/features/edit-note/api/actions.ts b/src/features/edit-note/api/actions.ts
new file mode 100644
index 0000000..a5e4653
--- /dev/null
+++ b/src/features/edit-note/api/actions.ts
@@ -0,0 +1,26 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { noteId, noteInput, noteRepository } from '@/entities/note'
+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 {
+ 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
+ }
+}
diff --git a/src/features/edit-note/index.ts b/src/features/edit-note/index.ts
new file mode 100644
index 0000000..cf85a2a
--- /dev/null
+++ b/src/features/edit-note/index.ts
@@ -0,0 +1,2 @@
+export { updateNote } from './api/actions'
+export { EditNoteForm } from './ui/EditNoteForm'
diff --git a/src/features/edit-note/ui/EditNoteForm.tsx b/src/features/edit-note/ui/EditNoteForm.tsx
new file mode 100644
index 0000000..48bdda0
--- /dev/null
+++ b/src/features/edit-note/ui/EditNoteForm.tsx
@@ -0,0 +1,18 @@
+import type { Note } from '@/entities/note'
+import { updateNote } from '@/features/edit-note/api/actions'
+
+export function EditNoteForm({ note }: { note: Note }) {
+ return (
+
+
+ Title
+
+
+
+ Body
+
+
+ Save
+
+ )
+}
diff --git a/src/features/export-notes/api/actions.test.ts b/src/features/export-notes/api/actions.test.ts
new file mode 100644
index 0000000..f1f5f25
--- /dev/null
+++ b/src/features/export-notes/api/actions.test.ts
@@ -0,0 +1,63 @@
+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' }))
+
+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, 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()
+ })
+})
diff --git a/src/features/export-notes/api/actions.ts b/src/features/export-notes/api/actions.ts
new file mode 100644
index 0000000..57c5e14
--- /dev/null
+++ b/src/features/export-notes/api/actions.ts
@@ -0,0 +1,33 @@
+'use server'
+
+import { noteRepository } from '@/entities/note'
+import { audit, db, logger, serverErrors } from '@/shared/api'
+import { exportToS3 } from '@/shared/lib/optional'
+import { ensureVisitor } from '@/shared/lib/visitor-session'
+
+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 {
+ const actor = await ensureVisitor()
+ const notes = await noteRepository(db()).listByAuthor(actor)
+ 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' }
+ }
+}
diff --git a/src/features/export-notes/index.ts b/src/features/export-notes/index.ts
new file mode 100644
index 0000000..80b3525
--- /dev/null
+++ b/src/features/export-notes/index.ts
@@ -0,0 +1,2 @@
+export { exportNotes } from './api/actions'
+export { ExportNotesButton } from './ui/ExportNotesButton'
diff --git a/src/features/export-notes/ui/ExportNotesButton.test.tsx b/src/features/export-notes/ui/ExportNotesButton.test.tsx
new file mode 100644
index 0000000..bb8bb96
--- /dev/null
+++ b/src/features/export-notes/ui/ExportNotesButton.test.tsx
@@ -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( )
+ expect(markup).toContain('(exportNotes, null)
+ return (
+
+
+ Export my notes to S3
+
+ {result?.ok && (
+
+ Exported {result.size} bytes to {result.key}.
+
+ )}
+ {result && !result.ok && {result.message}
}
+
+ )
+}
diff --git a/src/features/publish-note-event/api/actions.test.ts b/src/features/publish-note-event/api/actions.test.ts
new file mode 100644
index 0000000..7247556
--- /dev/null
+++ b/src/features/publish-note-event/api/actions.test.ts
@@ -0,0 +1,58 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const publishNoteCreated = vi.fn<(note: { id: string; title: string }) => Promise>(async () => undefined)
+const log = vi.fn<(...args: unknown[]) => Promise>(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 }),
+ logger: () => ({ info: loggerInfo, recordException: loggerRecordException }),
+}))
+
+const { announceNote } = await import('@/features/publish-note-event/api/actions')
+
+describe('announceNote', () => {
+ beforeEach(() => {
+ publishNoteCreated.mockClear()
+ log.mockClear()
+ get.mockReset()
+ loggerInfo.mockClear()
+ loggerRecordException.mockClear()
+ visitor = 'visitor-a'
+ })
+
+ it('publishes the author\'s note once and audits the announcement', async () => {
+ get.mockResolvedValue({ id: 'note-1', title: 'Budget', authorId: 'visitor-a' })
+ await announceNote('note-1')
+ 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, 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' })
+ })
+})
diff --git a/src/features/publish-note-event/api/actions.ts b/src/features/publish-note-event/api/actions.ts
new file mode 100644
index 0000000..e5647ac
--- /dev/null
+++ b/src/features/publish-note-event/api/actions.ts
@@ -0,0 +1,30 @@
+'use server'
+
+import { noteRepository } from '@/entities/note'
+import { audit, db, logger } from '@/shared/api'
+import { publishNoteCreated } from '@/shared/lib/optional'
+import { ensureVisitor } from '@/shared/lib/visitor-session'
+
+/**
+ * Announces one of the visitor's own notes on NATS again, as a `note.created`
+ * event (plan §4.4). Only the author may announce a note; anyone else gets a refusal
+ * and no event. A no-op when NATS is not linked.
+ */
+export async function announceNote(noteId: string): Promise {
+ 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. */
+export async function announceFromForm(form: FormData): Promise {
+ await announceNote(String(form.get('id') ?? ''))
+}
diff --git a/src/features/publish-note-event/index.ts b/src/features/publish-note-event/index.ts
new file mode 100644
index 0000000..c7535a1
--- /dev/null
+++ b/src/features/publish-note-event/index.ts
@@ -0,0 +1,2 @@
+export { AnnounceNoteButton } from './ui/AnnounceNoteButton'
+export { announceNote } from './api/actions'
diff --git a/src/features/publish-note-event/ui/AnnounceNoteButton.test.tsx b/src/features/publish-note-event/ui/AnnounceNoteButton.test.tsx
new file mode 100644
index 0000000..a9cd600
--- /dev/null
+++ b/src/features/publish-note-event/ui/AnnounceNoteButton.test.tsx
@@ -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(' {
+ const markup = renderToStaticMarkup(AnnounceNoteButton({ id: 'note-1', linked: false }))
+ expect(markup).not.toContain('
+
+ Announce
+ {' '}
+ Link NATS to enable this.
+
+ )
+ }
+ return (
+
+
+ Announce
+
+ )
+}
diff --git a/src/features/rate-limit/index.ts b/src/features/rate-limit/index.ts
new file mode 100644
index 0000000..48de324
--- /dev/null
+++ b/src/features/rate-limit/index.ts
@@ -0,0 +1 @@
+export { UploadsLeft } from './ui/UploadsLeft'
diff --git a/src/features/rate-limit/ui/UploadsLeft.test.tsx b/src/features/rate-limit/ui/UploadsLeft.test.tsx
new file mode 100644
index 0000000..4f727f4
--- /dev/null
+++ b/src/features/rate-limit/ui/UploadsLeft.test.tsx
@@ -0,0 +1,12 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/shared/lib/optional', () => ({ uploadsLeft: vi.fn(async () => 3) }))
+vi.mock('@/shared/lib/visitor-session', () => ({ currentVisitor: vi.fn(async () => 'visitor-a') }))
+
+describe('UploadsLeft', () => {
+ it('renders the uploads the visitor has left this minute', async () => {
+ const { UploadsLeft } = await import('@/features/rate-limit/ui/UploadsLeft')
+ expect(renderToStaticMarkup(await UploadsLeft())).toContain('3 uploads left this minute')
+ })
+})
diff --git a/src/features/rate-limit/ui/UploadsLeft.tsx b/src/features/rate-limit/ui/UploadsLeft.tsx
new file mode 100644
index 0000000..b11f821
--- /dev/null
+++ b/src/features/rate-limit/ui/UploadsLeft.tsx
@@ -0,0 +1,9 @@
+import { uploadsLeft } from '@/shared/lib/optional'
+import { currentVisitor } from '@/shared/lib/visitor-session'
+
+/** How many uploads the visitor has left this minute (KV module). */
+export async function UploadsLeft() {
+ const visitor = await currentVisitor()
+ const left = visitor ? await uploadsLeft(visitor) : null
+ return {left ?? 0} uploads left this minute
+}
diff --git a/src/features/search-notes/index.ts b/src/features/search-notes/index.ts
new file mode 100644
index 0000000..c5ded26
--- /dev/null
+++ b/src/features/search-notes/index.ts
@@ -0,0 +1 @@
+export { SearchNotes } from './ui/SearchNotes'
diff --git a/src/features/search-notes/ui/SearchNotes.tsx b/src/features/search-notes/ui/SearchNotes.tsx
new file mode 100644
index 0000000..3dd3ad0
--- /dev/null
+++ b/src/features/search-notes/ui/SearchNotes.tsx
@@ -0,0 +1,26 @@
+import { similarNotes } from '@/shared/lib/optional'
+
+/** Similar-notes search (Qdrant module): a GET form, so a search is a plain link. */
+export async function SearchNotes({ query }: { query: string | undefined }) {
+ const hits: Awaited> = query ? await similarNotes(query) : []
+ return (
+
+ )
+}
diff --git a/src/features/simulate-traffic/index.ts b/src/features/simulate-traffic/index.ts
new file mode 100644
index 0000000..0965a4a
--- /dev/null
+++ b/src/features/simulate-traffic/index.ts
@@ -0,0 +1,2 @@
+export { SimulateTrafficButton } from './ui/SimulateTrafficButton'
+export { runJourney } from './model/journey'
diff --git a/src/features/simulate-traffic/model/journey.test.ts b/src/features/simulate-traffic/model/journey.test.ts
new file mode 100644
index 0000000..dd25232
--- /dev/null
+++ b/src/features/simulate-traffic/model/journey.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+import { createTestObserve } from '@conusai-sdk/observe-testing'
+import { runJourney } from '@/features/simulate-traffic/model/journey'
+
+describe('runJourney', () => {
+ it('sends the page view, the custom event and the captured error', async () => {
+ const observe = createTestObserve({ modules: { analytics: true } })
+ runJourney(observe)
+ await observe.flush()
+
+ const kinds = observe.mock.sent.map(({ item }) => item.kind)
+ // Custom events travel under their app. name; a captured error is kind `error`.
+ expect(kinds).toContain('app.page_view')
+ expect(kinds).toContain('app.journey_started')
+ expect(kinds).toContain('error')
+ const error = observe.mock.sent.find(({ item }) => item.kind === 'error')?.item
+ expect(JSON.stringify(error)).toContain('simulated journey failure')
+ })
+})
diff --git a/src/features/simulate-traffic/model/journey.ts b/src/features/simulate-traffic/model/journey.ts
new file mode 100644
index 0000000..b65e4e4
--- /dev/null
+++ b/src/features/simulate-traffic/model/journey.ts
@@ -0,0 +1,8 @@
+import type { Observe } from '@conusai-sdk/observe'
+
+/** What one simulated journey reports: a page view, a custom event and a captured error. */
+export function runJourney(observe: Pick): void {
+ observe.track('page_view', { path: '/' })
+ observe.track('journey_started', { steps: 3 })
+ observe.captureException(new Error('simulated journey failure'), { tags: { journey: 'simulate-traffic' } })
+}
diff --git a/src/features/simulate-traffic/ui/SimulateTrafficButton.tsx b/src/features/simulate-traffic/ui/SimulateTrafficButton.tsx
new file mode 100644
index 0000000..c98fc28
--- /dev/null
+++ b/src/features/simulate-traffic/ui/SimulateTrafficButton.tsx
@@ -0,0 +1,14 @@
+'use client'
+
+import { useObserve } from '@conusai-sdk/observe-react'
+import { runJourney } from '@/features/simulate-traffic/model/journey'
+
+/** Runs one simulated journey, so a visitor sees the signals land in the console. */
+export function SimulateTrafficButton() {
+ const observe = useObserve()
+ return (
+ runJourney(observe)}>
+ Simulate a user journey
+
+ )
+}
diff --git a/src/features/upload-file/api/actions.test.ts b/src/features/upload-file/api/actions.test.ts
new file mode 100644
index 0000000..9536918
--- /dev/null
+++ b/src/features/upload-file/api/actions.test.ts
@@ -0,0 +1,140 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// The action runs against fakes: a Blob Storage client that records what it was
+// given, and a database whose note and attachment rows are set per test.
+const NOTE = '00000000-0000-4000-8000-0000000000aa'
+const mocks = vi.hoisted(() => ({
+ put: vi.fn(async (pathname: string) => ({ url: `https://blob.example.com/${pathname}` })),
+ log: vi.fn(async () => {}),
+ 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[],
+}))
+
+const fakeDb = {
+ select: () => ({ from: () => ({ where: () => ({ limit: async () => (mocks.note ? [mocks.note] : []) }) }) }),
+ insert: () => ({
+ values: (row: Record) => ({
+ returning: async () => {
+ mocks.inserted.push(row)
+ return [{ id: '00000000-0000-4000-8000-0000000000ff', ...row, createdAt: new Date() }]
+ },
+ }),
+ }),
+}
+
+vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath }))
+vi.mock('@/shared/api', () => ({
+ db: () => fakeDb,
+ audit: () => ({ log: mocks.log }),
+ 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' }),
+}))
+vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => 'visitor-1' }))
+vi.mock('@/shared/lib/optional', () => ({ consumeUploadQuota: mocks.consumeUploadQuota }))
+
+import { uploadFile } from '@/features/upload-file/api/actions'
+
+function attach(file: File): FormData {
+ const data = new FormData()
+ data.set('file', file)
+ return data
+}
+
+describe('uploadFile', () => {
+ beforeEach(() => {
+ mocks.put.mockClear()
+ mocks.log.mockClear()
+ mocks.revalidatePath.mockClear()
+ mocks.consumeUploadQuota.mockReset()
+ 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, 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 () => {
+ const exe = new File(['MZ'], 'run.exe', { type: 'application/x-msdownload' })
+ await expect(uploadFile(NOTE, attach(exe))).rejects.toThrow(/not accepted/)
+ expect(mocks.put).not.toHaveBeenCalled()
+ })
+
+ 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 () => {
+ const png = new File(['png'], 'a.png', { type: 'image/png' })
+ await uploadFile(NOTE, attach(png))
+
+ expect(mocks.put).toHaveBeenCalledTimes(1)
+ expect(mocks.put.mock.calls[0]?.[0]).toBe(`notes/${NOTE}/a.png`)
+ expect(mocks.inserted[0]).toMatchObject({
+ noteId: NOTE,
+ authorId: 'visitor-1',
+ name: 'a.png',
+ contentType: 'image/png',
+ size: 3,
+ url: `https://blob.example.com/notes/${NOTE}/a.png`,
+ })
+ expect(mocks.log).toHaveBeenCalledTimes(1)
+ expect(mocks.log).toHaveBeenCalledWith('file.uploaded', {
+ 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 () => {
+ const png = new File(['png'], 'a.png', { type: 'image/png' })
+ await uploadFile(NOTE, attach(png))
+
+ expect(mocks.recordUsage).toHaveBeenCalledTimes(1)
+ const [config, events] = mocks.recordUsage.mock.calls[0] as unknown as [
+ { baseUrl: string; token: string },
+ Array<{ idempotency_key: string; meter: string; quantity: string }>,
+ ]
+ expect(config).toEqual({ baseUrl: 'https://console.example.com', token: 'dt_fixture' })
+ expect(events).toHaveLength(1)
+ expect(events[0]).toMatchObject({ idempotency_key: 'upload:00000000-0000-4000-8000-0000000000ff', meter: 'uploads', quantity: '1' })
+ })
+
+ it('refuses an upload over the minute allowance before any write', async () => {
+ mocks.consumeUploadQuota.mockResolvedValue({ allowed: false, remaining: 0 })
+ const png = new File(['png'], 'a.png', { type: 'image/png' })
+ await expect(uploadFile(NOTE, attach(png))).rejects.toThrow(/limit/)
+ expect(mocks.put).not.toHaveBeenCalled()
+ expect(mocks.inserted).toHaveLength(0)
+ })
+})
diff --git a/src/features/upload-file/api/actions.ts b/src/features/upload-file/api/actions.ts
new file mode 100644
index 0000000..df16963
--- /dev/null
+++ b/src/features/upload-file/api/actions.ts
@@ -0,0 +1,68 @@
+'use server'
+
+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, logger, recordUsage, serverErrors } from '@/shared/api'
+import { consumeUploadQuota } from '@/shared/lib/optional'
+import { ensureVisitor } from '@/shared/lib/visitor-session'
+
+/**
+ * Attach a file to a note the visitor wrote. The limits are checked before any
+ * upload, so a refused file never reaches Blob Storage.
+ */
+export async function uploadFile(note: string, form: FormData): Promise {
+ 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()
+ 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 } })
+ 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()
+ if (env.USAGE_ORG_SLUG) {
+ await recordUsage({ baseUrl: env.CONUSAI_API_URL, token: env.CONUSAI_API_TOKEN, orgSlug: env.USAGE_ORG_SLUG }, [
+ {
+ idempotencyKey: `upload:${row.id}`,
+ customer: { externalId: 'showcase' },
+ meter: 'uploads',
+ quantity: 1,
+ properties: { bytes: limits.size, content_type: limits.contentType },
+ },
+ ]).catch((error: unknown) => serverErrors().captureException(error))
+ } else {
+ serverErrors().captureException(new Error('USAGE_ORG_SLUG is required to report usage'))
+ }
+ revalidatePath('/')
+ } catch (error) {
+ logger()?.recordException(error, { 'conusai.action': 'file.uploaded', 'note.id': note })
+ throw error
+ }
+}
diff --git a/src/features/upload-file/index.ts b/src/features/upload-file/index.ts
new file mode 100644
index 0000000..f08f27d
--- /dev/null
+++ b/src/features/upload-file/index.ts
@@ -0,0 +1,2 @@
+export { uploadFile } from './api/actions'
+export { UploadFileForm } from './ui/UploadFileForm'
diff --git a/src/features/upload-file/ui/UploadFileForm.tsx b/src/features/upload-file/ui/UploadFileForm.tsx
new file mode 100644
index 0000000..ac4f53b
--- /dev/null
+++ b/src/features/upload-file/ui/UploadFileForm.tsx
@@ -0,0 +1,13 @@
+import { uploadFile } from '@/features/upload-file/api/actions'
+
+export function UploadFileForm({ noteId }: { noteId: string }) {
+ return (
+
+
+ Attachment
+
+
+ Attach
+
+ )
+}
diff --git a/src/shared/api/audit/index.ts b/src/shared/api/audit/index.ts
new file mode 100644
index 0000000..3718365
--- /dev/null
+++ b/src/shared/api/audit/index.ts
@@ -0,0 +1 @@
+export * from './server'
diff --git a/src/shared/api/audit/server.ts b/src/shared/api/audit/server.ts
new file mode 100644
index 0000000..d83833b
--- /dev/null
+++ b/src/shared/api/audit/server.ts
@@ -0,0 +1,37 @@
+import 'server-only'
+import { ConusAIClient } from '@conusai-sdk/node-sdk'
+import { createAuditClient, defineAuditEvents, type AuditClient } from '@conusai-sdk/node-sdk/audit'
+import { getEnv } from '@/shared/config/env'
+
+/** The actions this app writes to the audit log (plan §4.4): one definition, typed `log()`. */
+export const events = defineAuditEvents({
+ 'note.created': { target: 'note' },
+ 'note.updated': { target: 'note' },
+ 'note.deleted': { target: 'note' },
+ 'note.announced': { target: 'note' },
+ 'file.uploaded': { target: 'file' },
+ 'file.deleted': { target: 'file' },
+ 'notes.exported': { target: 'export' },
+})
+
+export type AppAuditClient = AuditClient
+
+let client: AppAuditClient | undefined
+
+/** The audit client. It reads `CONUSAI_API_URL` and `CONUSAI_API_TOKEN` itself (D8). */
+export function audit(): AppAuditClient {
+ client ??= createAuditClient({ events })
+ return client
+}
+
+/**
+ * The project's own audit events, newest first, one page. The deployment token
+ * carries `audit:read` (showcase D9), so the platform answers for this project only.
+ */
+export async function readAuditEvents(options: { cursor?: string; limit?: number } = {}) {
+ const env = getEnv()
+ const platform = new ConusAIClient({ baseUrl: env.CONUSAI_API_URL, apiKey: env.CONUSAI_API_TOKEN })
+ const { data } = await platform.readEvents({ query: options, throwOnError: true })
+ if (!data) throw new Error('the platform returned no audit page')
+ return data
+}
diff --git a/src/shared/api/blob/index.ts b/src/shared/api/blob/index.ts
new file mode 100644
index 0000000..3718365
--- /dev/null
+++ b/src/shared/api/blob/index.ts
@@ -0,0 +1 @@
+export * from './server'
diff --git a/src/shared/api/blob/server.ts b/src/shared/api/blob/server.ts
new file mode 100644
index 0000000..0b3f2b6
--- /dev/null
+++ b/src/shared/api/blob/server.ts
@@ -0,0 +1,10 @@
+import 'server-only'
+import { createClient, type BlobClient } from '@conusai-sdk/blob'
+
+let client: BlobClient | undefined
+
+/** The Blob Storage client. It reads `CONUSAI_API_URL` and `CONUSAI_API_TOKEN` itself (D8). */
+export function blob(): BlobClient {
+ client ??= createClient()
+ return client
+}
diff --git a/src/shared/api/db/index.ts b/src/shared/api/db/index.ts
new file mode 100644
index 0000000..3718365
--- /dev/null
+++ b/src/shared/api/db/index.ts
@@ -0,0 +1 @@
+export * from './server'
diff --git a/src/shared/api/db/server.ts b/src/shared/api/db/server.ts
new file mode 100644
index 0000000..32cbddc
--- /dev/null
+++ b/src/shared/api/db/server.ts
@@ -0,0 +1,14 @@
+import 'server-only'
+import { drizzle } from 'drizzle-orm/postgres-js'
+import postgres from 'postgres'
+import { getEnv } from '@/shared/config/env'
+
+export type Db = ReturnType
+
+let connection: Db | undefined
+
+/** The Postgres connection every server module shares, opened on first use. */
+export function db(): Db {
+ connection ??= drizzle(postgres(getEnv().POSTGRES_URL))
+ return connection
+}
diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts
new file mode 100644
index 0000000..c66e2b7
--- /dev/null
+++ b/src/shared/api/index.ts
@@ -0,0 +1,12 @@
+// The public API of the shared server clients. Each one is `server-only`; a
+// client component that imports this file fails the build, which is the guard.
+export * from './audit'
+export * from './blob'
+export * from './db'
+export * from './kv'
+export * from './nats'
+export * from './observe'
+export * from './platform'
+export * from './qdrant'
+export * from './s3'
+export * from './usage'
diff --git a/src/shared/api/kv/index.ts b/src/shared/api/kv/index.ts
new file mode 100644
index 0000000..3718365
--- /dev/null
+++ b/src/shared/api/kv/index.ts
@@ -0,0 +1 @@
+export * from './server'
diff --git a/src/shared/api/kv/server.ts b/src/shared/api/kv/server.ts
new file mode 100644
index 0000000..929fa19
--- /dev/null
+++ b/src/shared/api/kv/server.ts
@@ -0,0 +1,15 @@
+import 'server-only'
+import { createClient, type KV } from '@conusai-sdk/kv'
+import { getEnv, modules } from '@/shared/config/env'
+
+let client: KV | undefined
+
+/**
+ * The KV client when the KV module is linked (D13: `REDIS_URL` present), else
+ * `null`. Callers render the enable card instead of calling a dead service.
+ */
+export function kv(): KV | null {
+ if (!modules(getEnv()).kv) return null
+ client ??= createClient()
+ return client
+}
diff --git a/src/shared/api/nats/index.ts b/src/shared/api/nats/index.ts
new file mode 100644
index 0000000..3718365
--- /dev/null
+++ b/src/shared/api/nats/index.ts
@@ -0,0 +1 @@
+export * from './server'
diff --git a/src/shared/api/nats/server.test.ts b/src/shared/api/nats/server.test.ts
new file mode 100644
index 0000000..e6aabf9
--- /dev/null
+++ b/src/shared/api/nats/server.test.ts
@@ -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('@/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)
+ })
+})
diff --git a/src/shared/api/nats/server.ts b/src/shared/api/nats/server.ts
new file mode 100644
index 0000000..dc58836
--- /dev/null
+++ b/src/shared/api/nats/server.ts
@@ -0,0 +1,22 @@
+import 'server-only'
+import { connect, type NatsConnection } from 'nats'
+import { getEnv, modules } from '@/shared/config/env'
+
+let connection: Promise | undefined
+
+/** The NATS connection, or `null` when the NATS module is not linked (D13). */
+export function nats(): Promise | null {
+ const env = getEnv()
+ const servers = env.NATS_URL
+ if (!modules(env).nats || !servers) return null
+ 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
+}
diff --git a/src/shared/api/observe/index.ts b/src/shared/api/observe/index.ts
new file mode 100644
index 0000000..60c1c95
--- /dev/null
+++ b/src/shared/api/observe/index.ts
@@ -0,0 +1 @@
+export { logger, serverErrors, withRequest } from './server'
diff --git a/src/shared/api/observe/server.test.ts b/src/shared/api/observe/server.test.ts
new file mode 100644
index 0000000..d0e864d
--- /dev/null
+++ b/src/shared/api/observe/server.test.ts
@@ -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((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,
+ )
+})
diff --git a/src/shared/api/observe/server.ts b/src/shared/api/observe/server.ts
new file mode 100644
index 0000000..5f4a55c
--- /dev/null
+++ b/src/shared/api/observe/server.ts
@@ -0,0 +1,29 @@
+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
+
+/**
+ * The server error client, bound to this environment's DSN and release (D8). Server
+ * errors leave with the request id of the request that raised them.
+ */
+export function serverErrors(): ErrorTrackingClient {
+ if (!client) {
+ const env = getEnv()
+ client = init({ dsn: env.CONUSAI_DSN, release: env.CONUSAI_RELEASE })
+ }
+ return client
+}
+
+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()
+}
diff --git a/src/shared/api/platform/index.ts b/src/shared/api/platform/index.ts
new file mode 100644
index 0000000..3718365
--- /dev/null
+++ b/src/shared/api/platform/index.ts
@@ -0,0 +1 @@
+export * from './server'
diff --git a/src/shared/api/platform/server.test.ts b/src/shared/api/platform/server.test.ts
new file mode 100644
index 0000000..8a746cc
--- /dev/null
+++ b/src/shared/api/platform/server.test.ts
@@ -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()
+ })
+})
diff --git a/src/shared/api/platform/server.ts b/src/shared/api/platform/server.ts
new file mode 100644
index 0000000..eac7c74
--- /dev/null
+++ b/src/shared/api/platform/server.ts
@@ -0,0 +1,46 @@
+import 'server-only'
+import { getPlatformInfo, getServiceType, type ServiceType } from '@conusai-sdk/api'
+import { getEnv } from '@/shared/config/env'
+
+function auth() {
+ const env = getEnv()
+ return { baseUrl: env.CONUSAI_API_URL, headers: { Authorization: `Bearer ${env.CONUSAI_API_TOKEN}` } }
+}
+
+// Pages render on every request; a refused platform read is logged once per
+// read, not once per request.
+const warned = new Set()
+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() {
+ 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). Null when the platform refuses or
+ * fails the read — the card renders without the catalog details.
+ */
+export async function serviceCatalogEntry(serviceType: ServiceType) {
+ 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
+ }
+}
diff --git a/src/shared/api/qdrant/index.ts b/src/shared/api/qdrant/index.ts
new file mode 100644
index 0000000..3718365
--- /dev/null
+++ b/src/shared/api/qdrant/index.ts
@@ -0,0 +1 @@
+export * from './server'
diff --git a/src/shared/api/qdrant/server.ts b/src/shared/api/qdrant/server.ts
new file mode 100644
index 0000000..06277b6
--- /dev/null
+++ b/src/shared/api/qdrant/server.ts
@@ -0,0 +1,47 @@
+import 'server-only'
+
+/** What the Qdrant REST API needs from this app: where it is and how to authenticate. */
+export type QdrantConfig = { url: string; apiKey?: string | undefined; collection: string }
+
+/** One note in the vector index: its id, its text's vector and its title. */
+export type QdrantPoint = { id: string; vector: number[]; payload: { title: string } }
+
+/** Creates the collection if it is missing, with cosine distance at the vector's size. */
+export async function ensureCollection(config: QdrantConfig, size: number, fetcher: typeof fetch = fetch): Promise {
+ const response = await fetcher(`${config.url}/collections/${config.collection}`, {
+ method: 'PUT',
+ headers: qdrantHeaders(config),
+ body: JSON.stringify({ vectors: { size, distance: 'Cosine' } }),
+ })
+ // 409 means the collection already exists: that is the state we want.
+ if (!response.ok && response.status !== 409) throw new Error(`qdrant collection answered ${response.status}`)
+}
+
+export async function upsertPoints(config: QdrantConfig, points: QdrantPoint[], fetcher: typeof fetch = fetch): Promise {
+ const response = await fetcher(`${config.url}/collections/${config.collection}/points?wait=true`, {
+ method: 'PUT',
+ headers: qdrantHeaders(config),
+ body: JSON.stringify({ points }),
+ })
+ if (!response.ok) throw new Error(`qdrant upsert answered ${response.status}`)
+}
+
+export async function searchPoints(
+ config: QdrantConfig,
+ vector: number[],
+ limit: number,
+ fetcher: typeof fetch = fetch,
+): Promise> {
+ const response = await fetcher(`${config.url}/collections/${config.collection}/points/search`, {
+ method: 'POST',
+ headers: qdrantHeaders(config),
+ body: JSON.stringify({ vector, limit, with_payload: true }),
+ })
+ if (!response.ok) throw new Error(`qdrant search answered ${response.status}`)
+ const body = (await response.json()) as { result: Array<{ id: string; score: number; payload: { title: string } }> }
+ return body.result
+}
+
+function qdrantHeaders(config: QdrantConfig): Record {
+ return config.apiKey ? { 'content-type': 'application/json', 'api-key': config.apiKey } : { 'content-type': 'application/json' }
+}
diff --git a/src/shared/api/s3/index.ts b/src/shared/api/s3/index.ts
new file mode 100644
index 0000000..3718365
--- /dev/null
+++ b/src/shared/api/s3/index.ts
@@ -0,0 +1 @@
+export * from './server'
diff --git a/src/shared/api/s3/server.ts b/src/shared/api/s3/server.ts
new file mode 100644
index 0000000..c069281
--- /dev/null
+++ b/src/shared/api/s3/server.ts
@@ -0,0 +1,23 @@
+import 'server-only'
+import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
+import { getEnv, modules } from '@/shared/config/env'
+
+let client: S3Client | undefined
+
+/** The dedicated S3 client, or `null` when the S3 module is not linked (D13). */
+export function s3(): { client: S3Client; bucket: string } | null {
+ const env = getEnv()
+ if (!modules(env).s3 || !env.S3_BUCKET || !env.AWS_ENDPOINT_URL) return null
+ const credentials =
+ env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY
+ ? { accessKeyId: env.AWS_ACCESS_KEY_ID, secretAccessKey: env.AWS_SECRET_ACCESS_KEY }
+ : undefined
+ client ??= new S3Client(
+ credentials
+ ? { endpoint: env.AWS_ENDPOINT_URL, forcePathStyle: true, region: 'us-east-1', credentials }
+ : { endpoint: env.AWS_ENDPOINT_URL, forcePathStyle: true, region: 'us-east-1' },
+ )
+ return { client, bucket: env.S3_BUCKET }
+}
+
+export { PutObjectCommand }
diff --git a/src/shared/api/usage/index.ts b/src/shared/api/usage/index.ts
new file mode 100644
index 0000000..4566631
--- /dev/null
+++ b/src/shared/api/usage/index.ts
@@ -0,0 +1 @@
+export { recordUsage, type UsageEvent } from './server'
diff --git a/src/shared/api/usage/server.test.ts b/src/shared/api/usage/server.test.ts
new file mode 100644
index 0000000..51b0b92
--- /dev/null
+++ b/src/shared/api/usage/server.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import { recordUsage, type UsageEvent } from '@/shared/api/usage/server'
+
+const config = { baseUrl: 'https://console.example.com', token: 'dt_fixture', orgSlug: 'showcase' }
+const event: UsageEvent = {
+ idempotencyKey: 'upload:f1',
+ customer: { externalId: 'showcase' },
+ meter: 'uploads',
+ quantity: 1,
+}
+
+describe('recordUsage', () => {
+ it('sends one batch through the usage SDK with the deployment credential', async () => {
+ const calls: { url: string; init: RequestInit }[] = []
+ const fetcher = (async (url: string, init: RequestInit) => {
+ calls.push({ url, init })
+ return new Response(JSON.stringify({ accepted: 1, duplicates: 0, rejected: [] }), { status: 202 })
+ }) as typeof fetch
+
+ await recordUsage(config, [event], fetcher)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]?.url).toBe('https://console.example.com/v1/orgs/showcase/billing/events')
+ expect(calls[0]?.init.method).toBe('POST')
+ expect(new Headers(calls[0]?.init.headers).get('authorization')).toBe(`Bearer ${config.token}`)
+ expect(JSON.parse(String(calls[0]?.init.body))).toEqual({
+ events: [
+ {
+ idempotency_key: 'upload:f1',
+ customer: { external_id: 'showcase' },
+ meter: 'uploads',
+ quantity: '1',
+ },
+ ],
+ })
+ })
+
+ it('surfaces a rejected usage request, so the caller can report the failed batch', async () => {
+ const fetcher = (async () => new Response('{}', { status: 403 })) as typeof fetch
+ await expect(recordUsage(config, [event], fetcher)).rejects.toThrow(/403/)
+ })
+})
diff --git a/src/shared/api/usage/server.ts b/src/shared/api/usage/server.ts
new file mode 100644
index 0000000..e66ae0b
--- /dev/null
+++ b/src/shared/api/usage/server.ts
@@ -0,0 +1,25 @@
+import 'server-only'
+import { UsageClient, type FetchLike, type UsageEvent } from '@conusai-sdk/usage'
+
+export type { UsageEvent } from '@conusai-sdk/usage'
+
+/**
+ * Sends usage events with the deployment token through the supported usage client.
+ * Closing the client flushes this batch before the request returns.
+ */
+export async function recordUsage(
+ config: { baseUrl: string; token: string; orgSlug: string },
+ events: UsageEvent[],
+ fetcher: FetchLike = fetch,
+): Promise {
+ const usage = new UsageClient({
+ baseUrl: config.baseUrl,
+ apiPrefix: '',
+ apiKey: config.token,
+ orgSlug: config.orgSlug,
+ flushIntervalMs: 0,
+ fetch: fetcher,
+ })
+ for (const event of events) usage.track(event)
+ await usage.close()
+}
diff --git a/src/shared/config/env.test.ts b/src/shared/config/env.test.ts
new file mode 100644
index 0000000..acfeffd
--- /dev/null
+++ b/src/shared/config/env.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest'
+import { modules, parseEnv } from '@/shared/config/env'
+
+const required = {
+ CONUSAI_API_URL: 'https://console.example.com',
+ CONUSAI_API_TOKEN: 'dt_fixture',
+ CONUSAI_PROJECT_ID: '42',
+ CONUSAI_DSN: 'https://key@errors.example.com/1',
+ CONUSAI_RELEASE: 'abc123',
+ POSTGRES_URL: 'postgres://app@db.example.com/app',
+ USAGE_ORG_SLUG: 'showcase',
+}
+
+describe('parseEnv', () => {
+ it('accepts the required contract and leaves every optional module off', () => {
+ const env = parseEnv(required)
+ expect(modules(env)).toEqual({ kv: false, nats: false, qdrant: false, git: false, s3: false })
+ })
+
+ it('names the missing required variable', () => {
+ const rest: Record = { ...required }
+ delete rest.CONUSAI_API_TOKEN
+ expect(() => parseEnv(rest)).toThrow(/CONUSAI_API_TOKEN/)
+ })
+
+ it('lights a module when its variable is present', () => {
+ const env = parseEnv({ ...required, REDIS_URL: 'redis://cache.example.com:6379' })
+ expect(modules(env).kv).toBe(true)
+ })
+
+ it('lights the S3 module only when both its endpoint and bucket are present', () => {
+ expect(modules(parseEnv({ ...required, AWS_ENDPOINT_URL: 'https://s3.example.com' })).s3).toBe(false)
+ expect(modules(parseEnv({ ...required, AWS_ENDPOINT_URL: 'https://s3.example.com', S3_BUCKET: 'exports' })).s3).toBe(true)
+ })
+})
diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts
new file mode 100644
index 0000000..87cc54c
--- /dev/null
+++ b/src/shared/config/env.ts
@@ -0,0 +1,60 @@
+import 'server-only'
+import { z } from 'zod'
+
+// The SDK environment contract (showcase D8): the names every deployment receives.
+const required = z.object({
+ CONUSAI_API_URL: z.string().url(),
+ CONUSAI_API_TOKEN: z.string().min(1),
+ CONUSAI_PROJECT_ID: z.string().min(1),
+ CONUSAI_DSN: z.string().url(),
+ CONUSAI_RELEASE: z.string().min(1),
+ POSTGRES_URL: z.string().url(),
+ USAGE_ORG_SLUG: z.string().min(1).optional(),
+})
+
+// Optional services (D13): each one lights a widget when its variables are present.
+const optional = z.object({
+ REDIS_URL: z.string().url().optional(),
+ NATS_URL: z.string().optional(),
+ NATS_TOKEN: z.string().optional(),
+ QDRANT_URL: z.string().url().optional(),
+ QDRANT_API_KEY: z.string().optional(),
+ GIT_URL: z.string().url().optional(),
+ AWS_ENDPOINT_URL: z.string().url().optional(),
+ S3_BUCKET: z.string().min(1).optional(),
+ AWS_ACCESS_KEY_ID: z.string().optional(),
+ AWS_SECRET_ACCESS_KEY: z.string().optional(),
+})
+
+const schema = required.merge(optional)
+
+export type Env = z.infer
+
+/** Parse the environment. A missing required name throws with its name in the message. */
+export function parseEnv(source: Record): Env {
+ const result = schema.safeParse(source)
+ if (!result.success) {
+ const names = result.error.issues.map((issue) => issue.path.join('.')).join(', ')
+ throw new Error(`invalid environment: ${names}`)
+ }
+ return result.data
+}
+
+let parsed: Env | undefined
+
+/** The environment, parsed once on first use. */
+export function getEnv(): Env {
+ parsed ??= parseEnv(process.env)
+ return parsed
+}
+
+/** Which optional modules are linked, for the widgets that depend on them. */
+export function modules(env: Env) {
+ return {
+ kv: Boolean(env.REDIS_URL),
+ nats: Boolean(env.NATS_URL),
+ qdrant: Boolean(env.QDRANT_URL),
+ git: Boolean(env.GIT_URL),
+ s3: Boolean(env.AWS_ENDPOINT_URL && env.S3_BUCKET),
+ } as const
+}
diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts
new file mode 100644
index 0000000..4109b29
--- /dev/null
+++ b/src/shared/config/index.ts
@@ -0,0 +1 @@
+export { getEnv, modules, parseEnv, type Env } from './env'
diff --git a/src/shared/lib/optional.live.test.ts b/src/shared/lib/optional.live.test.ts
new file mode 100644
index 0000000..d54c2b2
--- /dev/null
+++ b/src/shared/lib/optional.live.test.ts
@@ -0,0 +1,64 @@
+import { randomUUID } from 'node:crypto'
+import { connect } from 'nats'
+import { CreateBucketCommand, GetObjectCommand } from '@aws-sdk/client-s3'
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+
+// The live check (plan S4.7 gap 4): NATS, Qdrant and S3 exercised against real
+// services. It runs when their variables are present, the same names a deployment
+// receives (D8, D13), and is skipped otherwise.
+const live = Boolean(process.env.NATS_URL && process.env.QDRANT_URL && process.env.AWS_ENDPOINT_URL)
+
+describe.runIf(live)('optional modules against live services', () => {
+ type Optional = typeof import('@/shared/lib/optional')
+ type Api = typeof import('@/shared/api')
+ let optional: Optional
+ let api: Api
+
+ beforeAll(async () => {
+ vi.stubEnv('S3_BUCKET', process.env.S3_BUCKET ?? 'showcase-live')
+ optional = await import('@/shared/lib/optional')
+ api = await import('@/shared/api')
+ const target = api.s3()
+ if (!target) throw new Error('S3 is not linked')
+ await target.client.send(new CreateBucketCommand({ Bucket: target.bucket })).catch((error: { name?: string }) => {
+ if (error.name !== 'BucketAlreadyOwnedByYou' && error.name !== 'BucketAlreadyExists') throw error
+ })
+ })
+
+ afterAll(() => vi.unstubAllEnvs())
+
+ it('publishes note.created on NATS and a subscriber receives it', async () => {
+ const subscriber = await connect({ servers: process.env.NATS_URL as string })
+ const sub = subscriber.subscribe('showcase.notes.created')
+ const id = randomUUID()
+ await optional.publishNoteCreated({ id, title: 'live note' })
+ await subscriber.flush()
+ const received = await Promise.race([
+ (async () => {
+ for await (const message of sub) return JSON.parse(new TextDecoder().decode(message.data)) as { id: string; title: string }
+ return undefined
+ })(),
+ new Promise((resolve) => setTimeout(() => resolve(undefined), 3000)),
+ ])
+ await subscriber.close()
+ expect(received).toEqual({ id, title: 'live note' })
+ })
+
+ it('indexes a note in Qdrant and finds it as the top similarity hit', async () => {
+ const id = randomUUID()
+ const title = `budget ${id.slice(0, 8)}`
+ await optional.indexNote({ id, title, body: 'quarterly budget review with the finance team' })
+ const hits = await optional.similarNotes(`${title} quarterly budget review`, 1)
+ expect(hits?.[0]?.id).toBe(id)
+ })
+
+ it('writes an export object to the S3 bucket and reads it back', async () => {
+ const notes = [{ title: 'live export' }]
+ const key = await optional.exportToS3('live-visitor', notes)
+ expect(key).toMatch(/^exports\/live-visitor\/.+\.json$/)
+ const target = api.s3()
+ if (!target || !key) throw new Error('S3 is not linked')
+ const object = await target.client.send(new GetObjectCommand({ Bucket: target.bucket, Key: key }))
+ expect(JSON.parse(await object.Body!.transformToString())).toEqual(notes)
+ })
+})
diff --git a/src/shared/lib/optional.test.ts b/src/shared/lib/optional.test.ts
new file mode 100644
index 0000000..d980102
--- /dev/null
+++ b/src/shared/lib/optional.test.ts
@@ -0,0 +1,81 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// The optional-module helpers run against a fake KV store: the modules' clients are
+// replaced, so each test asserts what the helper asked for.
+const mocks = vi.hoisted(() => {
+ const store = new Map()
+ return {
+ store,
+ kvClient: null as null | {
+ incr: (key: string) => Promise
+ expire: (key: string, seconds: number) => Promise
+ get: (key: string) => Promise
+ },
+ expire: vi.fn(async () => 1),
+ }
+})
+
+vi.mock('@/shared/api', () => ({
+ kv: () => mocks.kvClient,
+ nats: () => null,
+ s3: () => null,
+ ensureCollection: vi.fn(async () => {}),
+ upsertPoints: vi.fn(async () => {}),
+ searchPoints: vi.fn(async () => []),
+}))
+vi.mock('@/shared/config', () => ({
+ getEnv: () => ({ QDRANT_URL: undefined }),
+}))
+
+import { consumeUploadQuota, embed, UPLOADS_PER_MINUTE, uploadsLeft } from '@/shared/lib/optional'
+
+function fakeKv() {
+ mocks.kvClient = {
+ incr: async (key: string) => {
+ const next = (mocks.store.get(key) ?? 0) + 1
+ mocks.store.set(key, next)
+ return next
+ },
+ expire: mocks.expire,
+ get: async (key: string) => (mocks.store.has(key) ? (mocks.store.get(key) as T) : null),
+ }
+}
+
+describe('consumeUploadQuota', () => {
+ beforeEach(() => {
+ mocks.store.clear()
+ mocks.expire.mockClear()
+ fakeKv()
+ })
+
+ it('allows the uploads of the minute up to the limit, refuses the next, and sets the window once', async () => {
+ let last: { allowed: boolean; remaining: number } | null = null
+ for (let i = 0; i < UPLOADS_PER_MINUTE; i += 1) last = await consumeUploadQuota('visitor-1')
+ expect(last).toEqual({ allowed: true, remaining: 0 })
+
+ expect(await consumeUploadQuota('visitor-1')).toEqual({ allowed: false, remaining: 0 })
+ expect(mocks.expire).toHaveBeenCalledTimes(1)
+ })
+
+ it('counts each visitor separately', async () => {
+ await consumeUploadQuota('visitor-1')
+ expect(await uploadsLeft('visitor-2')).toBe(UPLOADS_PER_MINUTE)
+ })
+
+ it('is null, and counts nothing, when KV is not linked', async () => {
+ mocks.kvClient = null
+ expect(await consumeUploadQuota('visitor-1')).toBeNull()
+ expect(await uploadsLeft('visitor-1')).toBeNull()
+ })
+})
+
+describe('embed', () => {
+ const dot = (x: number[], y: number[]) => x.reduce((sum, value, i) => sum + value * (y[i] ?? 0), 0)
+
+ it('is stable for the same text, and orthogonal for text with no word in common', () => {
+ const a = embed('plan the launch')
+ expect(embed('plan the launch')).toEqual(a)
+ expect(dot(a, a)).toBeCloseTo(1)
+ expect(dot(a, embed('zebra quartz'))).toBeCloseTo(0)
+ })
+})
diff --git a/src/shared/lib/optional.ts b/src/shared/lib/optional.ts
new file mode 100644
index 0000000..aa8df54
--- /dev/null
+++ b/src/shared/lib/optional.ts
@@ -0,0 +1,83 @@
+import 'server-only'
+import { PutObjectCommand } from '@aws-sdk/client-s3'
+import { ensureCollection, kv, nats, s3, searchPoints, upsertPoints, type QdrantConfig } from '@/shared/api'
+import { getEnv } from '@/shared/config'
+
+/** Uploads a visitor may make in one minute, when the KV module is linked (plan §4.4). */
+export const UPLOADS_PER_MINUTE = 5
+
+/**
+ * Counts one upload against the visitor's minute. `null` when KV is not linked: the
+ * upload then proceeds unlimited, because there is no store to count in.
+ */
+export async function consumeUploadQuota(visitor: string): Promise<{ allowed: boolean; remaining: number } | null> {
+ const store = kv()
+ if (!store) return null
+ const key = `uploads:${visitor}:${Math.floor(Date.now() / 60_000)}`
+ const count = await store.incr(key)
+ if (count === 1) await store.expire(key, 60)
+ return { allowed: count <= UPLOADS_PER_MINUTE, remaining: Math.max(0, UPLOADS_PER_MINUTE - count) }
+}
+
+/** The uploads a visitor has left this minute, read without counting one. `null` without KV. */
+export async function uploadsLeft(visitor: string): Promise {
+ const store = kv()
+ if (!store) return null
+ const key = `uploads:${visitor}:${Math.floor(Date.now() / 60_000)}`
+ const count = Number((await store.get(key)) ?? 0)
+ return Math.max(0, UPLOADS_PER_MINUTE - count)
+}
+
+/** Publishes "a note was created" on NATS when it is linked; a no-op otherwise. */
+export async function publishNoteCreated(note: { id: string; title: string }): Promise {
+ const connection = nats()
+ if (!connection) return
+ (await connection).publish('showcase.notes.created', new TextEncoder().encode(JSON.stringify(note)))
+}
+
+/** The Qdrant config, or `null` when the Qdrant module is not linked. */
+function qdrantConfig(): QdrantConfig | null {
+ const env = getEnv()
+ if (!env.QDRANT_URL) return null
+ return { url: env.QDRANT_URL, apiKey: env.QDRANT_API_KEY, collection: 'showcase_notes' }
+}
+
+/**
+ * A deterministic bag-of-words vector: each word hashes into one of 64 buckets,
+ * counts are normalised. No embedding service is needed, so the similarity search
+ * runs on any install that links Qdrant.
+ */
+export function embed(text: string, size = 64): number[] {
+ const vector = new Array(size).fill(0)
+ for (const word of text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)) {
+ let hash = 0
+ for (const ch of word) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
+ vector[hash % size] = (vector[hash % size] ?? 0) + 1
+ }
+ const norm = Math.hypot(...vector) || 1
+ return vector.map((value) => value / norm)
+}
+
+export async function indexNote(note: { id: string; title: string; body: string }): Promise {
+ const config = qdrantConfig()
+ if (!config) return
+ await ensureCollection(config, 64)
+ await upsertPoints(config, [{ id: note.id, vector: embed(`${note.title} ${note.body}`), payload: { title: note.title } }])
+}
+
+export async function similarNotes(text: string, limit = 5) {
+ const config = qdrantConfig()
+ if (!config) return null
+ return searchPoints(config, embed(text), limit)
+}
+
+/** Writes the visitor's notes as one JSON object to the S3 bucket; `null` when S3 is not linked. */
+export async function exportToS3(visitor: string, notes: unknown): Promise {
+ const target = s3()
+ if (!target) return null
+ const key = `exports/${visitor}/${new Date().toISOString()}.json`
+ await target.client.send(
+ new PutObjectCommand({ Bucket: target.bucket, Key: key, Body: JSON.stringify(notes), ContentType: 'application/json' }),
+ )
+ return key
+}
diff --git a/src/shared/lib/visitor-session.ts b/src/shared/lib/visitor-session.ts
new file mode 100644
index 0000000..39bd815
--- /dev/null
+++ b/src/shared/lib/visitor-session.ts
@@ -0,0 +1,38 @@
+import 'server-only'
+import { cookies } from 'next/headers'
+import { getEnv } from '@/shared/config/env'
+import { VISITOR_COOKIE, newVisitorId, readVisitorId, signVisitorId } from '@/shared/lib/visitor'
+
+// The HMAC key is the deployment token (D8): every container holds it and it
+// never reaches the browser, so the cookie cannot be forged without it.
+const ONE_YEAR = 60 * 60 * 24 * 365
+
+/**
+ * The current visitor's id. A visitor with no valid cookie gets a new one, set
+ * on this response. Call it only from a server action or a route handler:
+ * Next allows cookies to be set there and not during rendering.
+ */
+export async function ensureVisitor(): Promise {
+ const jar = await cookies()
+ const secret = getEnv().CONUSAI_API_TOKEN
+ const existing = readVisitorId(jar.get(VISITOR_COOKIE)?.value, secret)
+ if (existing) return existing
+ const id = newVisitorId()
+ jar.set(VISITOR_COOKIE, signVisitorId(id, secret), {
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: process.env.NODE_ENV === 'production',
+ path: '/',
+ maxAge: ONE_YEAR,
+ })
+ return id
+}
+
+/**
+ * The current visitor's id, or `null` when there is none. Reads the cookie and
+ * never sets one, so it is safe during rendering.
+ */
+export async function currentVisitor(): Promise {
+ const jar = await cookies()
+ return readVisitorId(jar.get(VISITOR_COOKIE)?.value, getEnv().CONUSAI_API_TOKEN)
+}
diff --git a/src/shared/lib/visitor.test.ts b/src/shared/lib/visitor.test.ts
new file mode 100644
index 0000000..561f10d
--- /dev/null
+++ b/src/shared/lib/visitor.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest'
+import { newVisitorId, readVisitorId, signVisitorId } from '@/shared/lib/visitor'
+
+const secret = 'fixture-secret'
+
+describe('visitor cookie', () => {
+ it('reads back the id it signed', () => {
+ const id = newVisitorId()
+ expect(readVisitorId(signVisitorId(id, secret), secret)).toBe(id)
+ })
+
+ it('rejects a value signed with another secret', () => {
+ const id = newVisitorId()
+ expect(readVisitorId(signVisitorId(id, 'other'), secret)).toBeNull()
+ })
+
+ it('rejects a forged id under a valid signature', () => {
+ const signed = signVisitorId(newVisitorId(), secret)
+ const forged = `${newVisitorId()}${signed.slice(signed.lastIndexOf('.'))}`
+ expect(readVisitorId(forged, secret)).toBeNull()
+ })
+
+ it('treats an absent or malformed cookie as no visitor', () => {
+ expect(readVisitorId(undefined, secret)).toBeNull()
+ expect(readVisitorId('no-dot-here', secret)).toBeNull()
+ })
+})
diff --git a/src/shared/lib/visitor.ts b/src/shared/lib/visitor.ts
new file mode 100644
index 0000000..49c40fe
--- /dev/null
+++ b/src/shared/lib/visitor.ts
@@ -0,0 +1,30 @@
+import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
+
+/** The cookie that identifies an anonymous visitor (showcase D16). */
+export const VISITOR_COOKIE = 'sc_vid'
+
+/** A new visitor id: random, opaque, and never derived from anything the visitor sent. */
+export function newVisitorId(): string {
+ return randomUUID()
+}
+
+/** The cookie value for `id`: the id and an HMAC over it, so a forged id fails `readVisitorId`. */
+export function signVisitorId(id: string, secret: string): string {
+ return `${id}.${mac(id, secret)}`
+}
+
+/** The visitor id in a cookie value, or `null` when the value is absent or its signature fails. */
+export function readVisitorId(value: string | undefined, secret: string): string | null {
+ if (!value) return null
+ const dot = value.lastIndexOf('.')
+ if (dot <= 0) return null
+ const id = value.slice(0, dot)
+ const given = Buffer.from(value.slice(dot + 1))
+ const expected = Buffer.from(mac(id, secret))
+ if (given.length !== expected.length) return null
+ return timingSafeEqual(given, expected) ? id : null
+}
+
+function mac(id: string, secret: string): string {
+ return createHmac('sha256', secret).update(id).digest('base64url')
+}
diff --git a/src/views/audit/index.ts b/src/views/audit/index.ts
new file mode 100644
index 0000000..e4ebae2
--- /dev/null
+++ b/src/views/audit/index.ts
@@ -0,0 +1 @@
+export { AuditPage } from './ui/AuditPage'
diff --git a/src/views/audit/ui/AuditPage.tsx b/src/views/audit/ui/AuditPage.tsx
new file mode 100644
index 0000000..6d1b11a
--- /dev/null
+++ b/src/views/audit/ui/AuditPage.tsx
@@ -0,0 +1,11 @@
+import { AuditTimeline } from '@/widgets/audit-timeline'
+
+export function AuditPage() {
+ return (
+
+ Audit
+ Every change this app made, as the platform recorded it.
+
+
+ )
+}
diff --git a/src/views/dashboard/index.ts b/src/views/dashboard/index.ts
new file mode 100644
index 0000000..e5d0d30
--- /dev/null
+++ b/src/views/dashboard/index.ts
@@ -0,0 +1 @@
+export { DashboardPage } from './ui/DashboardPage'
diff --git a/src/views/dashboard/ui/DashboardPage.tsx b/src/views/dashboard/ui/DashboardPage.tsx
new file mode 100644
index 0000000..40ec838
--- /dev/null
+++ b/src/views/dashboard/ui/DashboardPage.tsx
@@ -0,0 +1,25 @@
+import { CreateNoteForm } from '@/features/create-note'
+import { SimulateTrafficButton } from '@/features/simulate-traffic'
+import { UsageMeter } from '@/widgets/usage-meter'
+import { Attachments } from '@/widgets/attachments'
+import { EnableCard } from '@/widgets/enable-card'
+import { ModulePanel } from '@/widgets/module-panel'
+import { NoteList } from '@/widgets/note-list'
+
+export async function DashboardPage({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
+ const { q } = await searchParams
+ return (
+
+ ConusAI Showcase
+ Notes with attachments, audited, observed and metered.
+
+
+
+ } />
+ } />
+
+ Audit timeline · Platform
+
+
+ )
+}
diff --git a/src/views/platform/index.ts b/src/views/platform/index.ts
new file mode 100644
index 0000000..ee0fde5
--- /dev/null
+++ b/src/views/platform/index.ts
@@ -0,0 +1 @@
+export { PlatformPage } from './ui/PlatformPage'
diff --git a/src/views/platform/ui/PlatformPage.test.tsx b/src/views/platform/ui/PlatformPage.test.tsx
new file mode 100644
index 0000000..971e0a5
--- /dev/null
+++ b/src/views/platform/ui/PlatformPage.test.tsx
@@ -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(//g)).toHaveLength(5)
+ expect(markup).toContain('not linked')
+ expect(markup).toContain('unavailable')
+ })
+})
diff --git a/src/views/platform/ui/PlatformPage.tsx b/src/views/platform/ui/PlatformPage.tsx
new file mode 100644
index 0000000..3c5500a
--- /dev/null
+++ b/src/views/platform/ui/PlatformPage.tsx
@@ -0,0 +1,33 @@
+import { platformInfo } from '@/shared/api'
+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. When the description read fails the page
+ * renders the list it owns and a placeholder.
+ */
+export async function PlatformPage() {
+ const info = await platformInfo()
+ const linked = modules(getEnv())
+ return (
+
+ Platform
+ Linked services
+
+ {(Object.keys(linked) as Array).map((name) => (
+
+ {name}: {linked[name] ? 'linked' : 'not linked'}
+
+ ))}
+
+ Description
+ {info ? (
+ {JSON.stringify(info, null, 2)}
+ ) : (
+ The platform description is unavailable.
+ )}
+
+ )
+}
diff --git a/src/widgets/attachments/index.ts b/src/widgets/attachments/index.ts
new file mode 100644
index 0000000..6cb2c42
--- /dev/null
+++ b/src/widgets/attachments/index.ts
@@ -0,0 +1 @@
+export { Attachments } from './ui/Attachments'
diff --git a/src/widgets/attachments/ui/Attachments.tsx b/src/widgets/attachments/ui/Attachments.tsx
new file mode 100644
index 0000000..a6bc03d
--- /dev/null
+++ b/src/widgets/attachments/ui/Attachments.tsx
@@ -0,0 +1,25 @@
+import { fileRepository } from '@/entities/file'
+import { DeleteFileButton } from '@/features/delete-file'
+import { UploadFileForm } from '@/features/upload-file'
+import { db } from '@/shared/api'
+
+/** The attachments on one note, with the controls to add and remove them. */
+export async function Attachments({ noteId }: { noteId: string }) {
+ const attached = await fileRepository(db()).listByNote(noteId)
+ return (
+
+ Attachments
+ {attached.length === 0 ? No attachments yet.
: null}
+
+ {attached.map((file) => (
+
+ {/* Served by the app: the platform's blob URL needs the deployment token, which the browser does not have. */}
+ {file.name} ({file.size} bytes)
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/widgets/audit-timeline/index.ts b/src/widgets/audit-timeline/index.ts
new file mode 100644
index 0000000..bc97939
--- /dev/null
+++ b/src/widgets/audit-timeline/index.ts
@@ -0,0 +1 @@
+export { AuditTimeline } from './ui/AuditTimeline'
diff --git a/src/widgets/audit-timeline/model/rows.test.ts b/src/widgets/audit-timeline/model/rows.test.ts
new file mode 100644
index 0000000..0833c1c
--- /dev/null
+++ b/src/widgets/audit-timeline/model/rows.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import { timelineRows } from '@/widgets/audit-timeline/model/rows'
+
+describe('timelineRows', () => {
+ it('keeps the platform order and names the target when it has one', () => {
+ const rows = timelineRows([
+ {
+ id: 2,
+ action: 'note.created',
+ outcome: 'success',
+ occurred_at: '2026-10-06T10:00:00Z',
+ resource_type: 'note',
+ resource_id: 'n-1',
+ },
+ {
+ id: 1,
+ action: 'file.uploaded',
+ outcome: 'success',
+ occurred_at: '2026-10-06T09:00:00Z',
+ resource_type: null,
+ resource_id: null,
+ },
+ ])
+ expect(rows.map((row) => row.id)).toEqual([2, 1])
+ expect(rows[0]?.target).toBe('note n-1')
+ expect(rows[1]?.target).toBe('')
+ })
+})
diff --git a/src/widgets/audit-timeline/model/rows.ts b/src/widgets/audit-timeline/model/rows.ts
new file mode 100644
index 0000000..1784306
--- /dev/null
+++ b/src/widgets/audit-timeline/model/rows.ts
@@ -0,0 +1,21 @@
+import type { TokenAuditEvent } from '@conusai-sdk/node-sdk'
+
+/** One line of the timeline, as the page shows it. */
+export type TimelineRow = {
+ id: number
+ when: string
+ action: string
+ outcome: string
+ target: string
+}
+
+/** The timeline rows for a page of events, in the order the platform returned them. */
+export function timelineRows(items: TokenAuditEvent[]): TimelineRow[] {
+ return items.map((event) => ({
+ id: event.id,
+ when: event.occurred_at,
+ action: event.action,
+ outcome: event.outcome,
+ target: event.resource_type && event.resource_id ? `${event.resource_type} ${event.resource_id}` : '',
+ }))
+}
diff --git a/src/widgets/audit-timeline/ui/AuditTimeline.tsx b/src/widgets/audit-timeline/ui/AuditTimeline.tsx
new file mode 100644
index 0000000..abdaae7
--- /dev/null
+++ b/src/widgets/audit-timeline/ui/AuditTimeline.tsx
@@ -0,0 +1,18 @@
+import { readAuditEvents } from '@/shared/api'
+import { timelineRows } from '@/widgets/audit-timeline/model/rows'
+
+/** The project's audit events, read back from the platform: what the console's Audit Logs show. */
+export async function AuditTimeline() {
+ const page = await readAuditEvents({ limit: 50 })
+ const rows = timelineRows(page.items)
+ if (rows.length === 0) return No audit events yet.
+ return (
+
+ {rows.map((row) => (
+
+ {row.when} {row.action} ({row.outcome}) {row.target}
+
+ ))}
+
+ )
+}
diff --git a/src/widgets/enable-card/index.ts b/src/widgets/enable-card/index.ts
new file mode 100644
index 0000000..352b277
--- /dev/null
+++ b/src/widgets/enable-card/index.ts
@@ -0,0 +1 @@
+export { EnableCard } from './ui/EnableCard'
diff --git a/src/widgets/enable-card/ui/EnableCard.test.tsx b/src/widgets/enable-card/ui/EnableCard.test.tsx
new file mode 100644
index 0000000..3da67cb
--- /dev/null
+++ b/src/widgets/enable-card/ui/EnableCard.test.tsx
@@ -0,0 +1,27 @@
+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) => ({
+ display_name: `Catalog ${service}`,
+ description: 'Counts uploads per visitor.',
+ })),
+}))
+
+describe('EnableCard', () => {
+ it('renders the catalog text the platform returns for the service', async () => {
+ const { EnableCard } = await import('@/widgets/enable-card/ui/EnableCard')
+ const markup = renderToStaticMarkup(await EnableCard({ service: 'redis' }))
+ 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.')
+ })
+})
diff --git a/src/widgets/enable-card/ui/EnableCard.tsx b/src/widgets/enable-card/ui/EnableCard.tsx
new file mode 100644
index 0000000..5f3af92
--- /dev/null
+++ b/src/widgets/enable-card/ui/EnableCard.tsx
@@ -0,0 +1,17 @@
+import type { ServiceType } from '@conusai-sdk/api'
+import { serviceCatalogEntry } from '@/shared/api'
+
+/**
+ * "Link to enable": the text comes from the platform's service catalog
+ * (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 (
+
+ )
+}
diff --git a/src/widgets/module-panel/index.ts b/src/widgets/module-panel/index.ts
new file mode 100644
index 0000000..8ca3036
--- /dev/null
+++ b/src/widgets/module-panel/index.ts
@@ -0,0 +1,2 @@
+export { ModulePanel } from './ui/ModulePanel'
+export { moduleView, type ModuleName } from './model/view'
diff --git a/src/widgets/module-panel/model/slots.test.tsx b/src/widgets/module-panel/model/slots.test.tsx
new file mode 100644
index 0000000..be6d384
--- /dev/null
+++ b/src/widgets/module-panel/model/slots.test.tsx
@@ -0,0 +1,58 @@
+import { isValidElement, type ReactElement } from 'react'
+import { describe, expect, it, vi } from 'vitest'
+import { ExportNotesButton } from '@/features/export-notes'
+import { UploadsLeft } from '@/features/rate-limit'
+import { SearchNotes } from '@/features/search-notes'
+import { panelSlots } from '@/widgets/module-panel/model/slots'
+import type { ModuleName } from '@/widgets/module-panel/model/view'
+
+// The features' actions reach the shared API barrel; the slot mapping calls none of it.
+vi.mock('@/shared/api', () => ({}))
+
+const none: Record = { kv: false, nats: false, qdrant: false, git: false, s3: false }
+const enable = (service: string) =>
+
+function slotFor(name: ModuleName, linked: Record) {
+ const slot = panelSlots({ linked, query: 'budget', gitHost: 'git.example.test', enable }).find((s) => s.name === name)
+ if (!slot) throw new Error(`no slot for ${name}`)
+ return slot.content
+}
+
+describe('panelSlots', () => {
+ it.each([
+ ['kv', 'kv'],
+ ['nats', 'nats'],
+ ['qdrant', 'qdrant'],
+ ['git', 'git'],
+ ['s3', 's3'],
+ ] as const)('renders the enable card for %s, naming %s, when its variables are absent', (name, service) => {
+ const content = slotFor(name, none) as ReactElement<{ 'data-enable': string }>
+ expect(isValidElement(content)).toBe(true)
+ expect(content.props['data-enable']).toBe(service)
+ })
+
+ it('renders the uploads counter for kv when linked', () => {
+ expect((slotFor('kv', { ...none, kv: true }) as ReactElement).type).toBe(UploadsLeft)
+ })
+
+ it('renders the similarity search for qdrant when linked, with the query', () => {
+ const content = slotFor('qdrant', { ...none, qdrant: true }) as ReactElement<{ query: string }>
+ expect(content.type).toBe(SearchNotes)
+ expect(content.props.query).toBe('budget')
+ })
+
+ it('renders the export button for s3 when linked', () => {
+ expect((slotFor('s3', { ...none, s3: true }) as ReactElement).type).toBe(ExportNotesButton)
+ })
+
+ it('names the git host the packages install from when git is linked', () => {
+ const content = slotFor('git', { ...none, git: true }) as ReactElement<{ children: unknown[] }>
+ expect(content.type).toBe('p')
+ expect(content.props.children).toContain('git.example.test')
+ })
+
+ it('renders no feature for a module whose variables are absent', () => {
+ const content = slotFor('kv', none) as ReactElement
+ expect(content.type).not.toBe(UploadsLeft)
+ })
+})
diff --git a/src/widgets/module-panel/model/slots.tsx b/src/widgets/module-panel/model/slots.tsx
new file mode 100644
index 0000000..e692080
--- /dev/null
+++ b/src/widgets/module-panel/model/slots.tsx
@@ -0,0 +1,40 @@
+import type { ReactNode } from 'react'
+import { ExportNotesButton } from '@/features/export-notes'
+import { UploadsLeft } from '@/features/rate-limit'
+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 = 'kv' | 'nats' | 'qdrant' | 'git' | 's3'
+
+export type ModuleSlot = { name: ModuleName; service: ModuleService; content: ReactNode }
+
+/**
+ * The panel's slots, one per optional module (D13): the module's feature when its
+ * variables are present, the enable card the view supplies when they are not. A pure
+ * function, so a test can assert what each slot renders without a server.
+ */
+export function panelSlots({
+ linked,
+ query,
+ gitHost,
+ enable,
+}: {
+ linked: Record
+ query: string | undefined
+ gitHost: string
+ enable: (service: ModuleService) => ReactNode
+}): ModuleSlot[] {
+ const features: Array<{ name: ModuleName; service: ModuleService; feature: ReactNode }> = [
+ { name: 'kv', service: 'kv', feature: },
+ { name: 'nats', service: 'nats', feature: New notes are announced live on NATS.
},
+ { name: 'qdrant', service: 'qdrant', feature: },
+ { name: 's3', service: 's3', feature: },
+ { name: 'git', service: 'git', feature: Packages install from {gitHost}.
},
+ ]
+ return features.map((slot) => ({
+ name: slot.name,
+ service: slot.service,
+ content: moduleView(slot.name, linked) === 'feature' ? slot.feature : enable(slot.service),
+ }))
+}
diff --git a/src/widgets/module-panel/model/view.test.ts b/src/widgets/module-panel/model/view.test.ts
new file mode 100644
index 0000000..7b65877
--- /dev/null
+++ b/src/widgets/module-panel/model/view.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest'
+import { moduleView, type ModuleName } from '@/widgets/module-panel/model/view'
+
+const names: ModuleName[] = ['kv', 'nats', 'qdrant', 'git', 's3']
+const none = { kv: false, nats: false, qdrant: false, git: false, s3: false }
+
+describe('moduleView', () => {
+ it.each(names)('shows the enable card for %s when its variables are absent', (name) => {
+ expect(moduleView(name, none)).toBe('enable')
+ })
+
+ it.each(names)('shows the feature for %s when its variables are present', (name) => {
+ expect(moduleView(name, { ...none, [name]: true })).toBe('feature')
+ })
+})
diff --git a/src/widgets/module-panel/model/view.ts b/src/widgets/module-panel/model/view.ts
new file mode 100644
index 0000000..8fd3735
--- /dev/null
+++ b/src/widgets/module-panel/model/view.ts
@@ -0,0 +1,9 @@
+/** The optional modules the panel shows (plan D13), by their env-derived names. */
+export type ModuleName = 'kv' | 'nats' | 'qdrant' | 'git' | 's3'
+
+/** What a module's slot shows: its feature when linked, its enable card when not. */
+export type ModuleView = 'feature' | 'enable'
+
+export function moduleView(name: ModuleName, linked: Record): ModuleView {
+ return linked[name] ? 'feature' : 'enable'
+}
diff --git a/src/widgets/module-panel/ui/ModulePanel.tsx b/src/widgets/module-panel/ui/ModulePanel.tsx
new file mode 100644
index 0000000..afe45b1
--- /dev/null
+++ b/src/widgets/module-panel/ui/ModulePanel.tsx
@@ -0,0 +1,31 @@
+import type { ReactNode } from 'react'
+import { getEnv, modules } from '@/shared/config'
+import { panelSlots, type ModuleService } from '@/widgets/module-panel/model/slots'
+
+/**
+ * One slot per optional module (D13): the module's feature when its variables are
+ * present, and the platform-described enable card when they are not.
+ */
+export function ModulePanel({
+ query,
+ enable,
+}: {
+ query: string | undefined
+ /** The enable card for a service, supplied by the view that composes this panel. */
+ enable: (service: ModuleService) => ReactNode
+}) {
+ const env = getEnv()
+ const slots = panelSlots({
+ linked: modules(env),
+ query,
+ gitHost: env.GIT_URL ? new URL(env.GIT_URL).host : '',
+ enable,
+ })
+ return (
+
+ {slots.map((slot) => (
+ {slot.content}
+ ))}
+
+ )
+}
diff --git a/src/widgets/note-list/index.ts b/src/widgets/note-list/index.ts
new file mode 100644
index 0000000..c3ab36a
--- /dev/null
+++ b/src/widgets/note-list/index.ts
@@ -0,0 +1 @@
+export { NoteList } from './ui/NoteList'
diff --git a/src/widgets/note-list/ui/NoteList.tsx b/src/widgets/note-list/ui/NoteList.tsx
new file mode 100644
index 0000000..0f3be12
--- /dev/null
+++ b/src/widgets/note-list/ui/NoteList.tsx
@@ -0,0 +1,35 @@
+import { noteRepository } from '@/entities/note'
+import type { ReactNode } from 'react'
+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'
+
+/**
+ * The visitor's own notes, each with its edit and delete controls. `extra` renders
+ * whatever a view adds under each note (its attachments, for one).
+ */
+export async function NoteList({ extra }: { extra?: (noteId: string) => ReactNode }) {
+ const visitor = await currentVisitor()
+ if (!visitor) return No notes yet.
+ const notes = await noteRepository(db()).listByAuthor(visitor)
+ if (notes.length === 0) return No notes yet.
+ // 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 (
+
+ {notes.map((note) => (
+
+ {note.title}
+
+
+
+ {extra?.(note.id)}
+
+ ))}
+
+ )
+}
diff --git a/src/widgets/usage-meter/index.ts b/src/widgets/usage-meter/index.ts
new file mode 100644
index 0000000..03fe64f
--- /dev/null
+++ b/src/widgets/usage-meter/index.ts
@@ -0,0 +1,2 @@
+export { UsageMeter } from './ui/UsageMeter'
+export { usageTotals, type UsageTotals } from './model/totals'
diff --git a/src/widgets/usage-meter/model/totals.test.ts b/src/widgets/usage-meter/model/totals.test.ts
new file mode 100644
index 0000000..28f3d0f
--- /dev/null
+++ b/src/widgets/usage-meter/model/totals.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import { usageTotals } from '@/widgets/usage-meter/model/totals'
+
+describe('usageTotals', () => {
+ it('counts uploads and sums their bytes', () => {
+ expect(usageTotals([{ size: 10 }, { size: 32 }])).toEqual({ uploads: 2, bytes: 42 })
+ })
+
+ it('is zero for a visitor with no uploads', () => {
+ expect(usageTotals([])).toEqual({ uploads: 0, bytes: 0 })
+ })
+})
diff --git a/src/widgets/usage-meter/model/totals.ts b/src/widgets/usage-meter/model/totals.ts
new file mode 100644
index 0000000..454b27d
--- /dev/null
+++ b/src/widgets/usage-meter/model/totals.ts
@@ -0,0 +1,9 @@
+/** The visitor's totals, from the attachments they made. */
+export type UsageTotals = { uploads: number; bytes: number }
+
+export function usageTotals(files: ReadonlyArray<{ size: number }>): UsageTotals {
+ return {
+ uploads: files.length,
+ bytes: files.reduce((sum, file) => sum + file.size, 0),
+ }
+}
diff --git a/src/widgets/usage-meter/ui/UsageMeter.tsx b/src/widgets/usage-meter/ui/UsageMeter.tsx
new file mode 100644
index 0000000..c3040ec
--- /dev/null
+++ b/src/widgets/usage-meter/ui/UsageMeter.tsx
@@ -0,0 +1,16 @@
+import { fileRepository } from '@/entities/file'
+import { db } from '@/shared/api'
+import { currentVisitor } from '@/shared/lib/visitor-session'
+import { usageTotals } from '@/widgets/usage-meter/model/totals'
+
+/** What the visitor has metered so far: uploads and the bytes they stored. */
+export async function UsageMeter() {
+ const visitor = await currentVisitor()
+ const files = visitor ? await fileRepository(db()).listByAuthor(visitor) : []
+ const totals = usageTotals(files)
+ return (
+
+ {totals.uploads} uploads, {totals.bytes} bytes stored
+
+ )
+}
diff --git a/steiger.config.mjs b/steiger.config.mjs
new file mode 100644
index 0000000..8bd7eea
--- /dev/null
+++ b/steiger.config.mjs
@@ -0,0 +1,14 @@
+import { defineConfig } from 'steiger'
+import fsd from '@feature-sliced/steiger-plugin'
+
+// Two FSD rules are off, both on purpose. fsd/repetitive-naming: the spec names
+// the slices create-note, edit-note and delete-note, and each repeats "note".
+// fsd/insignificant-slice: each action is its own slice (plan §4.3), so a slice
+// that one widget uses stays a slice rather than being merged into it.
+export default defineConfig([
+ ...fsd.configs.recommended,
+ {
+ files: ['./src/**/*'],
+ rules: { 'fsd/repetitive-naming': 'off', 'fsd/insignificant-slice': 'off' },
+ },
+])
diff --git a/test/conusai-yaml.test.ts b/test/conusai-yaml.test.ts
new file mode 100644
index 0000000..e2a760b
--- /dev/null
+++ b/test/conusai-yaml.test.ts
@@ -0,0 +1,14 @@
+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.json (schema: crates/conusai-presets/src/repo_config.rs,
+// `health_check.path`).
+describe('conusai.json', () => {
+ const config = JSON.parse(readFileSync(new URL('../conusai.json', import.meta.url), 'utf8'))
+
+ it('declares the health check path the app actually serves', () => {
+ expect(config.health_check.path).toBe('/api/health')
+ })
+})
diff --git a/test/favicon.test.ts b/test/favicon.test.ts
new file mode 100644
index 0000000..79bf875
--- /dev/null
+++ b/test/favicon.test.ts
@@ -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 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(' {
+ const layout = readFileSync(
+ new URL('../src/app/layouts/root.tsx', import.meta.url),
+ 'utf8',
+ )
+ expect(layout).toMatch(/\/favicon\.svg/)
+ expect(layout).toMatch(/\/favicon\.ico/)
+ })
+})
diff --git a/test/globals-css.test.ts b/test/globals-css.test.ts
new file mode 100644
index 0000000..b8c8aa1
--- /dev/null
+++ b/test/globals-css.test.ts
@@ -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')
+ })
+})
diff --git a/test/instrumentation-client.test.ts b/test/instrumentation-client.test.ts
new file mode 100644
index 0000000..84d293e
--- /dev/null
+++ b/test/instrumentation-client.test.ts
@@ -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 {
+ 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 = {}
+ 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['"]/)
+ })
+})
diff --git a/test/next-config.test.ts b/test/next-config.test.ts
new file mode 100644
index 0000000..1069da4
--- /dev/null
+++ b/test/next-config.test.ts
@@ -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)
+ })
+})
diff --git a/test/npm-install.test.ts b/test/npm-install.test.ts
new file mode 100644
index 0000000..996ed4f
--- /dev/null
+++ b/test/npm-install.test.ts
@@ -0,0 +1,34 @@
+import { existsSync, readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+
+// The platform builder installs a Next.js project with `npm install` on node:22,
+// whose npm 10 arborist crashes resolving vitest's optional peer set:
+// "npm error Cannot read properties of null (reading 'edgesOut')" in
+// #loadPeerSet (build.conusai.com, 2026-10-18: every Showcase deploy failed in
+// build_image; reproduced on node:22 / npm 10.9.9 with this package.json, vitest
+// 4.1.12 and 5.x alike, and an explicit vite alone does not avoid it). The
+// project .npmrc turns the peer auto-install off (the preset copies `.npmrc*`
+// next to package.json for the install step), so vitest's own peer, vite, is
+// declared here instead.
+describe('npm install on the platform builder', () => {
+ const npmrcUrl = new URL('../.npmrc', import.meta.url)
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
+
+ it('ships an .npmrc that keeps npm 10 out of the optional-peer crash', () => {
+ expect(existsSync(npmrcUrl)).toBe(true)
+ const npmrc = readFileSync(npmrcUrl, 'utf8')
+ expect(npmrc).toMatch(/^legacy-peer-deps=true$/m)
+ })
+
+ it('carries no registry or credential in that .npmrc', () => {
+ // Registry mappings and tokens reach the build as the npmrc secret, never
+ // the repository (crates/conusai-presets/src/nextjs.rs).
+ const npmrc = existsSync(npmrcUrl) ? readFileSync(npmrcUrl, 'utf8') : ''
+ expect(npmrc).not.toMatch(/registry|_authToken|_auth|token/i)
+ })
+
+ it('declares vitest\'s vite peer itself, since npm no longer installs peers', () => {
+ expect(pkg.devDependencies.vitest).toBeDefined()
+ expect(pkg.devDependencies.vite).toMatch(/^\d+\.\d+\.\d+$/)
+ })
+})
diff --git a/test/sdk-deps.test.ts b/test/sdk-deps.test.ts
new file mode 100644
index 0000000..7d4bce2
--- /dev/null
+++ b/test/sdk-deps.test.ts
@@ -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()): Set {
+ 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
+ devDependencies?: Record
+}
+const declared: Record = { ...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 {
+ const versions = new Map()
+ 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..g` 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}\.g[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))
+ }
+ })
+})
diff --git a/test/server-only.ts b/test/server-only.ts
new file mode 100644
index 0000000..d8f28c5
--- /dev/null
+++ b/test/server-only.ts
@@ -0,0 +1,4 @@
+// Vitest runs outside Next's server bundle, where `server-only` throws on import.
+// The alias in vitest.config.ts points that import here, so server modules load
+// under test without weakening the production guard.
+export {}
diff --git a/test/tsconfig.test.ts b/test/tsconfig.test.ts
new file mode 100644
index 0000000..ca61109
--- /dev/null
+++ b/test/tsconfig.test.ts
@@ -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')
+ })
+})
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..eae4aba
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "noFallthroughCasesInSwitch": true,
+ "exactOptionalPropertyTypes": true,
+ "allowJs": false,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "baseUrl": ".",
+ "paths": { "@/*": ["./src/*"] }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..06b2112
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,19 @@
+import { fileURLToPath } from 'node:url'
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ resolve: {
+ alias: [
+ { find: /^@\//, replacement: `${fileURLToPath(new URL('./src', import.meta.url))}/` },
+ { find: /^server-only$/, replacement: fileURLToPath(new URL('./test/server-only.ts', import.meta.url)) },
+ ],
+ },
+ // The tests transform JSX themselves (oxc automatic runtime).
+ oxc: { jsx: { runtime: 'automatic' } },
+ test: {
+ 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\//] } },
+ },
+})