ConusAI vendor 81941888f

This commit is contained in:
ConusAI vendor 81941888f
2026-10-07 08:47:51 +00:00
commit c3c6526952
135 changed files with 13010 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
import type { ReactNode } from 'react'
import { Observe } from '@/app/observability/observe'
/** 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',
}
export function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<Observe>{children}</Observe>
</body>
</html>
)
}
+13
View File
@@ -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>
)
}
+40
View File
@@ -0,0 +1,40 @@
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))
},
/** 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
},
}
}
+3
View File
@@ -0,0 +1,3 @@
export { files, type FileRow } from './model/schema'
export { MAX_UPLOAD_BYTES, ALLOWED_CONTENT_TYPES, uploadLimits, type UploadCandidate } from './model/limits'
export { fileRepository, type NewFile } from './api/repository'
+22
View File
@@ -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/)
})
})
+22
View File
@@ -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>
+19
View File
@@ -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
+68
View File
@@ -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()
})
})
+59
View File
@@ -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>
+3
View File
@@ -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'
+23
View File
@@ -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()
})
})
+12
View File
@@ -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()
+13
View File
@@ -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,71 @@
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(),
}
})
vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath }))
vi.mock('@/shared/api', () => ({
db: () => ({ insert: mocks.insert }),
audit: () => ({ log: mocks.log }),
serverErrors: () => ({ captureException: vi.fn() }),
}))
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()
})
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('/')
})
it('refuses an invalid title before any write or audit', async () => {
await expect(createNote(form({ title: ' ' }))).rejects.toThrow()
expect(mocks.insert).not.toHaveBeenCalled()
expect(mocks.log).not.toHaveBeenCalled()
})
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: '' })
})
})
+22
View File
@@ -0,0 +1,22 @@
'use server'
import { revalidatePath } from 'next/cache'
import { noteInput, noteRepository } from '@/entities/note'
import { audit, db, 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('/')
}
+2
View File
@@ -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,54 @@
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(),
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 }),
}))
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.removed.length = 0
})
it('removes the stored bytes and audits file.deleted once', 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 },
})
})
it('touches nothing when the visitor did not attach the file', async () => {
await deleteFile(FILE)
expect(mocks.del).not.toHaveBeenCalled()
expect(mocks.log).not.toHaveBeenCalled()
})
})
+18
View File
@@ -0,0 +1,18 @@
'use server'
import { revalidatePath } from 'next/cache'
import { fileRepository } from '@/entities/file'
import { noteId } from '@/entities/note'
import { audit, blob, db } 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('/')
}
+2
View File
@@ -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,42 @@
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(),
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 }) }))
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.removed.length = 0
})
it('audits note.deleted exactly once with the actor and the note', 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 },
})
})
it('audits nothing when the visitor did not write the note', async () => {
await deleteNote(NOTE)
expect(mocks.log).not.toHaveBeenCalled()
})
})
+17
View File
@@ -0,0 +1,17 @@
'use server'
import { revalidatePath } from 'next/cache'
import { noteId, noteRepository } from '@/entities/note'
import { audit } from '@/shared/api'
import { db } 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('/')
}
+2
View File
@@ -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,53 @@
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(),
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 }) }))
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.updated = undefined
})
it('audits note.updated exactly once with the actor and the note', 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 },
})
})
it('audits nothing when the visitor did not write the note', async () => {
await updateNote(NOTE, edit('Renamed'))
expect(mocks.log).not.toHaveBeenCalled()
})
})
+18
View File
@@ -0,0 +1,18 @@
'use server'
import { revalidatePath } from 'next/cache'
import { noteId, noteInput, noteRepository } from '@/entities/note'
import { audit } from '@/shared/api'
import { db } 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('/')
}
+2
View File
@@ -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,28 @@
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' }],
}))
vi.mock('@/shared/api', () => ({
db: () => ({
select: () => ({ from: () => ({ where: () => ({ orderBy: () => ({ limit: async () => mocks.notes }) }) }) }),
}),
}))
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()
})
it('writes the visitor’s own notes to S3 once', async () => {
await exportNotes()
expect(mocks.exportToS3).toHaveBeenCalledTimes(1)
expect(mocks.exportToS3).toHaveBeenCalledWith('visitor-1', mocks.notes)
})
})
+13
View File
@@ -0,0 +1,13 @@
'use server'
import { noteRepository } from '@/entities/note'
import { db } 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> {
const actor = await ensureVisitor()
const notes = await noteRepository(db()).listByAuthor(actor)
await exportToS3(actor, notes)
}
+2
View File
@@ -0,0 +1,2 @@
export { exportNotes } from './api/actions'
export { ExportNotesButton } from './ui/ExportNotesButton'
@@ -0,0 +1,9 @@
import { exportNotes } from '@/features/export-notes/api/actions'
export function ExportNotesButton() {
return (
<form action={exportNotes}>
<button type="submit">Export my notes to S3</button>
</form>
)
}
@@ -0,0 +1,43 @@
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>>()
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 }) }))
const { announceNote } = await import('@/features/publish-note-event/api/actions')
describe('announceNote', () => {
beforeEach(() => {
publishNoteCreated.mockClear()
log.mockClear()
get.mockReset()
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' } }))
})
it('refuses another visitor\'s note and publishes nothing', 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()
})
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()
})
})
@@ -0,0 +1,24 @@
'use server'
import { noteRepository } from '@/entities/note'
import { audit, db } 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> {
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 } })
}
/** 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') ?? ''))
}
+2
View File
@@ -0,0 +1,2 @@
export { AnnounceNoteButton } from './ui/AnnounceNoteButton'
export { announceNote } from './api/actions'
@@ -0,0 +1,11 @@
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 }) {
return (
<form action={announceFromForm}>
<input type="hidden" name="id" value={id} />
<button type="submit">Announce</button>
</form>
)
}
+1
View File
@@ -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>
}
+1
View File
@@ -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>
)
}
+2
View File
@@ -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,124 @@
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(),
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() }),
}))
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' }
})
it('refuses a file over the size limit before any upload', 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()
})
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', 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)
})
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' },
})
})
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)
})
})
+57
View File
@@ -0,0 +1,57 @@
'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, 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> {
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 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('/')
}
+2
View File
@@ -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>
)
}
+1
View File
@@ -0,0 +1 @@
export * from './server'
+36
View File
@@ -0,0 +1,36 @@
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' },
})
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
}
+1
View File
@@ -0,0 +1 @@
export * from './server'
+10
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
export * from './server'
+14
View File
@@ -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
}
+12
View File
@@ -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'
+1
View File
@@ -0,0 +1 @@
export * from './server'
+15
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
export * from './server'
+14
View File
@@ -0,0 +1,14 @@
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) connection = connect(env.NATS_TOKEN ? { servers, token: env.NATS_TOKEN } : { servers })
return connection
}
+1
View File
@@ -0,0 +1 @@
export { serverErrors, withRequest } from './server'
+19
View File
@@ -0,0 +1,19 @@
import 'server-only'
import { init, withRequest, type ErrorTrackingClient } from '@conusai-sdk/observe-node'
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 }
+1
View File
@@ -0,0 +1 @@
export * from './server'
+25
View File
@@ -0,0 +1,25 @@
import 'server-only'
import { getPlatformInfo, getServiceType, type ServiceType } from '@conusai-sdk/api'
import { getEnv } from '@/shared/config/env'
function auth() {
const env = getEnv()
return { baseUrl: env.CONUSAI_API_URL, headers: { Authorization: `Bearer ${env.CONUSAI_API_TOKEN}` } }
}
/** The platform's own description, read with this project's deployment token. */
export async function platformInfo() {
const { data } = await getPlatformInfo({ ...auth(), throwOnError: true })
if (!data) throw new Error('the platform returned no description')
return data
}
/**
* The catalog entry of a service type: the text an enable card shows, read from the
* platform rather than written here (plan D13).
*/
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
}
+1
View File
@@ -0,0 +1 @@
export * from './server'
+47
View File
@@ -0,0 +1,47 @@
import 'server-only'
/** What the Qdrant REST API needs from this app: where it is and how to authenticate. */
export type QdrantConfig = { url: string; apiKey?: string | undefined; collection: string }
/** One note in the vector index: its id, its text's vector and its title. */
export type QdrantPoint = { id: string; vector: number[]; payload: { title: string } }
/** Creates the collection if it is missing, with cosine distance at the vector's size. */
export async function ensureCollection(config: QdrantConfig, size: number, fetcher: typeof fetch = fetch): Promise<void> {
const response = await fetcher(`${config.url}/collections/${config.collection}`, {
method: 'PUT',
headers: qdrantHeaders(config),
body: JSON.stringify({ vectors: { size, distance: 'Cosine' } }),
})
// 409 means the collection already exists: that is the state we want.
if (!response.ok && response.status !== 409) throw new Error(`qdrant collection answered ${response.status}`)
}
export async function upsertPoints(config: QdrantConfig, points: QdrantPoint[], fetcher: typeof fetch = fetch): Promise<void> {
const response = await fetcher(`${config.url}/collections/${config.collection}/points?wait=true`, {
method: 'PUT',
headers: qdrantHeaders(config),
body: JSON.stringify({ points }),
})
if (!response.ok) throw new Error(`qdrant upsert answered ${response.status}`)
}
export async function searchPoints(
config: QdrantConfig,
vector: number[],
limit: number,
fetcher: typeof fetch = fetch,
): Promise<Array<{ id: string; score: number; payload: { title: string } }>> {
const response = await fetcher(`${config.url}/collections/${config.collection}/points/search`, {
method: 'POST',
headers: qdrantHeaders(config),
body: JSON.stringify({ vector, limit, with_payload: true }),
})
if (!response.ok) throw new Error(`qdrant search answered ${response.status}`)
const body = (await response.json()) as { result: Array<{ id: string; score: number; payload: { title: string } }> }
return body.result
}
function qdrantHeaders(config: QdrantConfig): Record<string, string> {
return config.apiKey ? { 'content-type': 'application/json', 'api-key': config.apiKey } : { 'content-type': 'application/json' }
}
+1
View File
@@ -0,0 +1 @@
export * from './server'
+23
View File
@@ -0,0 +1,23 @@
import 'server-only'
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { getEnv, modules } from '@/shared/config/env'
let client: S3Client | undefined
/** The dedicated S3 client, or `null` when the S3 module is not linked (D13). */
export function s3(): { client: S3Client; bucket: string } | null {
const env = getEnv()
if (!modules(env).s3 || !env.S3_BUCKET || !env.AWS_ENDPOINT_URL) return null
const credentials =
env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY
? { accessKeyId: env.AWS_ACCESS_KEY_ID, secretAccessKey: env.AWS_SECRET_ACCESS_KEY }
: undefined
client ??= new S3Client(
credentials
? { endpoint: env.AWS_ENDPOINT_URL, forcePathStyle: true, region: 'us-east-1', credentials }
: { endpoint: env.AWS_ENDPOINT_URL, forcePathStyle: true, region: 'us-east-1' },
)
return { client, bucket: env.S3_BUCKET }
}
export { PutObjectCommand }
+1
View File
@@ -0,0 +1 @@
export { recordUsage, type UsageEvent, type UsageResult } from './server'
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { recordUsage, type UsageEvent } from '@/shared/api/usage/server'
const config = { baseUrl: 'https://console.example.com', token: 'dt_fixture' }
const event: UsageEvent = {
idempotency_key: 'upload:f1',
customer: { external_id: 'showcase' },
meter: 'uploads',
quantity: '1',
}
describe('recordUsage', () => {
it('posts one batch to the token route with the token as a bearer credential', async () => {
const calls: { url: string; init: RequestInit }[] = []
const fetcher = (async (url: string, init: RequestInit) => {
calls.push({ url, init })
return new Response(JSON.stringify({ accepted: 1, duplicates: 0, rejected: [] }), { status: 202 })
}) as typeof fetch
const result = await recordUsage(config, [event], fetcher)
expect(result).toEqual({ accepted: 1, duplicates: 0, rejected: [] })
expect(calls).toHaveLength(1)
expect(calls[0]?.url).toBe('https://console.example.com/_conusai/usage/events')
expect(calls[0]?.init.method).toBe('POST')
expect(new Headers(calls[0]?.init.headers).get('authorization')).toBe('Bearer dt_fixture')
expect(JSON.parse(String(calls[0]?.init.body))).toEqual({ events: [event] })
})
it('treats anything but 202 as a failure, so the caller can resend the same key', async () => {
const fetcher = (async () => new Response('{}', { status: 403 })) as typeof fetch
await expect(recordUsage(config, [event], fetcher)).rejects.toThrow(/403/)
})
})
+36
View File
@@ -0,0 +1,36 @@
import 'server-only'
/** One usage event, as the platform's metering ingest reads it (plan D9). */
export type UsageEvent = {
idempotency_key: string
customer: { external_id: string }
meter: string
quantity: string
properties?: Record<string, unknown>
}
/** The ingest's answer: what was stored, what was already there, and what was refused. */
export type UsageResult = {
accepted: number
duplicates: number
rejected: unknown[]
}
/**
* Sends usage events with the deployment token (showcase S4.6, D9). A retry with the
* same idempotency key stores nothing twice, so a failed send can be sent again.
* Anything but 202 is an error: the platform answers 422 only when every event was refused.
*/
export async function recordUsage(
config: { baseUrl: string; token: string },
events: UsageEvent[],
fetcher: typeof fetch = fetch,
): Promise<UsageResult> {
const response = await fetcher(`${config.baseUrl}/_conusai/usage/events`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${config.token}` },
body: JSON.stringify({ events }),
})
if (response.status !== 202) throw new Error(`usage ingest answered ${response.status}`)
return (await response.json()) as UsageResult
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { modules, parseEnv } from '@/shared/config/env'
const required = {
CONUSAI_API_URL: 'https://console.example.com',
CONUSAI_API_TOKEN: 'dt_fixture',
CONUSAI_PROJECT_ID: '42',
CONUSAI_DSN: 'https://key@errors.example.com/1',
CONUSAI_RELEASE: 'abc123',
POSTGRES_URL: 'postgres://app@db.example.com/app',
}
describe('parseEnv', () => {
it('accepts the required contract and leaves every optional module off', () => {
const env = parseEnv(required)
expect(modules(env)).toEqual({ kv: false, nats: false, qdrant: false, git: false, s3: false })
})
it('names the missing required variable', () => {
const rest: Record<string, string | undefined> = { ...required }
delete rest.CONUSAI_API_TOKEN
expect(() => parseEnv(rest)).toThrow(/CONUSAI_API_TOKEN/)
})
it('lights a module when its variable is present', () => {
const env = parseEnv({ ...required, REDIS_URL: 'redis://cache.example.com:6379' })
expect(modules(env).kv).toBe(true)
})
it('lights the S3 module only when both its endpoint and bucket are present', () => {
expect(modules(parseEnv({ ...required, AWS_ENDPOINT_URL: 'https://s3.example.com' })).s3).toBe(false)
expect(modules(parseEnv({ ...required, AWS_ENDPOINT_URL: 'https://s3.example.com', S3_BUCKET: 'exports' })).s3).toBe(true)
})
})
+59
View File
@@ -0,0 +1,59 @@
import 'server-only'
import { z } from 'zod'
// The SDK environment contract (showcase D8): the names every deployment receives.
const required = z.object({
CONUSAI_API_URL: z.string().url(),
CONUSAI_API_TOKEN: z.string().min(1),
CONUSAI_PROJECT_ID: z.string().min(1),
CONUSAI_DSN: z.string().url(),
CONUSAI_RELEASE: z.string().min(1),
POSTGRES_URL: z.string().url(),
})
// Optional services (D13): each one lights a widget when its variables are present.
const optional = z.object({
REDIS_URL: z.string().url().optional(),
NATS_URL: z.string().optional(),
NATS_TOKEN: z.string().optional(),
QDRANT_URL: z.string().url().optional(),
QDRANT_API_KEY: z.string().optional(),
GIT_URL: z.string().url().optional(),
AWS_ENDPOINT_URL: z.string().url().optional(),
S3_BUCKET: z.string().min(1).optional(),
AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(),
})
const schema = required.merge(optional)
export type Env = z.infer<typeof schema>
/** Parse the environment. A missing required name throws with its name in the message. */
export function parseEnv(source: Record<string, string | undefined>): Env {
const result = schema.safeParse(source)
if (!result.success) {
const names = result.error.issues.map((issue) => issue.path.join('.')).join(', ')
throw new Error(`invalid environment: ${names}`)
}
return result.data
}
let parsed: Env | undefined
/** The environment, parsed once on first use. */
export function getEnv(): Env {
parsed ??= parseEnv(process.env)
return parsed
}
/** Which optional modules are linked, for the widgets that depend on them. */
export function modules(env: Env) {
return {
kv: Boolean(env.REDIS_URL),
nats: Boolean(env.NATS_URL),
qdrant: Boolean(env.QDRANT_URL),
git: Boolean(env.GIT_URL),
s3: Boolean(env.AWS_ENDPOINT_URL && env.S3_BUCKET),
} as const
}
+1
View File
@@ -0,0 +1 @@
export { getEnv, modules, parseEnv, type Env } from './env'
+64
View File
@@ -0,0 +1,64 @@
import { randomUUID } from 'node:crypto'
import { connect } from 'nats'
import { CreateBucketCommand, GetObjectCommand } from '@aws-sdk/client-s3'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
// The live check (plan S4.7 gap 4): NATS, Qdrant and S3 exercised against real
// services. It runs when their variables are present, the same names a deployment
// receives (D8, D13), and is skipped otherwise.
const live = Boolean(process.env.NATS_URL && process.env.QDRANT_URL && process.env.AWS_ENDPOINT_URL)
describe.runIf(live)('optional modules against live services', () => {
type Optional = typeof import('@/shared/lib/optional')
type Api = typeof import('@/shared/api')
let optional: Optional
let api: Api
beforeAll(async () => {
vi.stubEnv('S3_BUCKET', process.env.S3_BUCKET ?? 'showcase-live')
optional = await import('@/shared/lib/optional')
api = await import('@/shared/api')
const target = api.s3()
if (!target) throw new Error('S3 is not linked')
await target.client.send(new CreateBucketCommand({ Bucket: target.bucket })).catch((error: { name?: string }) => {
if (error.name !== 'BucketAlreadyOwnedByYou' && error.name !== 'BucketAlreadyExists') throw error
})
})
afterAll(() => vi.unstubAllEnvs())
it('publishes note.created on NATS and a subscriber receives it', async () => {
const subscriber = await connect({ servers: process.env.NATS_URL as string })
const sub = subscriber.subscribe('showcase.notes.created')
const id = randomUUID()
await optional.publishNoteCreated({ id, title: 'live note' })
await subscriber.flush()
const received = await Promise.race([
(async () => {
for await (const message of sub) return JSON.parse(new TextDecoder().decode(message.data)) as { id: string; title: string }
return undefined
})(),
new Promise((resolve) => setTimeout(() => resolve(undefined), 3000)),
])
await subscriber.close()
expect(received).toEqual({ id, title: 'live note' })
})
it('indexes a note in Qdrant and finds it as the top similarity hit', async () => {
const id = randomUUID()
const title = `budget ${id.slice(0, 8)}`
await optional.indexNote({ id, title, body: 'quarterly budget review with the finance team' })
const hits = await optional.similarNotes(`${title} quarterly budget review`, 1)
expect(hits?.[0]?.id).toBe(id)
})
it('writes an export object to the S3 bucket and reads it back', async () => {
const notes = [{ title: 'live export' }]
const key = await optional.exportToS3('live-visitor', notes)
expect(key).toMatch(/^exports\/live-visitor\/.+\.json$/)
const target = api.s3()
if (!target || !key) throw new Error('S3 is not linked')
const object = await target.client.send(new GetObjectCommand({ Bucket: target.bucket, Key: key }))
expect(JSON.parse(await object.Body!.transformToString())).toEqual(notes)
})
})
+81
View File
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// The optional-module helpers run against a fake KV store: the modules' clients are
// replaced, so each test asserts what the helper asked for.
const mocks = vi.hoisted(() => {
const store = new Map<string, number>()
return {
store,
kvClient: null as null | {
incr: (key: string) => Promise<number>
expire: (key: string, seconds: number) => Promise<number>
get: <T>(key: string) => Promise<T | null>
},
expire: vi.fn(async () => 1),
}
})
vi.mock('@/shared/api', () => ({
kv: () => mocks.kvClient,
nats: () => null,
s3: () => null,
ensureCollection: vi.fn(async () => {}),
upsertPoints: vi.fn(async () => {}),
searchPoints: vi.fn(async () => []),
}))
vi.mock('@/shared/config', () => ({
getEnv: () => ({ QDRANT_URL: undefined }),
}))
import { consumeUploadQuota, embed, UPLOADS_PER_MINUTE, uploadsLeft } from '@/shared/lib/optional'
function fakeKv() {
mocks.kvClient = {
incr: async (key: string) => {
const next = (mocks.store.get(key) ?? 0) + 1
mocks.store.set(key, next)
return next
},
expire: mocks.expire,
get: async <T>(key: string) => (mocks.store.has(key) ? (mocks.store.get(key) as T) : null),
}
}
describe('consumeUploadQuota', () => {
beforeEach(() => {
mocks.store.clear()
mocks.expire.mockClear()
fakeKv()
})
it('allows the uploads of the minute up to the limit, refuses the next, and sets the window once', async () => {
let last: { allowed: boolean; remaining: number } | null = null
for (let i = 0; i < UPLOADS_PER_MINUTE; i += 1) last = await consumeUploadQuota('visitor-1')
expect(last).toEqual({ allowed: true, remaining: 0 })
expect(await consumeUploadQuota('visitor-1')).toEqual({ allowed: false, remaining: 0 })
expect(mocks.expire).toHaveBeenCalledTimes(1)
})
it('counts each visitor separately', async () => {
await consumeUploadQuota('visitor-1')
expect(await uploadsLeft('visitor-2')).toBe(UPLOADS_PER_MINUTE)
})
it('is null, and counts nothing, when KV is not linked', async () => {
mocks.kvClient = null
expect(await consumeUploadQuota('visitor-1')).toBeNull()
expect(await uploadsLeft('visitor-1')).toBeNull()
})
})
describe('embed', () => {
const dot = (x: number[], y: number[]) => x.reduce((sum, value, i) => sum + value * (y[i] ?? 0), 0)
it('is stable for the same text, and orthogonal for text with no word in common', () => {
const a = embed('plan the launch')
expect(embed('plan the launch')).toEqual(a)
expect(dot(a, a)).toBeCloseTo(1)
expect(dot(a, embed('zebra quartz'))).toBeCloseTo(0)
})
})
+83
View File
@@ -0,0 +1,83 @@
import 'server-only'
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { ensureCollection, kv, nats, s3, searchPoints, upsertPoints, type QdrantConfig } from '@/shared/api'
import { getEnv } from '@/shared/config'
/** Uploads a visitor may make in one minute, when the KV module is linked (plan §4.4). */
export const UPLOADS_PER_MINUTE = 5
/**
* Counts one upload against the visitor's minute. `null` when KV is not linked: the
* upload then proceeds unlimited, because there is no store to count in.
*/
export async function consumeUploadQuota(visitor: string): Promise<{ allowed: boolean; remaining: number } | null> {
const store = kv()
if (!store) return null
const key = `uploads:${visitor}:${Math.floor(Date.now() / 60_000)}`
const count = await store.incr(key)
if (count === 1) await store.expire(key, 60)
return { allowed: count <= UPLOADS_PER_MINUTE, remaining: Math.max(0, UPLOADS_PER_MINUTE - count) }
}
/** The uploads a visitor has left this minute, read without counting one. `null` without KV. */
export async function uploadsLeft(visitor: string): Promise<number | null> {
const store = kv()
if (!store) return null
const key = `uploads:${visitor}:${Math.floor(Date.now() / 60_000)}`
const count = Number((await store.get<number>(key)) ?? 0)
return Math.max(0, UPLOADS_PER_MINUTE - count)
}
/** Publishes "a note was created" on NATS when it is linked; a no-op otherwise. */
export async function publishNoteCreated(note: { id: string; title: string }): Promise<void> {
const connection = nats()
if (!connection) return
(await connection).publish('showcase.notes.created', new TextEncoder().encode(JSON.stringify(note)))
}
/** The Qdrant config, or `null` when the Qdrant module is not linked. */
function qdrantConfig(): QdrantConfig | null {
const env = getEnv()
if (!env.QDRANT_URL) return null
return { url: env.QDRANT_URL, apiKey: env.QDRANT_API_KEY, collection: 'showcase_notes' }
}
/**
* A deterministic bag-of-words vector: each word hashes into one of 64 buckets,
* counts are normalised. No embedding service is needed, so the similarity search
* runs on any install that links Qdrant.
*/
export function embed(text: string, size = 64): number[] {
const vector = new Array<number>(size).fill(0)
for (const word of text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)) {
let hash = 0
for (const ch of word) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
vector[hash % size] = (vector[hash % size] ?? 0) + 1
}
const norm = Math.hypot(...vector) || 1
return vector.map((value) => value / norm)
}
export async function indexNote(note: { id: string; title: string; body: string }): Promise<void> {
const config = qdrantConfig()
if (!config) return
await ensureCollection(config, 64)
await upsertPoints(config, [{ id: note.id, vector: embed(`${note.title} ${note.body}`), payload: { title: note.title } }])
}
export async function similarNotes(text: string, limit = 5) {
const config = qdrantConfig()
if (!config) return null
return searchPoints(config, embed(text), limit)
}
/** Writes the visitor's notes as one JSON object to the S3 bucket; `null` when S3 is not linked. */
export async function exportToS3(visitor: string, notes: unknown): Promise<string | null> {
const target = s3()
if (!target) return null
const key = `exports/${visitor}/${new Date().toISOString()}.json`
await target.client.send(
new PutObjectCommand({ Bucket: target.bucket, Key: key, Body: JSON.stringify(notes), ContentType: 'application/json' }),
)
return key
}
+38
View File
@@ -0,0 +1,38 @@
import 'server-only'
import { cookies } from 'next/headers'
import { getEnv } from '@/shared/config/env'
import { VISITOR_COOKIE, newVisitorId, readVisitorId, signVisitorId } from '@/shared/lib/visitor'
// The HMAC key is the deployment token (D8): every container holds it and it
// never reaches the browser, so the cookie cannot be forged without it.
const ONE_YEAR = 60 * 60 * 24 * 365
/**
* The current visitor's id. A visitor with no valid cookie gets a new one, set
* on this response. Call it only from a server action or a route handler:
* Next allows cookies to be set there and not during rendering.
*/
export async function ensureVisitor(): Promise<string> {
const jar = await cookies()
const secret = getEnv().CONUSAI_API_TOKEN
const existing = readVisitorId(jar.get(VISITOR_COOKIE)?.value, secret)
if (existing) return existing
const id = newVisitorId()
jar.set(VISITOR_COOKIE, signVisitorId(id, secret), {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: ONE_YEAR,
})
return id
}
/**
* The current visitor's id, or `null` when there is none. Reads the cookie and
* never sets one, so it is safe during rendering.
*/
export async function currentVisitor(): Promise<string | null> {
const jar = await cookies()
return readVisitorId(jar.get(VISITOR_COOKIE)?.value, getEnv().CONUSAI_API_TOKEN)
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { newVisitorId, readVisitorId, signVisitorId } from '@/shared/lib/visitor'
const secret = 'fixture-secret'
describe('visitor cookie', () => {
it('reads back the id it signed', () => {
const id = newVisitorId()
expect(readVisitorId(signVisitorId(id, secret), secret)).toBe(id)
})
it('rejects a value signed with another secret', () => {
const id = newVisitorId()
expect(readVisitorId(signVisitorId(id, 'other'), secret)).toBeNull()
})
it('rejects a forged id under a valid signature', () => {
const signed = signVisitorId(newVisitorId(), secret)
const forged = `${newVisitorId()}${signed.slice(signed.lastIndexOf('.'))}`
expect(readVisitorId(forged, secret)).toBeNull()
})
it('treats an absent or malformed cookie as no visitor', () => {
expect(readVisitorId(undefined, secret)).toBeNull()
expect(readVisitorId('no-dot-here', secret)).toBeNull()
})
})
+30
View File
@@ -0,0 +1,30 @@
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
/** The cookie that identifies an anonymous visitor (showcase D16). */
export const VISITOR_COOKIE = 'sc_vid'
/** A new visitor id: random, opaque, and never derived from anything the visitor sent. */
export function newVisitorId(): string {
return randomUUID()
}
/** The cookie value for `id`: the id and an HMAC over it, so a forged id fails `readVisitorId`. */
export function signVisitorId(id: string, secret: string): string {
return `${id}.${mac(id, secret)}`
}
/** The visitor id in a cookie value, or `null` when the value is absent or its signature fails. */
export function readVisitorId(value: string | undefined, secret: string): string | null {
if (!value) return null
const dot = value.lastIndexOf('.')
if (dot <= 0) return null
const id = value.slice(0, dot)
const given = Buffer.from(value.slice(dot + 1))
const expected = Buffer.from(mac(id, secret))
if (given.length !== expected.length) return null
return timingSafeEqual(given, expected) ? id : null
}
function mac(id: string, secret: string): string {
return createHmac('sha256', secret).update(id).digest('base64url')
}
+1
View File
@@ -0,0 +1 @@
export { AuditPage } from './ui/AuditPage'
+11
View File
@@ -0,0 +1,11 @@
import { AuditTimeline } from '@/widgets/audit-timeline'
export function AuditPage() {
return (
<main>
<h1>Audit</h1>
<p>Every change this app made, as the platform recorded it.</p>
<AuditTimeline />
</main>
)
}
+1
View File
@@ -0,0 +1 @@
export { DashboardPage } from './ui/DashboardPage'
+25
View File
@@ -0,0 +1,25 @@
import { CreateNoteForm } from '@/features/create-note'
import { SimulateTrafficButton } from '@/features/simulate-traffic'
import { UsageMeter } from '@/widgets/usage-meter'
import { Attachments } from '@/widgets/attachments'
import { EnableCard } from '@/widgets/enable-card'
import { ModulePanel } from '@/widgets/module-panel'
import { NoteList } from '@/widgets/note-list'
export async function DashboardPage({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
const { q } = await searchParams
return (
<main>
<h1>ConusAI Showcase</h1>
<p>Notes with attachments, audited, observed and metered.</p>
<CreateNoteForm />
<SimulateTrafficButton />
<UsageMeter />
<NoteList extra={(noteId) => <Attachments noteId={noteId} />} />
<ModulePanel query={q} enable={(service) => <EnableCard service={service} />} />
<p>
<a href="/audit">Audit timeline</a> · <a href="/platform">Platform</a>
</p>
</main>
)
}
+1
View File
@@ -0,0 +1 @@
export { PlatformPage } from './ui/PlatformPage'
+28
View File
@@ -0,0 +1,28 @@
import { platformInfo } from '@/shared/api'
import { getEnv, modules } from '@/shared/config'
/**
* The platform's own description, read through the typed API (plan §4.4), and the
* optional services this deployment is linked to. The platform has no token-accessible
* listing of a project's services (D8), so the linked list is read from the variables
* the platform injected at deploy time.
*/
export async function PlatformPage() {
const info = await platformInfo()
const linked = modules(getEnv())
return (
<main>
<h1>Platform</h1>
<h2>Linked services</h2>
<ul>
{(Object.keys(linked) as Array<keyof typeof linked>).map((name) => (
<li key={name}>
{name}: {linked[name] ? 'linked' : 'not linked'}
</li>
))}
</ul>
<h2>Description</h2>
<pre>{JSON.stringify(info, null, 2)}</pre>
</main>
)
}
+1
View File
@@ -0,0 +1 @@
export { Attachments } from './ui/Attachments'
@@ -0,0 +1,24 @@
import { fileRepository } from '@/entities/file'
import { DeleteFileButton } from '@/features/delete-file'
import { UploadFileForm } from '@/features/upload-file'
import { db } from '@/shared/api'
/** The attachments on one note, with the controls to add and remove them. */
export async function Attachments({ noteId }: { noteId: string }) {
const attached = await fileRepository(db()).listByNote(noteId)
return (
<section>
<h3>Attachments</h3>
{attached.length === 0 ? <p>No attachments yet.</p> : null}
<ul>
{attached.map((file) => (
<li key={file.id}>
<a href={file.url}>{file.name}</a> ({file.size} bytes)
<DeleteFileButton id={file.id} />
</li>
))}
</ul>
<UploadFileForm noteId={noteId} />
</section>
)
}
+1
View File
@@ -0,0 +1 @@
export { AuditTimeline } from './ui/AuditTimeline'
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { timelineRows } from '@/widgets/audit-timeline/model/rows'
describe('timelineRows', () => {
it('keeps the platform order and names the target when it has one', () => {
const rows = timelineRows([
{
id: 2,
action: 'note.created',
outcome: 'success',
occurred_at: '2026-10-06T10:00:00Z',
resource_type: 'note',
resource_id: 'n-1',
},
{
id: 1,
action: 'file.uploaded',
outcome: 'success',
occurred_at: '2026-10-06T09:00:00Z',
resource_type: null,
resource_id: null,
},
])
expect(rows.map((row) => row.id)).toEqual([2, 1])
expect(rows[0]?.target).toBe('note n-1')
expect(rows[1]?.target).toBe('')
})
})
+21
View File
@@ -0,0 +1,21 @@
import type { TokenAuditEvent } from '@conusai-sdk/node-sdk'
/** One line of the timeline, as the page shows it. */
export type TimelineRow = {
id: number
when: string
action: string
outcome: string
target: string
}
/** The timeline rows for a page of events, in the order the platform returned them. */
export function timelineRows(items: TokenAuditEvent[]): TimelineRow[] {
return items.map((event) => ({
id: event.id,
when: event.occurred_at,
action: event.action,
outcome: event.outcome,
target: event.resource_type && event.resource_id ? `${event.resource_type} ${event.resource_id}` : '',
}))
}
@@ -0,0 +1,18 @@
import { readAuditEvents } from '@/shared/api'
import { timelineRows } from '@/widgets/audit-timeline/model/rows'
/** The project's audit events, read back from the platform: what the console's Audit Logs show. */
export async function AuditTimeline() {
const page = await readAuditEvents({ limit: 50 })
const rows = timelineRows(page.items)
if (rows.length === 0) return <p>No audit events yet.</p>
return (
<ol>
{rows.map((row) => (
<li key={row.id}>
<time dateTime={row.when}>{row.when}</time> {row.action} ({row.outcome}) {row.target}
</li>
))}
</ol>
)
}
+1
View File
@@ -0,0 +1 @@
export { EnableCard } from './ui/EnableCard'
@@ -0,0 +1,18 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/shared/api', () => ({
serviceCatalogEntry: vi.fn(async (service: string) => ({
display_name: `Catalog ${service}`,
description: 'Counts uploads per visitor.',
})),
}))
describe('EnableCard', () => {
it('renders the catalog text the platform returns for the service', async () => {
const { EnableCard } = await import('@/widgets/enable-card/ui/EnableCard')
const markup = renderToStaticMarkup(await EnableCard({ service: 'redis' }))
expect(markup).toContain('Link Catalog redis to enable this.')
expect(markup).toContain('Counts uploads per visitor.')
})
})
+16
View File
@@ -0,0 +1,16 @@
import type { ServiceType } from '@conusai-sdk/api'
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.
*/
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>
</aside>
)
}
+2
View File
@@ -0,0 +1,2 @@
export { ModulePanel } from './ui/ModulePanel'
export { moduleView, type ModuleName } from './model/view'
@@ -0,0 +1,58 @@
import { isValidElement, type ReactElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { ExportNotesButton } from '@/features/export-notes'
import { UploadsLeft } from '@/features/rate-limit'
import { SearchNotes } from '@/features/search-notes'
import { panelSlots } from '@/widgets/module-panel/model/slots'
import type { ModuleName } from '@/widgets/module-panel/model/view'
// The features' actions reach the shared API barrel; the slot mapping calls none of it.
vi.mock('@/shared/api', () => ({}))
const none: Record<ModuleName, boolean> = { kv: false, nats: false, qdrant: false, git: false, s3: false }
const enable = (service: string) => <i data-enable={service} />
function slotFor(name: ModuleName, linked: Record<ModuleName, boolean>) {
const slot = panelSlots({ linked, query: 'budget', gitHost: 'git.example.test', enable }).find((s) => s.name === name)
if (!slot) throw new Error(`no slot for ${name}`)
return slot.content
}
describe('panelSlots', () => {
it.each([
['kv', 'redis'],
['nats', 'nats'],
['qdrant', 'qdrant'],
['git', 'git'],
['s3', 's3'],
] as const)('renders the enable card for %s, naming %s, when its variables are absent', (name, service) => {
const content = slotFor(name, none) as ReactElement<{ 'data-enable': string }>
expect(isValidElement(content)).toBe(true)
expect(content.props['data-enable']).toBe(service)
})
it('renders the uploads counter for kv when linked', () => {
expect((slotFor('kv', { ...none, kv: true }) as ReactElement).type).toBe(UploadsLeft)
})
it('renders the similarity search for qdrant when linked, with the query', () => {
const content = slotFor('qdrant', { ...none, qdrant: true }) as ReactElement<{ query: string }>
expect(content.type).toBe(SearchNotes)
expect(content.props.query).toBe('budget')
})
it('renders the export button for s3 when linked', () => {
expect((slotFor('s3', { ...none, s3: true }) as ReactElement).type).toBe(ExportNotesButton)
})
it('names the git host the packages install from when git is linked', () => {
const content = slotFor('git', { ...none, git: true }) as ReactElement<{ children: unknown[] }>
expect(content.type).toBe('p')
expect(content.props.children).toContain('git.example.test')
})
it('renders no feature for a module whose variables are absent', () => {
const content = slotFor('kv', none) as ReactElement
expect(content.type).not.toBe(UploadsLeft)
})
})
+40
View File
@@ -0,0 +1,40 @@
import type { ReactNode } from 'react'
import { ExportNotesButton } from '@/features/export-notes'
import { UploadsLeft } from '@/features/rate-limit'
import { SearchNotes } from '@/features/search-notes'
import { moduleView, type ModuleName } from '@/widgets/module-panel/model/view'
/** The service an optional module is linked through, as the platform's catalog names it. */
export type ModuleService = 'redis' | 'nats' | 'qdrant' | 'git' | 's3'
export type ModuleSlot = { name: ModuleName; service: ModuleService; content: ReactNode }
/**
* The panel's slots, one per optional module (D13): the module's feature when its
* variables are present, the enable card the view supplies when they are not. A pure
* function, so a test can assert what each slot renders without a server.
*/
export function panelSlots({
linked,
query,
gitHost,
enable,
}: {
linked: Record<ModuleName, boolean>
query: string | undefined
gitHost: string
enable: (service: ModuleService) => ReactNode
}): ModuleSlot[] {
const features: Array<{ name: ModuleName; service: ModuleService; feature: ReactNode }> = [
{ name: 'kv', service: 'redis', 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 /> },
{ name: 'git', service: 'git', feature: <p>Packages install from {gitHost}.</p> },
]
return features.map((slot) => ({
name: slot.name,
service: slot.service,
content: moduleView(slot.name, linked) === 'feature' ? slot.feature : enable(slot.service),
}))
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { moduleView, type ModuleName } from '@/widgets/module-panel/model/view'
const names: ModuleName[] = ['kv', 'nats', 'qdrant', 'git', 's3']
const none = { kv: false, nats: false, qdrant: false, git: false, s3: false }
describe('moduleView', () => {
it.each(names)('shows the enable card for %s when its variables are absent', (name) => {
expect(moduleView(name, none)).toBe('enable')
})
it.each(names)('shows the feature for %s when its variables are present', (name) => {
expect(moduleView(name, { ...none, [name]: true })).toBe('feature')
})
})

Some files were not shown because too many files have changed in this diff Show More