ConusAI vendor v0.1.0-dev.20261027+9b1131fb
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
next-env.d.ts
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
@@ -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 <package> 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/<id>`, downloaded server-side through the Blob API and streamed to the visitor who attached it: the platform's blob URL needs the deployment token, so the browser cannot open it. |
|
||||||
|
| `features/publish-note-event` | An author re-announces a note on NATS (`note.announced` in the audit log); another visitor is refused. |
|
||||||
|
| `features/search-notes` | Similar-note search in Qdrant, when Qdrant is linked. |
|
||||||
|
| `features/export-notes` | The visitor's notes written as one JSON object to the dedicated S3 bucket, when S3 is linked. |
|
||||||
|
| `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.
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export { AuditPage as default } from '@/views/audit'
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
export { GET } from '@/features/download-file'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { RootLayout as default, metadata } from '@/app/layouts/root'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { DashboardPage as default } from '@/views/dashboard'
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
export { PlatformPage as default } from '@/views/platform'
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 2,
|
||||||
|
"health_check": {
|
||||||
|
"path": "/api/health"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test'
|
||||||
|
|
||||||
|
// Showcase D23: register() in instrumentation.ts starts the OTLP exporters when the platform
|
||||||
|
// injects OTEL_EXPORTER_OTLP_ENDPOINT, and every server action logs through shared/api/observe's
|
||||||
|
// logger(). This proves the whole chain against a real `next start` production server (not dev,
|
||||||
|
// not a mock of register()): a log a server action emits reaches the fake platform's
|
||||||
|
// /otel/v1/logs route within the exporter's 5 s batch interval.
|
||||||
|
|
||||||
|
const platform = 'http://127.0.0.1:4010'
|
||||||
|
|
||||||
|
interface LogRecordJson {
|
||||||
|
body?: { stringValue?: string }
|
||||||
|
attributes?: Array<{ key: string; value: { stringValue?: string } }>
|
||||||
|
}
|
||||||
|
|
||||||
|
type OtelState = { otel: { logs: Array<{ authorization: string | null; body: { resourceLogs: Array<{ scopeLogs: Array<{ logRecords: LogRecordJson[] }> }> } }> } }
|
||||||
|
|
||||||
|
async function otelLogRecords(): Promise<LogRecordJson[]> {
|
||||||
|
const response = await fetch(`${platform}/__e2e/state`)
|
||||||
|
const state = (await response.json()) as OtelState
|
||||||
|
return state.otel.logs.flatMap((post) => post.body.resourceLogs.flatMap((r) => r.scopeLogs.flatMap((s) => s.logRecords)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function attr(record: LogRecordJson, key: string): string | undefined {
|
||||||
|
return record.attributes?.find((kv) => kv.key === key)?.value.stringValue
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNote(page: Page, title: string): Promise<void> {
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByLabel('Title').fill(title)
|
||||||
|
await page.getByLabel('Body').fill('observability e2e')
|
||||||
|
await page.getByRole('button', { name: 'Create note' }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: title })).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a note.created server action log reaches the OTLP receiver within the batch interval', async ({ page }) => {
|
||||||
|
const title = `otlp ${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||||
|
await createNote(page, title)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => (await otelLogRecords()).some((r) => r.body?.stringValue === 'note.created' && attr(r, 'outcome') === 'ok'),
|
||||||
|
// BatchExporter's default flush interval is 5 s (exporter.ts); this allows one full
|
||||||
|
// interval plus slack for the request round trip and CI scheduling jitter.
|
||||||
|
{ timeout: 9_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the OTLP request carries the deployment token, not the public client', async ({ page }) => {
|
||||||
|
const title = `otlp-auth ${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||||
|
await createNote(page, title)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await otelLogRecords()).some((r) => r.body?.stringValue === 'note.created'), { timeout: 9_000 })
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const response = await fetch(`${platform}/__e2e/state`)
|
||||||
|
const state = (await response.json()) as OtelState
|
||||||
|
expect(state.otel.logs.every((post) => post.authorization === 'Bearer e2e-deployment-token')).toBe(true)
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
// Proves, against a real production build (next build + next start, plan D18), that a
|
||||||
|
// visit actually uploads a session replay segment — not just that the SDK is configured
|
||||||
|
// to try. instrumentation-client.ts's /api/_conusai/* calls are same-origin and answered
|
||||||
|
// by the platform's edge on a real deployment (docs/how-to/use-platform-analytics-in-an-
|
||||||
|
// app.md §"Why this works"); this app has no edge in front of it here, so the test stands
|
||||||
|
// in for it with the minimum the SDK needs: a session id from /ids, and a place to post
|
||||||
|
// the envelope. This caught the stage regression (2026-10-11, showcase plan D24) where
|
||||||
|
// mode: 'always' alone produced zero /api/_conusai/envelope requests because replay is a
|
||||||
|
// 'consented' item and the default consent mode never sends those.
|
||||||
|
|
||||||
|
const SESSION_ID = '11111111-1111-1111-1111-111111111111'
|
||||||
|
|
||||||
|
test('a visit uploads a session replay segment within 15 s on a production build', async ({ page }) => {
|
||||||
|
let envelopePosts = 0
|
||||||
|
|
||||||
|
// Analytics, vitals and errors also call the same-origin /api/_conusai/* paths; answer
|
||||||
|
// them all generically so the run is quiet and deterministic, except the two the
|
||||||
|
// recorder actually needs.
|
||||||
|
await page.route('**/api/_conusai/**', (route) => {
|
||||||
|
const url = route.request().url()
|
||||||
|
if (url.includes('/api/_conusai/ids')) {
|
||||||
|
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ v: 1, session_id: SESSION_ID }) })
|
||||||
|
}
|
||||||
|
if (url.includes('/api/_conusai/envelope')) {
|
||||||
|
envelopePosts += 1
|
||||||
|
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||||
|
}
|
||||||
|
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await page.getByLabel('Title').click()
|
||||||
|
await page.mouse.wheel(0, 200)
|
||||||
|
await page.goto('/audit')
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect.poll(() => envelopePosts, { timeout: 15_000, message: 'no POST to /api/_conusai/envelope (replay segment)' }).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
@@ -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<PlatformState> {
|
||||||
|
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<void> {
|
||||||
|
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<PerformanceEntry & { interactionId?: number }>) {
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
+16
@@ -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')
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#2563eb" />
|
||||||
|
<text x="16" y="22" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#ffffff" text-anchor="middle">C</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 283 B |
@@ -0,0 +1,232 @@
|
|||||||
|
/* Global stylesheet for the Showcase.
|
||||||
|
*
|
||||||
|
* The template deliberately ships no Tailwind or CSS framework (D11 keeps
|
||||||
|
* every layer dependency-light), so this file carries the whole design with
|
||||||
|
* element selectors only — no class names exist in the markup. Style new
|
||||||
|
* markup with semantic elements (main, section, aside, form, ol/ul, pre)
|
||||||
|
* rather than adding utility classes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f6f7f9;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--border: #e2e5ea;
|
||||||
|
--text: #1a202c;
|
||||||
|
--muted: #5b6472;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--accent-hover: #4338ca;
|
||||||
|
--danger: #b91c1c;
|
||||||
|
--radius: 10px;
|
||||||
|
--shadow: 0 1px 2px rgb(16 24 40 / 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
Helvetica,
|
||||||
|
Arial,
|
||||||
|
sans-serif;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 56rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1.25rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin: 1.75rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
main > p:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms: the create/edit note forms and every action button. */
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
button:focus-visible,
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
align-self: flex-start;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
form[action=''] + button,
|
||||||
|
main > button {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists: notes (ul) and the audit timeline (ol). */
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
list-style: none;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li,
|
||||||
|
ol li {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul li h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
time {
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The module panel: one card per optional service, enable cards included. */
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
section > div {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
section > div > aside,
|
||||||
|
section > div > form {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
section aside {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section aside p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
section aside p + p {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section form {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section button {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The platform page's raw description payload. */
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
main {
|
||||||
|
padding: 1.25rem 1rem 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <link> tag name `/favicon.svg` (served from
|
||||||
|
// `public/favicon.svg`) instead of `/icon.svg`, since both paths 404'd on
|
||||||
|
// every visit before this (stage sweep, 2026-10-11).
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: '/favicon.svg', type: 'image/svg+xml' },
|
||||||
|
{ url: '/favicon.ico', sizes: 'any' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RootLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<Observe>{children}</Observe>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<ObserveProvider>
|
||||||
|
<ErrorBoundary fallback={<p>Something went wrong. The error has been reported.</p>}>{children}</ErrorBoundary>
|
||||||
|
</ObserveProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<typeof drizzle>
|
||||||
|
|
||||||
|
export type NewFile = Omit<FileRow, 'id' | 'createdAt'>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<FileRow> {
|
||||||
|
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<FileRow[]> {
|
||||||
|
return db.select().from(files).where(eq(files.noteId, noteId))
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The attachment, or `undefined` when `authorId` did not attach it. */
|
||||||
|
async get(id: string, authorId: string): Promise<FileRow | undefined> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(files)
|
||||||
|
.where(and(eq(files.id, id), eq(files.authorId, authorId)))
|
||||||
|
.limit(1)
|
||||||
|
return row
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Every attachment the visitor made, across their notes. */
|
||||||
|
async listByAuthor(authorId: string): Promise<FileRow[]> {
|
||||||
|
return db.select().from(files).where(eq(files.authorId, authorId))
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The removed file, or `undefined` when `authorId` did not attach it. */
|
||||||
|
async remove(id: string, authorId: string): Promise<FileRow | undefined> {
|
||||||
|
const [row] = await db
|
||||||
|
.delete(files)
|
||||||
|
.where(and(eq(files.id, id), eq(files.authorId, authorId)))
|
||||||
|
.returning()
|
||||||
|
return row
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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()
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<typeof uploadLimits>
|
||||||
@@ -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
|
||||||
@@ -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<typeof noteRepository>
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<typeof drizzle>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Note> {
|
||||||
|
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<Note | undefined> {
|
||||||
|
const [row] = await db.select().from(notes).where(eq(notes.id, id)).limit(1)
|
||||||
|
return row
|
||||||
|
},
|
||||||
|
|
||||||
|
async listByAuthor(authorId: string, limit = 50): Promise<Note[]> {
|
||||||
|
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<Note | undefined> {
|
||||||
|
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<boolean> {
|
||||||
|
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<typeof noteRepository>
|
||||||
@@ -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'
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<typeof noteInput>
|
||||||
|
|
||||||
|
/** A note id as it arrives in a form or a URL: a UUID, nothing else. */
|
||||||
|
export const noteId = z.uuid()
|
||||||
@@ -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
|
||||||
@@ -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<string, string>): 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: '' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { createNote } from './api/actions'
|
||||||
|
export { CreateNoteForm } from './ui/CreateNoteForm'
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createNote } from '@/features/create-note/api/actions'
|
||||||
|
|
||||||
|
export function CreateNoteForm() {
|
||||||
|
return (
|
||||||
|
<form action={createNote}>
|
||||||
|
<label>
|
||||||
|
Title
|
||||||
|
<input name="title" required maxLength={200} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Body
|
||||||
|
<textarea name="body" rows={4} />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Create note</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { deleteFile } from './api/actions'
|
||||||
|
export { DeleteFileButton } from './ui/DeleteFileButton'
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { deleteFile } from '@/features/delete-file/api/actions'
|
||||||
|
|
||||||
|
export function DeleteFileButton({ id }: { id: string }) {
|
||||||
|
return (
|
||||||
|
<form action={deleteFile.bind(null, id)}>
|
||||||
|
<button type="submit">Remove attachment</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<void> {
|
||||||
|
try {
|
||||||
|
const key = noteId.parse(id)
|
||||||
|
const actor = await ensureVisitor()
|
||||||
|
const removed = await noteRepository(db()).remove(key, actor)
|
||||||
|
if (!removed) {
|
||||||
|
logger()?.info('note.deleted', { 'note.id': key, outcome: 'denied' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await audit().log('note.deleted', { actor: { kind: 'human', id: actor }, target: { id: key } })
|
||||||
|
logger()?.info('note.deleted', { 'note.id': key, outcome: 'ok' })
|
||||||
|
revalidatePath('/')
|
||||||
|
} catch (error) {
|
||||||
|
logger()?.recordException(error, { 'conusai.action': 'note.deleted', 'note.id': id })
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { deleteNote } from './api/actions'
|
||||||
|
export { DeleteNoteButton } from './ui/DeleteNoteButton'
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { deleteNote } from '@/features/delete-note/api/actions'
|
||||||
|
|
||||||
|
export function DeleteNoteButton({ id }: { id: string }) {
|
||||||
|
return (
|
||||||
|
<form action={deleteNote.bind(null, id)}>
|
||||||
|
<button type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// The download route runs against fakes: a Blob Storage client whose download
|
||||||
|
// answers with known bytes, and a database whose attachment row is set per test.
|
||||||
|
const FILE = '00000000-0000-4000-8000-0000000000ff'
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
download: vi.fn(async () => new Response('sixteen bytes!!!')),
|
||||||
|
row: undefined as { id: string; name: string; contentType: string; url: string } | undefined,
|
||||||
|
visitor: 'visitor-1' as string | null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fakeDb = {
|
||||||
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => (mocks.row ? [mocks.row] : []) }) }) }),
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@/shared/api', () => ({
|
||||||
|
db: () => fakeDb,
|
||||||
|
blob: () => ({ download: mocks.download }),
|
||||||
|
}))
|
||||||
|
vi.mock('@/shared/lib/visitor-session', () => ({ currentVisitor: async () => mocks.visitor }))
|
||||||
|
|
||||||
|
import { downloadAttachment, GET } from '@/features/download-file/api/route'
|
||||||
|
|
||||||
|
describe('downloadAttachment', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.download.mockClear()
|
||||||
|
mocks.visitor = 'visitor-1'
|
||||||
|
mocks.row = {
|
||||||
|
id: FILE,
|
||||||
|
name: 'notes.txt',
|
||||||
|
contentType: 'text/plain',
|
||||||
|
url: 'https://blob.example.com/api/blob/7/notes/n/notes.txt',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('streams the bytes with the stored content type and an attachment disposition', async () => {
|
||||||
|
const response = await downloadAttachment(FILE)
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.text()).toBe('sixteen bytes!!!')
|
||||||
|
expect(response.headers.get('content-type')).toBe('text/plain')
|
||||||
|
expect(response.headers.get('content-disposition')).toBe("attachment; filename*=UTF-8''notes.txt")
|
||||||
|
expect(mocks.download).toHaveBeenCalledWith(mocks.row?.url)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers 404 for a file row the visitor does not own', async () => {
|
||||||
|
mocks.row = undefined
|
||||||
|
const response = await downloadAttachment(FILE)
|
||||||
|
expect(response.status).toBe(404)
|
||||||
|
expect(mocks.download).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers 404 when there is no visitor cookie', async () => {
|
||||||
|
mocks.visitor = null
|
||||||
|
expect((await downloadAttachment(FILE)).status).toBe(404)
|
||||||
|
expect(mocks.download).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers 404 for an id that is not a UUID', async () => {
|
||||||
|
expect((await downloadAttachment('../../etc/passwd')).status).toBe(404)
|
||||||
|
expect(mocks.download).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('encodes a hostile file name into the disposition', async () => {
|
||||||
|
if (mocks.row) mocks.row.name = 'a"\r\nb.txt'
|
||||||
|
const response = await downloadAttachment(FILE)
|
||||||
|
expect(response.headers.get('content-disposition')).toBe("attachment; filename*=UTF-8''a%22%0D%0Ab.txt")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET', () => {
|
||||||
|
it('takes the id from the route params', async () => {
|
||||||
|
mocks.row = {
|
||||||
|
id: FILE,
|
||||||
|
name: 'notes.txt',
|
||||||
|
contentType: 'text/plain',
|
||||||
|
url: 'https://blob.example.com/api/blob/7/notes/n/notes.txt',
|
||||||
|
}
|
||||||
|
const response = await GET(new Request('http://localhost/files/x'), { params: Promise.resolve({ id: FILE }) })
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { fileId, fileRepository } from '@/entities/file'
|
||||||
|
import { blob, db } from '@/shared/api'
|
||||||
|
import { currentVisitor } from '@/shared/lib/visitor-session'
|
||||||
|
|
||||||
|
function notFound(): Response {
|
||||||
|
return new Response('not found', { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bytes of one attachment, for the visitor who attached it. The platform's
|
||||||
|
* blob URL needs the deployment token, which the browser does not have, so the
|
||||||
|
* app downloads server-side and streams the bytes back. Anything that is not
|
||||||
|
* the visitor's own attachment answers 404, never 403.
|
||||||
|
*/
|
||||||
|
export async function downloadAttachment(id: string): Promise<Response> {
|
||||||
|
const parsed = fileId.safeParse(id)
|
||||||
|
if (!parsed.success) return notFound()
|
||||||
|
const visitor = await currentVisitor()
|
||||||
|
if (!visitor) return notFound()
|
||||||
|
const file = await fileRepository(db()).get(parsed.data, visitor)
|
||||||
|
if (!file) return notFound()
|
||||||
|
const upstream = await blob().download(file.url)
|
||||||
|
if (!upstream.body) return notFound()
|
||||||
|
return new Response(upstream.body, {
|
||||||
|
headers: {
|
||||||
|
'content-type': file.contentType,
|
||||||
|
// RFC 5987: the name is percent-encoded, so quotes and CR/LF cannot inject.
|
||||||
|
'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /files/<id>`: the route handler the app router re-exports. */
|
||||||
|
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }): Promise<Response> {
|
||||||
|
return downloadAttachment((await context.params).id)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { GET } from './api/route'
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { updateNote } from './api/actions'
|
||||||
|
export { EditNoteForm } from './ui/EditNoteForm'
|
||||||
@@ -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 (
|
||||||
|
<form action={updateNote.bind(null, note.id)}>
|
||||||
|
<label>
|
||||||
|
Title
|
||||||
|
<input name="title" defaultValue={note.title} required maxLength={200} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Body
|
||||||
|
<textarea name="body" rows={3} defaultValue={note.body} />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<ExportResult> {
|
||||||
|
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' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { exportNotes } from './api/actions'
|
||||||
|
export { ExportNotesButton } from './ui/ExportNotesButton'
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { ExportNotesButton } from '@/features/export-notes/ui/ExportNotesButton'
|
||||||
|
|
||||||
|
// The button gave no feedback at all after submit: no message, no object key, and
|
||||||
|
// nothing in the audit timeline (stage sweep, 2026-10-11; the audit half is covered by
|
||||||
|
// exportNotes's own tests). This only checks the button's initial render — before any
|
||||||
|
// submission `useActionState`'s state is `null` — so neither the success nor the error
|
||||||
|
// branch has leaked into the markup by mistake.
|
||||||
|
describe('ExportNotesButton', () => {
|
||||||
|
it('renders the form with no result before any submission', () => {
|
||||||
|
const markup = renderToStaticMarkup(<ExportNotesButton />)
|
||||||
|
expect(markup).toContain('<form')
|
||||||
|
expect(markup).toContain('Export my notes to S3')
|
||||||
|
expect(markup).not.toContain('Exported')
|
||||||
|
expect(markup).not.toMatch(/role="alert"/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useActionState } from 'react'
|
||||||
|
import { exportNotes, type ExportResult } from '@/features/export-notes/api/actions'
|
||||||
|
|
||||||
|
export function ExportNotesButton() {
|
||||||
|
const [result, action, pending] = useActionState<ExportResult | null, FormData>(exportNotes, null)
|
||||||
|
return (
|
||||||
|
<form action={action}>
|
||||||
|
<button type="submit" disabled={pending}>
|
||||||
|
Export my notes to S3
|
||||||
|
</button>
|
||||||
|
{result?.ok && (
|
||||||
|
<p>
|
||||||
|
Exported {result.size} bytes to <code>{result.key}</code>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{result && !result.ok && <p role="alert">{result.message}</p>}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const publishNoteCreated = vi.fn<(note: { id: string; title: string }) => Promise<void>>(async () => undefined)
|
||||||
|
const log = vi.fn<(...args: unknown[]) => Promise<void>>(async () => undefined)
|
||||||
|
const get = vi.fn<(id: string) => Promise<{ id: string; title: string; authorId: string } | undefined>>()
|
||||||
|
const loggerInfo = vi.fn()
|
||||||
|
const loggerRecordException = vi.fn()
|
||||||
|
let visitor = 'visitor-a'
|
||||||
|
|
||||||
|
vi.mock('@/shared/lib/optional', () => ({ publishNoteCreated: (note: { id: string; title: string }) => publishNoteCreated(note) }))
|
||||||
|
vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => visitor }))
|
||||||
|
vi.mock('@/entities/note', () => ({ noteRepository: () => ({ get }) }))
|
||||||
|
vi.mock('@/shared/api', () => ({
|
||||||
|
db: () => ({}),
|
||||||
|
audit: () => ({ log }),
|
||||||
|
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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
await announceNote(String(form.get('id') ?? ''))
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { AnnounceNoteButton } from './ui/AnnounceNoteButton'
|
||||||
|
export { announceNote } from './api/actions'
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { AnnounceNoteButton } from '@/features/publish-note-event/ui/AnnounceNoteButton'
|
||||||
|
|
||||||
|
// The note list rendered this button unconditionally while a nearby card read
|
||||||
|
// "Link NATS to enable this" (stage sweep, 2026-10-11): clicking it with NATS
|
||||||
|
// unlinked submitted a form whose action is a no-op (`publishNoteCreated` returns
|
||||||
|
// early without a connection). The button must agree with the module panel's own
|
||||||
|
// `nats` slot about whether the module is linked, consistent with the enable-card
|
||||||
|
// pattern used everywhere else in the template.
|
||||||
|
describe('AnnounceNoteButton', () => {
|
||||||
|
it('renders the live form when NATS is linked', () => {
|
||||||
|
const markup = renderToStaticMarkup(AnnounceNoteButton({ id: 'note-1', linked: true }))
|
||||||
|
expect(markup).toContain('<form')
|
||||||
|
expect(markup).toContain('Announce')
|
||||||
|
expect(markup).not.toContain('disabled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a disabled button with the enable-card explanation when NATS is not linked', () => {
|
||||||
|
const markup = renderToStaticMarkup(AnnounceNoteButton({ id: 'note-1', linked: false }))
|
||||||
|
expect(markup).not.toContain('<form')
|
||||||
|
expect(markup).toContain('disabled')
|
||||||
|
expect(markup).toContain('Link NATS to enable this.')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { announceFromForm } from '@/features/publish-note-event/api/actions'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Announce" on one of the visitor's notes: publishes its `note.created` event again.
|
||||||
|
* `linked` is the NATS module's own linked state (plan D13): the action is a no-op
|
||||||
|
* without NATS, so the button must not invite a click that does nothing — it renders
|
||||||
|
* disabled with the same "Link ... to enable this" explanation the enable-card gives
|
||||||
|
* the NATS slot in the module panel, rather than quietly succeeding at nothing.
|
||||||
|
*/
|
||||||
|
export function AnnounceNoteButton({ id, linked }: { id: string; linked: boolean }) {
|
||||||
|
if (!linked) {
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
<button type="button" disabled>
|
||||||
|
Announce
|
||||||
|
</button>{' '}
|
||||||
|
Link NATS to enable this.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<form action={announceFromForm}>
|
||||||
|
<input type="hidden" name="id" value={id} />
|
||||||
|
<button type="submit">Announce</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { UploadsLeft } from './ui/UploadsLeft'
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 <p>{left ?? 0} uploads left this minute</p>
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SearchNotes } from './ui/SearchNotes'
|
||||||
@@ -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<ReturnType<typeof similarNotes>> = query ? await similarNotes(query) : []
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<form method="get">
|
||||||
|
<label>
|
||||||
|
Find similar notes
|
||||||
|
<input name="q" defaultValue={query ?? ''} />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Search</button>
|
||||||
|
</form>
|
||||||
|
{hits && hits.length > 0 ? (
|
||||||
|
<ol>
|
||||||
|
{hits.map((hit) => (
|
||||||
|
<li key={hit.id}>
|
||||||
|
{hit.payload.title} ({hit.score.toFixed(2)})
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { SimulateTrafficButton } from './ui/SimulateTrafficButton'
|
||||||
|
export { runJourney } from './model/journey'
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<Observe, 'track' | 'captureException'>): void {
|
||||||
|
observe.track('page_view', { path: '/' })
|
||||||
|
observe.track('journey_started', { steps: 3 })
|
||||||
|
observe.captureException(new Error('simulated journey failure'), { tags: { journey: 'simulate-traffic' } })
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<button type="button" onClick={() => runJourney(observe)}>
|
||||||
|
Simulate a user journey
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, unknown>[],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fakeDb = {
|
||||||
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => (mocks.note ? [mocks.note] : []) }) }) }),
|
||||||
|
insert: () => ({
|
||||||
|
values: (row: Record<string, unknown>) => ({
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { uploadFile } from './api/actions'
|
||||||
|
export { UploadFileForm } from './ui/UploadFileForm'
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { uploadFile } from '@/features/upload-file/api/actions'
|
||||||
|
|
||||||
|
export function UploadFileForm({ noteId }: { noteId: string }) {
|
||||||
|
return (
|
||||||
|
<form action={uploadFile.bind(null, noteId)}>
|
||||||
|
<label>
|
||||||
|
Attachment
|
||||||
|
<input name="file" type="file" required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Attach</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './server'
|
||||||
@@ -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<typeof events>
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './server'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './server'
|
||||||
@@ -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<typeof drizzle>
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './server'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './server'
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// The NATS client module is loaded fresh per test: its memoized connection is
|
||||||
|
// module state. `connect` is scripted per test.
|
||||||
|
const mocks = vi.hoisted(() => ({ connect: vi.fn() }))
|
||||||
|
|
||||||
|
vi.mock('nats', () => ({ connect: mocks.connect }))
|
||||||
|
vi.mock('@/shared/config/env', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/shared/config/env')>('@/shared/config/env')
|
||||||
|
return { ...actual, getEnv: () => ({ NATS_URL: 'nats://nats.example.test:4222', NATS_TOKEN: undefined }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function freshNats() {
|
||||||
|
vi.resetModules()
|
||||||
|
const client = await import('@/shared/api/nats/server')
|
||||||
|
return client.nats
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('nats', () => {
|
||||||
|
beforeEach(() => mocks.connect.mockReset())
|
||||||
|
|
||||||
|
it('retries the connection after a failed connect instead of serving the rejection forever', async () => {
|
||||||
|
mocks.connect.mockRejectedValueOnce(new Error('nats down')).mockResolvedValueOnce({ closed: false })
|
||||||
|
const nats = await freshNats()
|
||||||
|
|
||||||
|
await expect(nats()).rejects.toThrow('nats down')
|
||||||
|
// The rejection must not be memoized: the next call connects again.
|
||||||
|
await expect(nats()).resolves.toEqual({ closed: false })
|
||||||
|
expect(mocks.connect).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('memoizes a successful connection', async () => {
|
||||||
|
mocks.connect.mockResolvedValue({ closed: false })
|
||||||
|
const nats = await freshNats()
|
||||||
|
|
||||||
|
await expect(nats()).resolves.toEqual({ closed: false })
|
||||||
|
expect(nats()).toBe(nats())
|
||||||
|
expect(mocks.connect).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { connect, type NatsConnection } from 'nats'
|
||||||
|
import { getEnv, modules } from '@/shared/config/env'
|
||||||
|
|
||||||
|
let connection: Promise<NatsConnection> | undefined
|
||||||
|
|
||||||
|
/** The NATS connection, or `null` when the NATS module is not linked (D13). */
|
||||||
|
export function nats(): Promise<NatsConnection> | 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
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { logger, serverErrors, withRequest } from './server'
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { createServer, type IncomingMessage, type Server } from 'node:http'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// logger() wraps observe-next's getLogger() (showcase plan D23): undefined until register()
|
||||||
|
// starts the OTLP exporters, which only happens when the platform injects OTEL env.
|
||||||
|
describe('logger', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
vi.resetModules()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is undefined in local dev, without OTEL_EXPORTER_OTLP_ENDPOINT', async () => {
|
||||||
|
vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', '')
|
||||||
|
vi.resetModules()
|
||||||
|
const { logger } = await import('@/shared/api/observe/server')
|
||||||
|
expect(logger()).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the logger of the OTLP exporters register() started, once OTEL_EXPORTER_OTLP_ENDPOINT is set', async () => {
|
||||||
|
vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', 'https://app.example.com/api/otel')
|
||||||
|
vi.stubEnv('CONUSAI_API_URL', 'https://app.example.com/api')
|
||||||
|
vi.stubEnv('CONUSAI_API_TOKEN', 'dt_fixture')
|
||||||
|
vi.resetModules()
|
||||||
|
const { register } = await import('@conusai-sdk/observe-next')
|
||||||
|
const { logger } = await import('@/shared/api/observe/server')
|
||||||
|
|
||||||
|
const started = register({ dsn: 'https://pub123@errors.example.com/42', handlers: false })
|
||||||
|
|
||||||
|
expect(logger()).toBeDefined()
|
||||||
|
expect(logger()).toBe(started.otlp?.logger)
|
||||||
|
await started.otlp?.shutdown(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stage regression (deployment #41): register()'s gate and getLogger() both resolved fine, but
|
||||||
|
// nothing a server action logged ever reached the platform's /otel/v1/logs route -- no real fetch,
|
||||||
|
// no error, no retry, just silence. The two tests above would have stayed green through that
|
||||||
|
// regression: they only check that an Otlp instance exists, never that it sends anything. This
|
||||||
|
// test starts a real HTTP receiver (no mocked fetch, no faked timers) and proves a log a server
|
||||||
|
// action calls logger().info() with actually leaves the process and arrives within the exporter's
|
||||||
|
// 5 s batch interval, exactly as the "note.created, outcome: ok" log in create-note's action does.
|
||||||
|
describe('logger network delivery (D23 regression: a started logger must actually send)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
vi.resetModules()
|
||||||
|
})
|
||||||
|
|
||||||
|
it(
|
||||||
|
'a log emitted through the logger register() started reaches a real receiver within 5 s',
|
||||||
|
async () => {
|
||||||
|
const posts: Array<{ authorization: string | undefined; body: string }> = []
|
||||||
|
const server: Server = createServer((req: IncomingMessage, res) => {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
req.on('data', (chunk) => chunks.push(chunk))
|
||||||
|
req.on('end', () => {
|
||||||
|
posts.push({ authorization: req.headers.authorization, body: Buffer.concat(chunks).toString('utf8') })
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' }).end('{}')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||||
|
const { port } = server.address() as { port: number }
|
||||||
|
|
||||||
|
try {
|
||||||
|
vi.stubEnv('OTEL_EXPORTER_OTLP_ENDPOINT', `http://127.0.0.1:${port}/otel`)
|
||||||
|
vi.stubEnv('CONUSAI_API_URL', `http://127.0.0.1:${port}`)
|
||||||
|
vi.stubEnv('CONUSAI_API_TOKEN', 'dt_fixture')
|
||||||
|
vi.resetModules()
|
||||||
|
const { register } = await import('@conusai-sdk/observe-next')
|
||||||
|
const { logger } = await import('@/shared/api/observe/server')
|
||||||
|
|
||||||
|
const started = register({ dsn: 'https://pub123@errors.example.com/42', handlers: false })
|
||||||
|
expect(started.otlp?.enabled).toBe(true)
|
||||||
|
|
||||||
|
logger()?.info('note.created', { 'note.id': 'note-e2e-1', outcome: 'ok' })
|
||||||
|
|
||||||
|
// No fake timers: BatchExporter's own real setInterval (default 5 s, exporter.ts) must fire.
|
||||||
|
await vi.waitFor(() => expect(posts).toHaveLength(1), { timeout: 7000, interval: 100 })
|
||||||
|
const [post] = posts
|
||||||
|
if (!post) throw new Error('unreachable: waitFor already asserted posts.length === 1')
|
||||||
|
expect(post.authorization).toBe('Bearer dt_fixture')
|
||||||
|
const doc = JSON.parse(post.body)
|
||||||
|
const [record] = doc.resourceLogs[0].scopeLogs[0].logRecords
|
||||||
|
expect(record.body).toEqual({ stringValue: 'note.created' })
|
||||||
|
expect(record.attributes).toEqual(
|
||||||
|
expect.arrayContaining([{ key: 'outcome', value: { stringValue: 'ok' } }, { key: 'note.id', value: { stringValue: 'note-e2e-1' } }]),
|
||||||
|
)
|
||||||
|
|
||||||
|
await started.otlp?.shutdown(1000)
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
8000,
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user