ConusAI vendor v0.1.0-dev.20261013+85463d22

This commit is contained in:
ConusAI vendor v0.1.0-dev.20261013+85463d22
2026-10-13 20:45:57 +00:00
parent c3c6526952
commit 0da8a2a81b
67 changed files with 1510 additions and 10153 deletions
+232
View File
@@ -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;
}
}
+12
View File
@@ -1,11 +1,23 @@
import type { Metadata } from 'next'
import type { ReactNode } from 'react'
import { Observe } from '@/app/observability/observe'
import '../globals.css'
/** The document title every page inherits (axe: document-title). */
export const metadata: Metadata = {
title: 'ConusAI Showcase',
description: 'Notes, attachments, audit and usage on the ConusAI platform',
// `app/favicon.ico` and `app/icon.svg` already serve `/favicon.ico` and
// `/icon.svg` through Next's own file convention; `icons.icon` here is what
// makes the page's own <link> tag name `/favicon.svg` (served from
// `public/favicon.svg`) instead of `/icon.svg`, since both paths 404'd on
// every visit before this (stage sweep, 2026-10-11).
icons: {
icon: [
{ url: '/favicon.svg', type: 'image/svg+xml' },
{ url: '/favicon.ico', sizes: 'any' },
],
},
}
export function RootLayout({ children }: { children: ReactNode }) {
+10
View File
@@ -23,6 +23,16 @@ export function fileRepository(db: Db) {
return db.select().from(files).where(eq(files.noteId, noteId))
},
/** The attachment, or `undefined` when `authorId` did not attach it. */
async get(id: string, authorId: string): Promise<FileRow | undefined> {
const [row] = await db
.select()
.from(files)
.where(and(eq(files.id, id), eq(files.authorId, authorId)))
.limit(1)
return row
},
/** Every attachment the visitor made, across their notes. */
async listByAuthor(authorId: string): Promise<FileRow[]> {
return db.select().from(files).where(eq(files.authorId, authorId))
+1
View File
@@ -1,3 +1,4 @@
export { files, type FileRow } from './model/schema'
export { fileId } from './model/id'
export { MAX_UPLOAD_BYTES, ALLOWED_CONTENT_TYPES, uploadLimits, type UploadCandidate } from './model/limits'
export { fileRepository, type NewFile } from './api/repository'
+4
View File
@@ -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()
+12 -1
View File
@@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => {
publishNoteCreated: vi.fn(async () => {}),
indexNote: vi.fn(async () => {}),
revalidatePath: vi.fn(),
loggerInfo: vi.fn(),
loggerRecordException: vi.fn(),
}
})
@@ -22,6 +24,7 @@ vi.mock('@/shared/api', () => ({
db: () => ({ insert: mocks.insert }),
audit: () => ({ log: mocks.log }),
serverErrors: () => ({ captureException: vi.fn() }),
logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }),
}))
vi.mock('@/shared/lib/optional', () => ({
publishNoteCreated: mocks.publishNoteCreated,
@@ -43,6 +46,8 @@ describe('createNote', () => {
mocks.insert.mockClear()
mocks.log.mockClear()
mocks.revalidatePath.mockClear()
mocks.loggerInfo.mockClear()
mocks.loggerRecordException.mockClear()
})
it('writes the note under the visitor and audits exactly one note.created', async () => {
@@ -55,12 +60,18 @@ describe('createNote', () => {
target: { id: mocks.row.id },
})
expect(mocks.revalidatePath).toHaveBeenCalledWith('/')
expect(mocks.loggerInfo).toHaveBeenCalledTimes(1)
expect(mocks.loggerInfo).toHaveBeenCalledWith('note.created', { 'note.id': mocks.row.id, outcome: 'ok' })
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
})
it('refuses an invalid title before any write or audit', async () => {
it('refuses an invalid title before any write or audit, and logs the failure', async () => {
await expect(createNote(form({ title: ' ' }))).rejects.toThrow()
expect(mocks.insert).not.toHaveBeenCalled()
expect(mocks.log).not.toHaveBeenCalled()
expect(mocks.loggerInfo).not.toHaveBeenCalled()
expect(mocks.loggerRecordException).toHaveBeenCalledTimes(1)
expect(mocks.loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'note.created' })
})
it('announces and indexes the new note when the optional modules are linked', async () => {
+18 -12
View File
@@ -2,21 +2,27 @@
import { revalidatePath } from 'next/cache'
import { noteInput, noteRepository } from '@/entities/note'
import { audit, db, serverErrors } from '@/shared/api'
import { audit, db, logger, serverErrors } from '@/shared/api'
import { indexNote, publishNoteCreated } from '@/shared/lib/optional'
import { ensureVisitor } from '@/shared/lib/visitor-session'
/** Parse, write, audit, revalidate (plan §4.4): the shape every mutation follows. */
export async function createNote(form: FormData): Promise<void> {
const input = noteInput.parse({ title: form.get('title'), body: form.get('body') ?? '' })
const actor = await ensureVisitor()
const note = await noteRepository(db()).create({ ...input, authorId: actor })
await audit().log('note.created', { actor: { kind: 'human', id: actor }, target: { id: note.id } })
// Optional modules: NATS hears of the note and Qdrant indexes it when linked. A failure is
// reported, not raised: the note is saved.
await Promise.all([
publishNoteCreated({ id: note.id, title: note.title }),
indexNote({ id: note.id, title: note.title, body: note.body }),
]).catch((error: unknown) => serverErrors().captureException(error))
revalidatePath('/')
try {
const input = noteInput.parse({ title: form.get('title'), body: form.get('body') ?? '' })
const actor = await ensureVisitor()
const note = await noteRepository(db()).create({ ...input, authorId: actor })
await audit().log('note.created', { actor: { kind: 'human', id: actor }, target: { id: note.id } })
logger()?.info('note.created', { 'note.id': note.id, outcome: 'ok' })
// Optional modules: NATS hears of the note and Qdrant indexes it when linked. A failure is
// reported, not raised: the note is saved.
await Promise.all([
publishNoteCreated({ id: note.id, title: note.title }),
indexNote({ id: note.id, title: note.title, body: note.body }),
]).catch((error: unknown) => serverErrors().captureException(error))
revalidatePath('/')
} catch (error) {
logger()?.recordException(error, { 'conusai.action': 'note.created' })
throw error
}
}
+12 -2
View File
@@ -5,6 +5,8 @@ const mocks = vi.hoisted(() => ({
del: vi.fn(async () => {}),
log: vi.fn(async () => {}),
revalidatePath: vi.fn(),
loggerInfo: vi.fn(),
loggerRecordException: vi.fn(),
removed: [] as Array<{ id: string; url: string }>,
}))
@@ -21,6 +23,7 @@ vi.mock('@/shared/api', () => ({
db: () => fakeDb,
audit: () => ({ log: mocks.log }),
blob: () => ({ del: mocks.del }),
logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }),
}))
vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => 'visitor-1' }))
@@ -31,10 +34,12 @@ describe('deleteFile', () => {
mocks.del.mockClear()
mocks.log.mockClear()
mocks.revalidatePath.mockClear()
mocks.loggerInfo.mockClear()
mocks.loggerRecordException.mockClear()
mocks.removed.length = 0
})
it('removes the stored bytes and audits file.deleted once', async () => {
it('removes the stored bytes and audits file.deleted once, and logs it', async () => {
mocks.removed.push({ id: FILE, url: 'https://blob.example.com/notes/n/a.png' })
await deleteFile(FILE)
@@ -44,11 +49,16 @@ describe('deleteFile', () => {
actor: { kind: 'human', id: 'visitor-1' },
target: { id: FILE },
})
expect(mocks.loggerInfo).toHaveBeenCalledTimes(1)
expect(mocks.loggerInfo).toHaveBeenCalledWith('file.deleted', { 'file.id': FILE, outcome: 'ok' })
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
})
it('touches nothing when the visitor did not attach the file', async () => {
it('touches nothing when the visitor did not attach the file, and logs it denied', async () => {
await deleteFile(FILE)
expect(mocks.del).not.toHaveBeenCalled()
expect(mocks.log).not.toHaveBeenCalled()
expect(mocks.loggerInfo).toHaveBeenCalledWith('file.deleted', { 'file.id': FILE, outcome: 'denied' })
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
})
})
+17 -8
View File
@@ -3,16 +3,25 @@
import { revalidatePath } from 'next/cache'
import { fileRepository } from '@/entities/file'
import { noteId } from '@/entities/note'
import { audit, blob, db } from '@/shared/api'
import { audit, blob, db, logger } from '@/shared/api'
import { ensureVisitor } from '@/shared/lib/visitor-session'
/** Remove an attachment the visitor attached: its row first, then its stored bytes. */
export async function deleteFile(id: string): Promise<void> {
const key = noteId.parse(id)
const actor = await ensureVisitor()
const removed = await fileRepository(db()).remove(key, actor)
if (!removed) return
await blob().del(removed.url)
await audit().log('file.deleted', { actor: { kind: 'human', id: actor }, target: { id: key } })
revalidatePath('/')
try {
const key = noteId.parse(id)
const actor = await ensureVisitor()
const removed = await fileRepository(db()).remove(key, actor)
if (!removed) {
logger()?.info('file.deleted', { 'file.id': key, outcome: 'denied' })
return
}
await blob().del(removed.url)
await audit().log('file.deleted', { actor: { kind: 'human', id: actor }, target: { id: key } })
logger()?.info('file.deleted', { 'file.id': key, outcome: 'ok' })
revalidatePath('/')
} catch (error) {
logger()?.recordException(error, { 'conusai.action': 'file.deleted', 'file.id': id })
throw error
}
}
+16 -3
View File
@@ -4,6 +4,8 @@ const NOTE = '00000000-0000-4000-8000-0000000000aa'
const mocks = vi.hoisted(() => ({
log: vi.fn(async () => {}),
revalidatePath: vi.fn(),
loggerInfo: vi.fn(),
loggerRecordException: vi.fn(),
removed: [] as Array<{ id: string }>,
}))
@@ -12,7 +14,11 @@ const fakeDb = {
}
vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath }))
vi.mock('@/shared/api', () => ({ db: () => fakeDb, audit: () => ({ log: mocks.log }) }))
vi.mock('@/shared/api', () => ({
db: () => fakeDb,
audit: () => ({ log: mocks.log }),
logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }),
}))
vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => 'visitor-1' }))
import { deleteNote } from '@/features/delete-note/api/actions'
@@ -21,10 +27,12 @@ describe('deleteNote', () => {
beforeEach(() => {
mocks.log.mockClear()
mocks.revalidatePath.mockClear()
mocks.loggerInfo.mockClear()
mocks.loggerRecordException.mockClear()
mocks.removed.length = 0
})
it('audits note.deleted exactly once with the actor and the note', async () => {
it('audits note.deleted exactly once with the actor and the note, and logs it', async () => {
mocks.removed.push({ id: NOTE })
await deleteNote(NOTE)
@@ -33,10 +41,15 @@ describe('deleteNote', () => {
actor: { kind: 'human', id: 'visitor-1' },
target: { id: NOTE },
})
expect(mocks.loggerInfo).toHaveBeenCalledTimes(1)
expect(mocks.loggerInfo).toHaveBeenCalledWith('note.deleted', { 'note.id': NOTE, outcome: 'ok' })
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
})
it('audits nothing when the visitor did not write the note', async () => {
it('audits nothing when the visitor did not write the note, and logs it denied', async () => {
await deleteNote(NOTE)
expect(mocks.log).not.toHaveBeenCalled()
expect(mocks.loggerInfo).toHaveBeenCalledWith('note.deleted', { 'note.id': NOTE, outcome: 'denied' })
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
})
})
+16 -8
View File
@@ -2,16 +2,24 @@
import { revalidatePath } from 'next/cache'
import { noteId, noteRepository } from '@/entities/note'
import { audit } from '@/shared/api'
import { db } from '@/shared/api'
import { audit, db, logger } from '@/shared/api'
import { ensureVisitor } from '@/shared/lib/visitor-session'
/** Delete a note the visitor wrote. A note they did not write is left untouched. */
export async function deleteNote(id: string): Promise<void> {
const key = noteId.parse(id)
const actor = await ensureVisitor()
const removed = await noteRepository(db()).remove(key, actor)
if (!removed) return
await audit().log('note.deleted', { actor: { kind: 'human', id: actor }, target: { id: key } })
revalidatePath('/')
try {
const key = noteId.parse(id)
const actor = await ensureVisitor()
const removed = await noteRepository(db()).remove(key, actor)
if (!removed) {
logger()?.info('note.deleted', { 'note.id': key, outcome: 'denied' })
return
}
await audit().log('note.deleted', { actor: { kind: 'human', id: actor }, target: { id: key } })
logger()?.info('note.deleted', { 'note.id': key, outcome: 'ok' })
revalidatePath('/')
} catch (error) {
logger()?.recordException(error, { 'conusai.action': 'note.deleted', 'note.id': id })
throw error
}
}
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// The download route runs against fakes: a Blob Storage client whose download
// answers with known bytes, and a database whose attachment row is set per test.
const FILE = '00000000-0000-4000-8000-0000000000ff'
const mocks = vi.hoisted(() => ({
download: vi.fn(async () => new Response('sixteen bytes!!!')),
row: undefined as { id: string; name: string; contentType: string; url: string } | undefined,
visitor: 'visitor-1' as string | null,
}))
const fakeDb = {
select: () => ({ from: () => ({ where: () => ({ limit: async () => (mocks.row ? [mocks.row] : []) }) }) }),
}
vi.mock('@/shared/api', () => ({
db: () => fakeDb,
blob: () => ({ download: mocks.download }),
}))
vi.mock('@/shared/lib/visitor-session', () => ({ currentVisitor: async () => mocks.visitor }))
import { downloadAttachment, GET } from '@/features/download-file/api/route'
describe('downloadAttachment', () => {
beforeEach(() => {
mocks.download.mockClear()
mocks.visitor = 'visitor-1'
mocks.row = {
id: FILE,
name: 'notes.txt',
contentType: 'text/plain',
url: 'https://blob.example.com/api/blob/7/notes/n/notes.txt',
}
})
it('streams the bytes with the stored content type and an attachment disposition', async () => {
const response = await downloadAttachment(FILE)
expect(response.status).toBe(200)
expect(await response.text()).toBe('sixteen bytes!!!')
expect(response.headers.get('content-type')).toBe('text/plain')
expect(response.headers.get('content-disposition')).toBe("attachment; filename*=UTF-8''notes.txt")
expect(mocks.download).toHaveBeenCalledWith(mocks.row?.url)
})
it('answers 404 for a file row the visitor does not own', async () => {
mocks.row = undefined
const response = await downloadAttachment(FILE)
expect(response.status).toBe(404)
expect(mocks.download).not.toHaveBeenCalled()
})
it('answers 404 when there is no visitor cookie', async () => {
mocks.visitor = null
expect((await downloadAttachment(FILE)).status).toBe(404)
expect(mocks.download).not.toHaveBeenCalled()
})
it('answers 404 for an id that is not a UUID', async () => {
expect((await downloadAttachment('../../etc/passwd')).status).toBe(404)
expect(mocks.download).not.toHaveBeenCalled()
})
it('encodes a hostile file name into the disposition', async () => {
if (mocks.row) mocks.row.name = 'a"\r\nb.txt'
const response = await downloadAttachment(FILE)
expect(response.headers.get('content-disposition')).toBe("attachment; filename*=UTF-8''a%22%0D%0Ab.txt")
})
})
describe('GET', () => {
it('takes the id from the route params', async () => {
mocks.row = {
id: FILE,
name: 'notes.txt',
contentType: 'text/plain',
url: 'https://blob.example.com/api/blob/7/notes/n/notes.txt',
}
const response = await GET(new Request('http://localhost/files/x'), { params: Promise.resolve({ id: FILE }) })
expect(response.status).toBe(200)
})
})
+37
View File
@@ -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)
}
+1
View File
@@ -0,0 +1 @@
export { GET } from './api/route'
+24 -3
View File
@@ -4,6 +4,8 @@ const NOTE = '00000000-0000-4000-8000-0000000000aa'
const mocks = vi.hoisted(() => ({
log: vi.fn(async () => {}),
revalidatePath: vi.fn(),
loggerInfo: vi.fn(),
loggerRecordException: vi.fn(),
updated: undefined as { id: string } | undefined,
}))
@@ -16,7 +18,11 @@ const fakeDb = {
}
vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath }))
vi.mock('@/shared/api', () => ({ db: () => fakeDb, audit: () => ({ log: mocks.log }) }))
vi.mock('@/shared/api', () => ({
db: () => fakeDb,
audit: () => ({ log: mocks.log }),
logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }),
}))
vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => 'visitor-1' }))
import { updateNote } from '@/features/edit-note/api/actions'
@@ -32,10 +38,12 @@ describe('updateNote', () => {
beforeEach(() => {
mocks.log.mockClear()
mocks.revalidatePath.mockClear()
mocks.loggerInfo.mockClear()
mocks.loggerRecordException.mockClear()
mocks.updated = undefined
})
it('audits note.updated exactly once with the actor and the note', async () => {
it('audits note.updated exactly once with the actor and the note, and logs the outcome', async () => {
mocks.updated = { id: NOTE }
await updateNote(NOTE, edit('Renamed'))
@@ -44,10 +52,23 @@ describe('updateNote', () => {
actor: { kind: 'human', id: 'visitor-1' },
target: { id: NOTE },
})
expect(mocks.loggerInfo).toHaveBeenCalledTimes(1)
expect(mocks.loggerInfo).toHaveBeenCalledWith('note.updated', { 'note.id': NOTE, outcome: 'ok' })
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
})
it('audits nothing when the visitor did not write the note', async () => {
it('audits nothing when the visitor did not write the note, and logs it denied', async () => {
await updateNote(NOTE, edit('Renamed'))
expect(mocks.log).not.toHaveBeenCalled()
expect(mocks.loggerInfo).toHaveBeenCalledWith('note.updated', { 'note.id': NOTE, outcome: 'denied' })
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
})
it('logs the failure when the input is invalid', async () => {
await expect(updateNote(NOTE, edit(' '))).rejects.toThrow()
expect(mocks.log).not.toHaveBeenCalled()
expect(mocks.loggerInfo).not.toHaveBeenCalled()
expect(mocks.loggerRecordException).toHaveBeenCalledTimes(1)
expect(mocks.loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'note.updated', 'note.id': NOTE })
})
})
+17 -9
View File
@@ -2,17 +2,25 @@
import { revalidatePath } from 'next/cache'
import { noteId, noteInput, noteRepository } from '@/entities/note'
import { audit } from '@/shared/api'
import { db } from '@/shared/api'
import { audit, db, logger } from '@/shared/api'
import { ensureVisitor } from '@/shared/lib/visitor-session'
/** Edit a note the visitor wrote. A note they did not write is left untouched. */
export async function updateNote(id: string, form: FormData): Promise<void> {
const key = noteId.parse(id)
const input = noteInput.parse({ title: form.get('title'), body: form.get('body') ?? '' })
const actor = await ensureVisitor()
const note = await noteRepository(db()).update(key, actor, input)
if (!note) return
await audit().log('note.updated', { actor: { kind: 'human', id: actor }, target: { id: note.id } })
revalidatePath('/')
try {
const key = noteId.parse(id)
const input = noteInput.parse({ title: form.get('title'), body: form.get('body') ?? '' })
const actor = await ensureVisitor()
const note = await noteRepository(db()).update(key, actor, input)
if (!note) {
logger()?.info('note.updated', { 'note.id': key, outcome: 'denied' })
return
}
await audit().log('note.updated', { actor: { kind: 'human', id: actor }, target: { id: note.id } })
logger()?.info('note.updated', { 'note.id': note.id, outcome: 'ok' })
revalidatePath('/')
} catch (error) {
logger()?.recordException(error, { 'conusai.action': 'note.updated', 'note.id': id })
throw error
}
}
+37 -2
View File
@@ -3,12 +3,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
exportToS3: vi.fn(async () => 'exports/visitor-1/now.json' as string | null),
notes: [{ id: 'n-1', title: 'Plan' }],
loggerInfo: vi.fn(),
loggerRecordException: vi.fn(),
log: vi.fn(async () => {}),
captureException: vi.fn(),
}))
vi.mock('@/shared/api', () => ({
db: () => ({
select: () => ({ from: () => ({ where: () => ({ orderBy: () => ({ limit: async () => mocks.notes }) }) }) }),
}),
logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }),
audit: () => ({ log: mocks.log }),
serverErrors: () => ({ captureException: mocks.captureException }),
}))
vi.mock('@/shared/lib/optional', () => ({ exportToS3: mocks.exportToS3 }))
vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => 'visitor-1' }))
@@ -18,11 +25,39 @@ import { exportNotes } from '@/features/export-notes/api/actions'
describe('exportNotes', () => {
beforeEach(() => {
mocks.exportToS3.mockClear()
mocks.loggerInfo.mockClear()
mocks.loggerRecordException.mockClear()
mocks.log.mockClear()
mocks.captureException.mockClear()
})
it('writes the visitor’s own notes to S3 once', async () => {
await exportNotes()
it('writes the visitor’s own notes to S3 once, logs the outcome, and audits notes.exported with the key', async () => {
const result = await exportNotes(null, new FormData())
expect(mocks.exportToS3).toHaveBeenCalledTimes(1)
expect(mocks.exportToS3).toHaveBeenCalledWith('visitor-1', mocks.notes)
expect(result).toEqual({ ok: true, key: 'exports/visitor-1/now.json', size: expect.any(Number) })
expect(mocks.loggerInfo).toHaveBeenCalledWith('notes.exported', { 'actor.id': 'visitor-1', 'export.count': 1, outcome: 'ok' })
expect(mocks.log).toHaveBeenCalledWith('notes.exported', {
actor: { kind: 'human', id: 'visitor-1' },
target: { id: 'exports/visitor-1/now.json' },
})
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
})
it('reports S3 as not linked, logs it as skipped, and audits nothing, when exportToS3 returns null', async () => {
mocks.exportToS3.mockResolvedValueOnce(null)
const result = await exportNotes(null, new FormData())
expect(result).toEqual({ ok: false, message: 'S3 is not linked; nothing was exported.' })
expect(mocks.loggerInfo).toHaveBeenCalledWith('notes.exported', { 'actor.id': 'visitor-1', 'export.count': 1, outcome: 'skipped' })
expect(mocks.log).not.toHaveBeenCalled()
})
it('reports the error, logs and captures it, when the S3 write throws', async () => {
mocks.exportToS3.mockRejectedValueOnce(new Error('bucket unreachable'))
const result = await exportNotes(null, new FormData())
expect(result).toEqual({ ok: false, message: 'bucket unreachable' })
expect(mocks.loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'notes.exported', 'actor.id': 'visitor-1' })
expect(mocks.captureException).toHaveBeenCalledTimes(1)
expect(mocks.log).not.toHaveBeenCalled()
})
})
+24 -4
View File
@@ -1,13 +1,33 @@
'use server'
import { noteRepository } from '@/entities/note'
import { db } from '@/shared/api'
import { audit, db, logger, serverErrors } from '@/shared/api'
import { exportToS3 } from '@/shared/lib/optional'
import { ensureVisitor } from '@/shared/lib/visitor-session'
/** Writes the visitor's notes to the S3 bucket as one JSON object (S3 module). */
export async function exportNotes(): Promise<void> {
export type ExportResult = { ok: true; key: string; size: number } | { ok: false; message: string }
/**
* Writes the visitor's notes to the S3 bucket as one JSON object (S3 module).
*
* The caller (a `useActionState` form) needs the object key/size or the error to show
* the visitor, and the write needs an audit event like every other action here — before
* this, the button gave no feedback at all and nothing landed in the audit timeline
* (stage sweep, 2026-10-11). The `ExportResult | null` / `FormData` signature is
* `useActionState`'s action shape, not a plain server action.
*/
export async function exportNotes(_state: ExportResult | null, _form: FormData): Promise<ExportResult> {
const actor = await ensureVisitor()
const notes = await noteRepository(db()).listByAuthor(actor)
await exportToS3(actor, notes)
try {
const key = await exportToS3(actor, notes)
logger()?.info('notes.exported', { 'actor.id': actor, 'export.count': notes.length, outcome: key ? 'ok' : 'skipped' })
if (!key) return { ok: false, message: 'S3 is not linked; nothing was exported.' }
await audit().log('notes.exported', { actor: { kind: 'human', id: actor }, target: { id: key } })
return { ok: true, key, size: Buffer.byteLength(JSON.stringify(notes)) }
} catch (error) {
logger()?.recordException(error, { 'conusai.action': 'notes.exported', 'actor.id': actor })
serverErrors().captureException(error)
return { ok: false, message: error instanceof Error ? error.message : 'the export failed' }
}
}
@@ -0,0 +1,18 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { ExportNotesButton } from '@/features/export-notes/ui/ExportNotesButton'
// The button gave no feedback at all after submit: no message, no object key, and
// nothing in the audit timeline (stage sweep, 2026-10-11; the audit half is covered by
// exportNotes's own tests). This only checks the button's initial render — before any
// submission `useActionState`'s state is `null` — so neither the success nor the error
// branch has leaked into the markup by mistake.
describe('ExportNotesButton', () => {
it('renders the form with no result before any submission', () => {
const markup = renderToStaticMarkup(<ExportNotesButton />)
expect(markup).toContain('<form')
expect(markup).toContain('Export my notes to S3')
expect(markup).not.toContain('Exported')
expect(markup).not.toMatch(/role="alert"/)
})
})
@@ -1,9 +1,21 @@
import { exportNotes } from '@/features/export-notes/api/actions'
'use client'
import { useActionState } from 'react'
import { exportNotes, type ExportResult } from '@/features/export-notes/api/actions'
export function ExportNotesButton() {
const [result, action, pending] = useActionState<ExportResult | null, FormData>(exportNotes, null)
return (
<form action={exportNotes}>
<button type="submit">Export my notes to S3</button>
<form action={action}>
<button type="submit" disabled={pending}>
Export my notes to S3
</button>
{result?.ok && (
<p>
Exported {result.size} bytes to <code>{result.key}</code>.
</p>
)}
{result && !result.ok && <p role="alert">{result.message}</p>}
</form>
)
}
@@ -3,12 +3,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const publishNoteCreated = vi.fn<(note: { id: string; title: string }) => Promise<void>>(async () => undefined)
const log = vi.fn<(...args: unknown[]) => Promise<void>>(async () => undefined)
const get = vi.fn<(id: string) => Promise<{ id: string; title: string; authorId: string } | undefined>>()
const loggerInfo = vi.fn()
const loggerRecordException = vi.fn()
let visitor = 'visitor-a'
vi.mock('@/shared/lib/optional', () => ({ publishNoteCreated: (note: { id: string; title: string }) => publishNoteCreated(note) }))
vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => visitor }))
vi.mock('@/entities/note', () => ({ noteRepository: () => ({ get }) }))
vi.mock('@/shared/api', () => ({ db: () => ({}), audit: () => ({ log }) }))
vi.mock('@/shared/api', () => ({
db: () => ({}),
audit: () => ({ log }),
logger: () => ({ info: loggerInfo, recordException: loggerRecordException }),
}))
const { announceNote } = await import('@/features/publish-note-event/api/actions')
@@ -17,6 +23,8 @@ describe('announceNote', () => {
publishNoteCreated.mockClear()
log.mockClear()
get.mockReset()
loggerInfo.mockClear()
loggerRecordException.mockClear()
visitor = 'visitor-a'
})
@@ -26,18 +34,25 @@ describe('announceNote', () => {
expect(publishNoteCreated).toHaveBeenCalledTimes(1)
expect(publishNoteCreated).toHaveBeenCalledWith({ id: 'note-1', title: 'Budget' })
expect(log).toHaveBeenCalledWith('note.announced', expect.objectContaining({ target: { id: 'note-1' } }))
expect(loggerInfo).toHaveBeenCalledTimes(1)
expect(loggerInfo).toHaveBeenCalledWith('note.announced', { 'note.id': 'note-1', outcome: 'ok' })
expect(loggerRecordException).not.toHaveBeenCalled()
})
it('refuses another visitor\'s note and publishes nothing', async () => {
it('refuses another visitor\'s note and publishes nothing, and logs the refusal', async () => {
get.mockResolvedValue({ id: 'note-1', title: 'Budget', authorId: 'visitor-b' })
await expect(announceNote('note-1')).rejects.toThrow('only the author')
expect(publishNoteCreated).not.toHaveBeenCalled()
expect(log).not.toHaveBeenCalled()
expect(loggerInfo).not.toHaveBeenCalled()
expect(loggerRecordException).toHaveBeenCalledTimes(1)
expect(loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'note.announced', 'note.id': 'note-1' })
})
it('refuses a note that does not exist', async () => {
get.mockResolvedValue(undefined)
await expect(announceNote('missing')).rejects.toThrow('only the author')
expect(publishNoteCreated).not.toHaveBeenCalled()
expect(loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'note.announced', 'note.id': 'missing' })
})
})
+12 -6
View File
@@ -1,7 +1,7 @@
'use server'
import { noteRepository } from '@/entities/note'
import { audit, db } from '@/shared/api'
import { audit, db, logger } from '@/shared/api'
import { publishNoteCreated } from '@/shared/lib/optional'
import { ensureVisitor } from '@/shared/lib/visitor-session'
@@ -11,11 +11,17 @@ import { ensureVisitor } from '@/shared/lib/visitor-session'
* and no event. A no-op when NATS is not linked.
*/
export async function announceNote(noteId: string): Promise<void> {
const actor = await ensureVisitor()
const note = await noteRepository(db()).get(noteId)
if (!note || note.authorId !== actor) throw new Error('only the author can announce this note')
await publishNoteCreated({ id: note.id, title: note.title })
await audit().log('note.announced', { actor: { kind: 'human', id: actor }, target: { id: note.id } })
try {
const actor = await ensureVisitor()
const note = await noteRepository(db()).get(noteId)
if (!note || note.authorId !== actor) throw new Error('only the author can announce this note')
await publishNoteCreated({ id: note.id, title: note.title })
await audit().log('note.announced', { actor: { kind: 'human', id: actor }, target: { id: note.id } })
logger()?.info('note.announced', { 'note.id': note.id, outcome: 'ok' })
} catch (error) {
logger()?.recordException(error, { 'conusai.action': 'note.announced', 'note.id': noteId })
throw error
}
}
/** The form action behind the button: the note id travels as a hidden field. */
@@ -0,0 +1,25 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import { AnnounceNoteButton } from '@/features/publish-note-event/ui/AnnounceNoteButton'
// The note list rendered this button unconditionally while a nearby card read
// "Link NATS to enable this" (stage sweep, 2026-10-11): clicking it with NATS
// unlinked submitted a form whose action is a no-op (`publishNoteCreated` returns
// early without a connection). The button must agree with the module panel's own
// `nats` slot about whether the module is linked, consistent with the enable-card
// pattern used everywhere else in the template.
describe('AnnounceNoteButton', () => {
it('renders the live form when NATS is linked', () => {
const markup = renderToStaticMarkup(AnnounceNoteButton({ id: 'note-1', linked: true }))
expect(markup).toContain('<form')
expect(markup).toContain('Announce')
expect(markup).not.toContain('disabled')
})
it('renders a disabled button with the enable-card explanation when NATS is not linked', () => {
const markup = renderToStaticMarkup(AnnounceNoteButton({ id: 'note-1', linked: false }))
expect(markup).not.toContain('<form')
expect(markup).toContain('disabled')
expect(markup).toContain('Link NATS to enable this.')
})
})
@@ -1,7 +1,23 @@
import { announceFromForm } from '@/features/publish-note-event/api/actions'
/** "Announce" on one of the visitor's notes: publishes its `note.created` event again. */
export function AnnounceNoteButton({ id }: { id: string }) {
/**
* "Announce" on one of the visitor's notes: publishes its `note.created` event again.
* `linked` is the NATS module's own linked state (plan D13): the action is a no-op
* without NATS, so the button must not invite a click that does nothing — it renders
* disabled with the same "Link ... to enable this" explanation the enable-card gives
* the NATS slot in the module panel, rather than quietly succeeding at nothing.
*/
export function AnnounceNoteButton({ id, linked }: { id: string; linked: boolean }) {
if (!linked) {
return (
<p>
<button type="button" disabled>
Announce
</button>{' '}
Link NATS to enable this.
</p>
)
}
return (
<form action={announceFromForm}>
<input type="hidden" name="id" value={id} />
+18 -2
View File
@@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({
recordUsage: vi.fn(async () => ({ accepted: 1, duplicates: 0, rejected: [] })),
consumeUploadQuota: vi.fn(async () => null as { allowed: boolean; remaining: number } | null),
revalidatePath: vi.fn(),
loggerInfo: vi.fn(),
loggerRecordException: vi.fn(),
note: undefined as { id: string; authorId: string } | undefined,
inserted: [] as Record<string, unknown>[],
}))
@@ -32,6 +34,7 @@ vi.mock('@/shared/api', () => ({
blob: () => ({ put: mocks.put }),
recordUsage: mocks.recordUsage,
serverErrors: () => ({ captureException: vi.fn() }),
logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }),
}))
vi.mock('@/shared/config', () => ({
getEnv: () => ({ CONUSAI_API_URL: 'https://console.example.com', CONUSAI_API_TOKEN: 'dt_fixture' }),
@@ -56,13 +59,18 @@ describe('uploadFile', () => {
mocks.consumeUploadQuota.mockResolvedValue(null)
mocks.inserted.length = 0
mocks.note = { id: NOTE, authorId: 'visitor-1' }
mocks.loggerInfo.mockClear()
mocks.loggerRecordException.mockClear()
})
it('refuses a file over the size limit before any upload', async () => {
it('refuses a file over the size limit before any upload, and logs the failure', async () => {
const big = new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'big.png', { type: 'image/png' })
await expect(uploadFile(NOTE, attach(big))).rejects.toThrow(/5 MiB/)
expect(mocks.put).not.toHaveBeenCalled()
expect(mocks.log).not.toHaveBeenCalled()
expect(mocks.loggerInfo).not.toHaveBeenCalled()
expect(mocks.loggerRecordException).toHaveBeenCalledTimes(1)
expect(mocks.loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'file.uploaded', 'note.id': NOTE })
})
it('refuses a disallowed type before any upload', async () => {
@@ -71,12 +79,13 @@ describe('uploadFile', () => {
expect(mocks.put).not.toHaveBeenCalled()
})
it('refuses a note the visitor did not write, without uploading', async () => {
it('refuses a note the visitor did not write, without uploading or spending quota', async () => {
mocks.note = { id: NOTE, authorId: 'visitor-2' }
const png = new File(['png'], 'a.png', { type: 'image/png' })
await expect(uploadFile(NOTE, attach(png))).rejects.toThrow(/not yours/)
expect(mocks.put).not.toHaveBeenCalled()
expect(mocks.inserted).toHaveLength(0)
expect(mocks.consumeUploadQuota).not.toHaveBeenCalled()
})
it('stores the bytes, records the attachment, and audits file.uploaded once', async () => {
@@ -98,6 +107,13 @@ describe('uploadFile', () => {
actor: { kind: 'human', id: 'visitor-1' },
target: { id: '00000000-0000-4000-8000-0000000000ff' },
})
expect(mocks.loggerInfo).toHaveBeenCalledTimes(1)
expect(mocks.loggerInfo).toHaveBeenCalledWith('file.uploaded', {
'file.id': '00000000-0000-4000-8000-0000000000ff',
'note.id': NOTE,
outcome: 'ok',
})
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
})
it('sends exactly one usage event, keyed by the attachment, for one upload', async () => {
+47 -40
View File
@@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache'
import { fileRepository, uploadLimits } from '@/entities/file'
import { noteId, noteRepository } from '@/entities/note'
import { getEnv } from '@/shared/config'
import { audit, blob, db, recordUsage, serverErrors } from '@/shared/api'
import { audit, blob, db, logger, recordUsage, serverErrors } from '@/shared/api'
import { consumeUploadQuota } from '@/shared/lib/optional'
import { ensureVisitor } from '@/shared/lib/visitor-session'
@@ -13,45 +13,52 @@ import { ensureVisitor } from '@/shared/lib/visitor-session'
* upload, so a refused file never reaches Blob Storage.
*/
export async function uploadFile(note: string, form: FormData): Promise<void> {
const key = noteId.parse(note)
const upload = form.get('file')
if (!(upload instanceof File)) throw new Error('choose a file to upload')
const limits = uploadLimits.parse({
name: upload.name,
size: upload.size,
contentType: upload.type,
})
try {
const key = noteId.parse(note)
const upload = form.get('file')
if (!(upload instanceof File)) throw new Error('choose a file to upload')
const limits = uploadLimits.parse({
name: upload.name,
size: upload.size,
contentType: upload.type,
})
const actor = await ensureVisitor()
// With KV linked, each visitor has a per-minute upload allowance; without it, none.
const quota = await consumeUploadQuota(actor)
if (quota && !quota.allowed) throw new Error('the upload limit for this minute is reached')
const owner = await noteRepository(db()).get(key)
if (!owner || owner.authorId !== actor) throw new Error('that note is not yours to attach to')
const actor = await ensureVisitor()
const owner = await noteRepository(db()).get(key)
if (!owner || owner.authorId !== actor) throw new Error('that note is not yours to attach to')
// With KV linked, each visitor has a per-minute upload allowance; without it, none.
// Checked after ownership: an attempt on someone else's note spends no quota.
const quota = await consumeUploadQuota(actor)
if (quota && !quota.allowed) throw new Error('the upload limit for this minute is reached')
const stored = await blob().put(`notes/${key}/${limits.name}`, Buffer.from(await upload.arrayBuffer()), {
contentType: limits.contentType,
})
const row = await fileRepository(db()).create({
noteId: key,
authorId: actor,
name: limits.name,
contentType: limits.contentType,
size: limits.size,
url: stored.url,
})
await audit().log('file.uploaded', { actor: { kind: 'human', id: actor }, target: { id: row.id } })
// One upload, one usage event. The key is the attachment's id, so a resend stores nothing twice.
// A failed send is reported, not raised: the upload itself succeeded.
const env = getEnv()
await recordUsage({ baseUrl: env.CONUSAI_API_URL, token: env.CONUSAI_API_TOKEN }, [
{
idempotency_key: `upload:${row.id}`,
customer: { external_id: 'showcase' },
meter: 'uploads',
quantity: '1',
properties: { bytes: limits.size, content_type: limits.contentType },
},
]).catch((error: unknown) => serverErrors().captureException(error))
revalidatePath('/')
const stored = await blob().put(`notes/${key}/${limits.name}`, Buffer.from(await upload.arrayBuffer()), {
contentType: limits.contentType,
})
const row = await fileRepository(db()).create({
noteId: key,
authorId: actor,
name: limits.name,
contentType: limits.contentType,
size: limits.size,
url: stored.url,
})
await audit().log('file.uploaded', { actor: { kind: 'human', id: actor }, target: { id: row.id } })
logger()?.info('file.uploaded', { 'file.id': row.id, 'note.id': key, outcome: 'ok' })
// One upload, one usage event. The key is the attachment's id, so a resend stores nothing twice.
// A failed send is reported, not raised: the upload itself succeeded.
const env = getEnv()
await recordUsage({ baseUrl: env.CONUSAI_API_URL, token: env.CONUSAI_API_TOKEN }, [
{
idempotency_key: `upload:${row.id}`,
customer: { external_id: 'showcase' },
meter: 'uploads',
quantity: '1',
properties: { bytes: limits.size, content_type: limits.contentType },
},
]).catch((error: unknown) => serverErrors().captureException(error))
revalidatePath('/')
} catch (error) {
logger()?.recordException(error, { 'conusai.action': 'file.uploaded', 'note.id': note })
throw error
}
}
+1
View File
@@ -11,6 +11,7 @@ export const events = defineAuditEvents({
'note.announced': { target: 'note' },
'file.uploaded': { target: 'file' },
'file.deleted': { target: 'file' },
'notes.exported': { target: 'export' },
})
export type AppAuditClient = AuditClient<typeof events>
+40
View File
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// The NATS client module is loaded fresh per test: its memoized connection is
// module state. `connect` is scripted per test.
const mocks = vi.hoisted(() => ({ connect: vi.fn() }))
vi.mock('nats', () => ({ connect: mocks.connect }))
vi.mock('@/shared/config/env', async () => {
const actual = await vi.importActual<typeof import('@/shared/config/env')>('@/shared/config/env')
return { ...actual, getEnv: () => ({ NATS_URL: 'nats://nats.example.test:4222', NATS_TOKEN: undefined }) }
})
async function freshNats() {
vi.resetModules()
const client = await import('@/shared/api/nats/server')
return client.nats
}
describe('nats', () => {
beforeEach(() => mocks.connect.mockReset())
it('retries the connection after a failed connect instead of serving the rejection forever', async () => {
mocks.connect.mockRejectedValueOnce(new Error('nats down')).mockResolvedValueOnce({ closed: false })
const nats = await freshNats()
await expect(nats()).rejects.toThrow('nats down')
// The rejection must not be memoized: the next call connects again.
await expect(nats()).resolves.toEqual({ closed: false })
expect(mocks.connect).toHaveBeenCalledTimes(2)
})
it('memoizes a successful connection', async () => {
mocks.connect.mockResolvedValue({ closed: false })
const nats = await freshNats()
await expect(nats()).resolves.toEqual({ closed: false })
expect(nats()).toBe(nats())
expect(mocks.connect).toHaveBeenCalledTimes(1)
})
})
+9 -1
View File
@@ -9,6 +9,14 @@ export function nats(): Promise<NatsConnection> | null {
const env = getEnv()
const servers = env.NATS_URL
if (!modules(env).nats || !servers) return null
if (!connection) connection = connect(env.NATS_TOKEN ? { servers, token: env.NATS_TOKEN } : { servers })
if (!connection) {
const pending = connect(env.NATS_TOKEN ? { servers, token: env.NATS_TOKEN } : { servers })
connection = pending
// A failed connect must not wedge the module: once it settles rejected, the
// next call connects again. The caller still sees the original rejection.
pending.catch(() => {
if (connection === pending) connection = undefined
})
}
return connection
}
+1 -1
View File
@@ -1 +1 @@
export { serverErrors, withRequest } from './server'
export { logger, serverErrors, withRequest } from './server'
+95
View File
@@ -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,
)
})
+10
View File
@@ -1,5 +1,6 @@
import 'server-only'
import { init, withRequest, type ErrorTrackingClient } from '@conusai-sdk/observe-node'
import { getLogger, type OtlpLogger } from '@conusai-sdk/observe-next'
import { getEnv } from '@/shared/config/env'
let client: ErrorTrackingClient | undefined
@@ -17,3 +18,12 @@ export function serverErrors(): ErrorTrackingClient {
}
export { withRequest }
/**
* The structured OTLP logger `register()` started in instrumentation.ts (showcase plan D23) —
* `undefined` in local dev, where the platform does not inject `OTEL_EXPORTER_OTLP_ENDPOINT`.
* Every Server Action logs through this, so a call never throws on its own account.
*/
export function logger(): OtlpLogger | undefined {
return getLogger()
}
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/shared/config/env', () => ({
getEnv: () => ({
CONUSAI_API_URL: 'https://console.example.com',
CONUSAI_API_TOKEN: 'dt_fixture',
}),
}))
// The platform answering 403 (a deployment token the catalog routes reject)
// throws inside the SDK call when throwOnError is set.
vi.mock('@conusai-sdk/api', () => ({
getPlatformInfo: vi.fn(async () => {
throw Object.assign(new Error('forbidden'), { status: 403 })
}),
getServiceType: vi.fn(async () => {
throw Object.assign(new Error('forbidden'), { status: 403 })
}),
}))
describe('platform reads when the platform refuses them', () => {
it('serviceCatalogEntry returns null on a 403 instead of throwing during render', async () => {
const { serviceCatalogEntry } = await import('@/shared/api/platform/server')
await expect(serviceCatalogEntry('redis')).resolves.toBeNull()
})
it('platformInfo returns null on a 403 instead of throwing during render', async () => {
const { platformInfo } = await import('@/shared/api/platform/server')
await expect(platformInfo()).resolves.toBeNull()
})
it('logs the failure once per read, not once per request', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const { serviceCatalogEntry } = await import('@/shared/api/platform/server')
await serviceCatalogEntry('qdrant')
await serviceCatalogEntry('qdrant')
expect(warn).toHaveBeenCalledTimes(1)
warn.mockRestore()
})
})
+29 -8
View File
@@ -7,19 +7,40 @@ function auth() {
return { baseUrl: env.CONUSAI_API_URL, headers: { Authorization: `Bearer ${env.CONUSAI_API_TOKEN}` } }
}
/** The platform's own description, read with this project's deployment token. */
// Pages render on every request; a refused platform read is logged once per
// read, not once per request.
const warned = new Set<string>()
function warnOnce(what: string, error: unknown) {
if (warned.has(what)) return
warned.add(what)
console.warn(`showcase: ${what} unavailable, rendering without it:`, error)
}
/**
* The platform's own description, read with this project's deployment token.
* Null when the platform refuses or fails the read — the page renders without it.
*/
export async function platformInfo() {
const { data } = await getPlatformInfo({ ...auth(), throwOnError: true })
if (!data) throw new Error('the platform returned no description')
return data
try {
const { data } = await getPlatformInfo({ ...auth(), throwOnError: true })
return data ?? null
} catch (error) {
warnOnce('platform description', error)
return null
}
}
/**
* The catalog entry of a service type: the text an enable card shows, read from the
* platform rather than written here (plan D13).
* platform rather than written here (plan D13). Null when the platform refuses or
* fails the read — the card renders without the catalog details.
*/
export async function serviceCatalogEntry(serviceType: ServiceType) {
const { data } = await getServiceType({ ...auth(), path: { service_type: serviceType }, throwOnError: true })
if (!data) throw new Error(`the platform returned no catalog entry for ${serviceType}`)
return data
try {
const { data } = await getServiceType({ ...auth(), path: { service_type: serviceType }, throwOnError: true })
return data ?? null
} catch (error) {
warnOnce(`catalog entry for ${serviceType}`, error)
return null
}
}
@@ -0,0 +1,25 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
// The platform description read failed (401/403/5xx from the platform): the
// page must still render the linked-services list it owns.
vi.mock('@/shared/api', () => ({
platformInfo: vi.fn(async () => null),
}))
vi.mock('@/shared/config', () => ({
getEnv: () => ({}),
modules: () => ({ kv: true, nats: false, qdrant: false, git: false, s3: false }),
}))
describe('PlatformPage', () => {
it('renders the linked services and a placeholder when the description is unavailable', async () => {
const { PlatformPage } = await import('@/views/platform/ui/PlatformPage')
const markup = renderToStaticMarkup(await PlatformPage())
expect(markup).toContain('Linked services')
// Every module is listed, linked or not (React splits text nodes with
// comments in static markup, so match the elements, not the joined text).
expect(markup.match(/<li>/g)).toHaveLength(5)
expect(markup).toContain('not linked')
expect(markup).toContain('unavailable')
})
})
+7 -2
View File
@@ -5,7 +5,8 @@ import { getEnv, modules } from '@/shared/config'
* The platform's own description, read through the typed API (plan §4.4), and the
* optional services this deployment is linked to. The platform has no token-accessible
* listing of a project's services (D8), so the linked list is read from the variables
* the platform injected at deploy time.
* the platform injected at deploy time. When the description read fails the page
* renders the list it owns and a placeholder.
*/
export async function PlatformPage() {
const info = await platformInfo()
@@ -22,7 +23,11 @@ export async function PlatformPage() {
))}
</ul>
<h2>Description</h2>
<pre>{JSON.stringify(info, null, 2)}</pre>
{info ? (
<pre>{JSON.stringify(info, null, 2)}</pre>
) : (
<p>The platform description is unavailable.</p>
)}
</main>
)
}
+2 -1
View File
@@ -13,7 +13,8 @@ export async function Attachments({ noteId }: { noteId: string }) {
<ul>
{attached.map((file) => (
<li key={file.id}>
<a href={file.url}>{file.name}</a> ({file.size} bytes)
{/* Served by the app: the platform's blob URL needs the deployment token, which the browser does not have. */}
<a href={`/files/${file.id}`}>{file.name}</a> ({file.size} bytes)
<DeleteFileButton id={file.id} />
</li>
))}
@@ -1,5 +1,6 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { serviceCatalogEntry } from '@/shared/api'
vi.mock('@/shared/api', () => ({
serviceCatalogEntry: vi.fn(async (service: string) => ({
@@ -15,4 +16,12 @@ describe('EnableCard', () => {
expect(markup).toContain('Link Catalog redis to enable this.')
expect(markup).toContain('Counts uploads per visitor.')
})
it('renders the card without catalog details when the platform read fails', async () => {
vi.mocked(serviceCatalogEntry).mockResolvedValueOnce(null)
const { EnableCard } = await import('@/widgets/enable-card/ui/EnableCard')
const markup = renderToStaticMarkup(await EnableCard({ service: 'redis' }))
expect(markup).toContain('Link redis to enable this.')
expect(markup).not.toContain('Counts uploads per visitor.')
})
})
+4 -3
View File
@@ -3,14 +3,15 @@ import { serviceCatalogEntry } from '@/shared/api'
/**
* "Link <service> to enable": the text comes from the platform's service catalog
* (plan D13), so the card never carries copy of its own.
* (plan D13), so the card never carries copy of its own. When the platform read
* fails the card still renders, without the catalog details.
*/
export async function EnableCard({ service }: { service: ServiceType }) {
const entry = await serviceCatalogEntry(service)
return (
<aside>
<p>Link {entry.display_name} to enable this.</p>
<p>{entry.description}</p>
<p>Link {entry?.display_name ?? service} to enable this.</p>
{entry ? <p>{entry.description}</p> : null}
</aside>
)
}
@@ -20,7 +20,7 @@ function slotFor(name: ModuleName, linked: Record<ModuleName, boolean>) {
describe('panelSlots', () => {
it.each([
['kv', 'redis'],
['kv', 'kv'],
['nats', 'nats'],
['qdrant', 'qdrant'],
['git', 'git'],
+2 -2
View File
@@ -5,7 +5,7 @@ import { SearchNotes } from '@/features/search-notes'
import { moduleView, type ModuleName } from '@/widgets/module-panel/model/view'
/** The service an optional module is linked through, as the platform's catalog names it. */
export type ModuleService = 'redis' | 'nats' | 'qdrant' | 'git' | 's3'
export type ModuleService = 'kv' | 'nats' | 'qdrant' | 'git' | 's3'
export type ModuleSlot = { name: ModuleName; service: ModuleService; content: ReactNode }
@@ -26,7 +26,7 @@ export function panelSlots({
enable: (service: ModuleService) => ReactNode
}): ModuleSlot[] {
const features: Array<{ name: ModuleName; service: ModuleService; feature: ReactNode }> = [
{ name: 'kv', service: 'redis', feature: <UploadsLeft /> },
{ name: 'kv', service: 'kv', feature: <UploadsLeft /> },
{ name: 'nats', service: 'nats', feature: <p>New notes are announced live on NATS.</p> },
{ name: 'qdrant', service: 'qdrant', feature: <SearchNotes query={query} /> },
{ name: 's3', service: 's3', feature: <ExportNotesButton /> },
+5 -1
View File
@@ -4,6 +4,7 @@ import { AnnounceNoteButton } from '@/features/publish-note-event'
import { DeleteNoteButton } from '@/features/delete-note'
import { EditNoteForm } from '@/features/edit-note'
import { db } from '@/shared/api'
import { getEnv, modules } from '@/shared/config'
import { currentVisitor } from '@/shared/lib/visitor-session'
/**
@@ -15,6 +16,9 @@ export async function NoteList({ extra }: { extra?: (noteId: string) => ReactNod
if (!visitor) return <p>No notes yet.</p>
const notes = await noteRepository(db()).listByAuthor(visitor)
if (notes.length === 0) return <p>No notes yet.</p>
// The same linked state the module panel's `nats` slot reads (D13): the Announce
// button must agree with the panel about whether NATS is on.
const natsLinked = modules(getEnv()).nats
return (
<ul>
{notes.map((note) => (
@@ -22,7 +26,7 @@ export async function NoteList({ extra }: { extra?: (noteId: string) => ReactNod
<h2>{note.title}</h2>
<EditNoteForm note={note} />
<DeleteNoteButton id={note.id} />
<AnnounceNoteButton id={note.id} />
<AnnounceNoteButton id={note.id} linked={natsLinked} />
{extra?.(note.id)}
</li>
))}