feat(notes): harden import export readiness

This commit is contained in:
Saravana Achu Mac 2026-05-05 12:15:11 -07:00
parent 7eed0443a7
commit 09f30c003e
8 changed files with 453 additions and 36 deletions

View File

@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { buildNotesExportPayload, exportFilename, renderNotesMarkdownExport } from './export.js';
import type { NoteDoc } from './types.js';
function note(overrides: Partial<NoteDoc>): NoteDoc {
return {
id: 'note-1',
productId: 'notelett',
workspaceId: 'ws-1',
userId: 'user-1',
title: 'Launch Note',
body: '<p>Ship &amp; verify</p>',
status: 'active',
tags: ['beta', 'alpha'],
links: ['z', 'a'],
createdAt: '2026-05-01T00:00:00.000Z',
updatedAt: '2026-05-02T00:00:00.000Z',
createdBy: 'user-1',
updatedBy: 'user-1',
embedding: [0.1],
_etag: 'etag',
_ts: 1,
...overrides,
};
}
describe('notes export', () => {
it('builds deterministic metadata and sorted export notes without storage-only fields', () => {
const payload = buildNotesExportPayload({
productId: 'notelett',
userId: 'user-1',
workspaceId: 'ws-1',
exportedAt: '2026-05-05T00:00:00.000Z',
notes: [
note({ id: 'b-note', updatedAt: '2026-05-01T00:00:00.000Z', tags: ['z', 'a'] }),
note({ id: 'a-note', updatedAt: '2026-05-03T00:00:00.000Z', tags: ['beta', 'alpha'] }),
],
});
expect(payload).toMatchObject({
schemaVersion: 'notelett.notes.export.v1',
productId: 'notelett',
exportedAt: '2026-05-05T00:00:00.000Z',
scope: { userId: 'user-1', workspaceId: 'ws-1' },
metadata: {
noteCount: 2,
sort: 'workspaceId:asc,updatedAt:desc,id:asc',
importStatus: 'deferred',
},
});
expect(payload.notes.map((item) => item.id)).toEqual(['a-note', 'b-note']);
expect(payload.notes[0]).not.toHaveProperty('embedding');
expect(payload.notes[0].tags).toEqual(['alpha', 'beta']);
});
it('renders readable markdown with export metadata', () => {
const payload = buildNotesExportPayload({
productId: 'notelett',
userId: 'user-1',
exportedAt: '2026-05-05T00:00:00.000Z',
notes: [note({ title: '# Heading', body: '<p>Ship &amp; verify</p>' })],
});
const markdown = renderNotesMarkdownExport(payload);
expect(markdown).toContain('# NoteLett Notes Export');
expect(markdown).toContain('- Import: deferred');
expect(markdown).toContain('## \\# Heading');
expect(markdown).toContain('Ship & verify');
});
it('uses deterministic safe filenames', () => {
expect(exportFilename('json')).toBe('notelett-notes-all.json');
expect(exportFilename('markdown', 'Workspace 1 / Launch')).toBe('notelett-notes-Workspace-1-Launch.md');
});
});

View File

@ -0,0 +1,164 @@
import type { NoteDoc } from './types.js';
export type NotesExportFormat = 'json' | 'markdown';
export interface NotesExportPayload {
schemaVersion: 'notelett.notes.export.v1';
productId: string;
exportedAt: string;
scope: {
userId: string;
workspaceId: string | null;
};
metadata: {
noteCount: number;
sort: 'workspaceId:asc,updatedAt:desc,id:asc';
fields: readonly string[];
importStatus: 'deferred';
};
notes: ExportedNote[];
}
export interface ExportedNote {
id: string;
workspaceId: string;
title: string;
body: string;
status: NoteDoc['status'];
tags: string[];
links: string[];
sourceType: string | null;
sourceUri: string | null;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
}
const EXPORT_FIELDS = [
'id',
'workspaceId',
'title',
'body',
'status',
'tags',
'links',
'sourceType',
'sourceUri',
'createdAt',
'updatedAt',
'createdBy',
'updatedBy',
] as const;
export function buildNotesExportPayload(input: {
notes: NoteDoc[];
productId: string;
userId: string;
workspaceId?: string;
exportedAt?: string;
}): NotesExportPayload {
const notes = input.notes
.map(toExportedNote)
.sort((a, b) =>
a.workspaceId.localeCompare(b.workspaceId) ||
b.updatedAt.localeCompare(a.updatedAt) ||
a.id.localeCompare(b.id)
);
return {
schemaVersion: 'notelett.notes.export.v1',
productId: input.productId,
exportedAt: input.exportedAt ?? new Date().toISOString(),
scope: {
userId: input.userId,
workspaceId: input.workspaceId ?? null,
},
metadata: {
noteCount: notes.length,
sort: 'workspaceId:asc,updatedAt:desc,id:asc',
fields: EXPORT_FIELDS,
importStatus: 'deferred',
},
notes,
};
}
export function renderNotesMarkdownExport(payload: NotesExportPayload): string {
const lines = [
'# NoteLett Notes Export',
'',
`- Schema: ${payload.schemaVersion}`,
`- Product: ${payload.productId}`,
`- Exported at: ${payload.exportedAt}`,
`- Workspace: ${payload.scope.workspaceId ?? 'all'}`,
`- Notes: ${payload.metadata.noteCount}`,
`- Sort: ${payload.metadata.sort}`,
`- Import: ${payload.metadata.importStatus}`,
'',
];
for (const note of payload.notes) {
lines.push(
'---',
'',
`## ${escapeMarkdownHeading(note.title)}`,
'',
`- ID: ${note.id}`,
`- Workspace: ${note.workspaceId}`,
`- Status: ${note.status}`,
`- Tags: ${note.tags.length > 0 ? note.tags.join(', ') : 'none'}`,
`- Created: ${note.createdAt}`,
`- Updated: ${note.updatedAt}`,
'',
normalizeMarkdownBody(note.body),
'',
);
}
return `${lines.join('\n').trimEnd()}\n`;
}
export function exportFilename(format: NotesExportFormat, workspaceId?: string): string {
const suffix = workspaceId ? sanitizeFilenamePart(workspaceId) : 'all';
return `notelett-notes-${suffix}.${format === 'markdown' ? 'md' : 'json'}`;
}
function toExportedNote(note: NoteDoc): ExportedNote {
return {
id: note.id,
workspaceId: note.workspaceId,
title: note.title,
body: note.body,
status: note.status,
tags: [...note.tags].sort((a, b) => a.localeCompare(b)),
links: [...note.links].sort((a, b) => a.localeCompare(b)),
sourceType: note.sourceType ?? null,
sourceUri: note.sourceUri ?? null,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
createdBy: note.createdBy,
updatedBy: note.updatedBy,
};
}
function sanitizeFilenamePart(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'workspace';
}
function escapeMarkdownHeading(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/#/g, '\\#').trim();
}
function normalizeMarkdownBody(value: string): string {
return value
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/\n{3,}/g, '\n\n')
.trim();
}

View File

@ -24,6 +24,7 @@ beforeAll(async () => {
beforeEach(() => { beforeEach(() => {
resetMemoryDatastore(); resetMemoryDatastore();
extractAuthMock.mockResolvedValue({ sub: 'user_1', type: 'access', role: 'editor' });
}); });
afterAll(async () => { afterAll(async () => {
@ -152,16 +153,58 @@ describe('notes routes — integration', () => {
const res = await app.inject({ method: 'GET', url: '/api/notes/export' }); const res = await app.inject({ method: 'GET', url: '/api/notes/export' });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = res.json(); const body = res.json();
expect(res.headers['content-disposition']).toContain('notelett-notes-all.json');
expect(body.schemaVersion).toBe('notelett.notes.export.v1');
expect(body.scope).toEqual({ userId: 'user_1', workspaceId: null });
expect(body.metadata).toMatchObject({
noteCount: 1,
sort: 'workspaceId:asc,updatedAt:desc,id:asc',
importStatus: 'deferred',
});
expect(body.notes).toHaveLength(1); expect(body.notes).toHaveLength(1);
expect(body.exportedAt).toBeDefined(); expect(body.exportedAt).toBeDefined();
expect(body.notes[0]).not.toHaveProperty('embedding');
}); });
it('GET /notes/export?format=markdown returns markdown', async () => { it('GET /notes/export?format=markdown returns markdown', async () => {
await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); await app.inject({ method: 'POST', url: '/api/notes', payload: validNote });
const res = await app.inject({ method: 'GET', url: '/api/notes/export?format=markdown' }); const res = await app.inject({ method: 'GET', url: '/api/notes/export?format=markdown&workspaceId=ws-1' });
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('text/markdown'); expect(res.headers['content-type']).toContain('text/markdown');
expect(res.body).toContain('# Test Note'); expect(res.headers['content-disposition']).toContain('notelett-notes-ws-1.md');
expect(res.body).toContain('# NoteLett Notes Export');
expect(res.body).toContain('## Test Note');
expect(res.body).toContain('- Import: deferred');
});
it('GET /notes/export paginates all notes and keeps user/workspace scope', async () => {
for (let i = 0; i < 105; i += 1) {
await app.inject({
method: 'POST',
url: '/api/notes',
payload: {
...validNote,
id: `note-${i}`,
workspaceId: i % 2 === 0 ? 'ws-1' : 'ws-2',
title: `Note ${i}`,
},
});
}
extractAuthMock.mockResolvedValueOnce({ sub: 'user_2', type: 'access', role: 'editor' });
await app.inject({
method: 'POST',
url: '/api/notes',
payload: { ...validNote, id: 'other-user-note', title: 'Other User' },
});
const res = await app.inject({ method: 'GET', url: '/api/notes/export?workspaceId=ws-1' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.metadata.noteCount).toBe(53);
expect(body.scope).toEqual({ userId: 'user_1', workspaceId: 'ws-1' });
expect(body.notes.every((note: { workspaceId: string }) => note.workspaceId === 'ws-1')).toBe(true);
expect(body.notes.some((note: { id: string }) => note.id === 'other-user-note')).toBe(false);
}); });
it('GET /notes/export rejects invalid format', async () => { it('GET /notes/export rejects invalid format', async () => {

View File

@ -18,6 +18,7 @@ import * as versionRepo from '../note-versions/repository.js';
import { CreateNoteShareSchema } from '../note-shares/types.js'; import { CreateNoteShareSchema } from '../note-shares/types.js';
import { ListNoteVersionsQuerySchema } from '../note-versions/types.js'; import { ListNoteVersionsQuerySchema } from '../note-versions/types.js';
import type { NoteVersionDoc } from '../note-versions/types.js'; import type { NoteVersionDoc } from '../note-versions/types.js';
import { buildNotesExportPayload, exportFilename, renderNotesMarkdownExport } from './export.js';
import { CreateNoteSchema, ListNotesQuerySchema, UpdateNoteSchema, type NoteDoc } from './types.js'; import { CreateNoteSchema, ListNotesQuerySchema, UpdateNoteSchema, type NoteDoc } from './types.js';
type RouteApp = Omit<FastifyApp, 'setReadyState' | 'isReadyState'>; type RouteApp = Omit<FastifyApp, 'setReadyState' | 'isReadyState'>;
@ -42,6 +43,11 @@ const ChatBodySchema = z.object({
message: z.string().min(1).max(2000), message: z.string().min(1).max(2000),
}); });
const ExportNotesQuerySchema = z.object({
format: z.enum(['json', 'markdown']).default('json'),
workspaceId: z.string().min(1).max(128).optional(),
});
const NOTE_AI_RATE_LIMIT = { label: 'note AI routes', max: 30, windowMs: 10 * 60_000 }; const NOTE_AI_RATE_LIMIT = { label: 'note AI routes', max: 30, windowMs: 10 * 60_000 };
function assertNoteAiRateLimit(userId: string, route: string): void { function assertNoteAiRateLimit(userId: string, route: string): void {
@ -62,6 +68,25 @@ function toLexicalHits(items: NoteDoc[]) {
}); });
} }
async function listAllNotesForExport(
userId: string,
workspaceId?: string,
): Promise<NoteDoc[]> {
const limit = 100;
const items: NoteDoc[] = [];
let offset = 0;
let total = 0;
do {
const result = await repo.listNotes(userId, PRODUCT_ID, { workspaceId, limit, offset });
items.push(...result.items);
total = result.total;
offset += limit;
} while (items.length < total);
return items;
}
export async function noteRoutes(app: RouteApp) { export async function noteRoutes(app: RouteApp) {
app.get('/notes/search', async req => { app.get('/notes/search', async req => {
if (!isFeatureEnabled('notes.enabled')) { if (!isFeatureEnabled('notes.enabled')) {
@ -96,28 +121,30 @@ export async function noteRoutes(app: RouteApp) {
// Must be registered before GET /notes/:id or "export" is captured as :id. // Must be registered before GET /notes/:id or "export" is captured as :id.
app.get('/notes/export', async (req, reply) => { app.get('/notes/export', async (req, reply) => {
const auth = await extractAuth(req); const auth = await extractAuth(req);
const query = req.query as { format?: string; workspaceId?: string }; const parsed = ExportNotesQuerySchema.safeParse(req.query ?? {});
const format = query.format ?? 'json'; if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; '));
if (format !== 'json' && format !== 'markdown') {
throw new BadRequestError('format must be json or markdown');
} }
const listQuery = { workspaceId: query.workspaceId, limit: 100, offset: 0 }; const { format, workspaceId } = parsed.data;
const result = await repo.listNotes(auth.sub, PRODUCT_ID, listQuery); const notes = await listAllNotesForExport(auth.sub, workspaceId);
const payload = buildNotesExportPayload({
notes,
productId: PRODUCT_ID,
userId: auth.sub,
workspaceId,
});
const filename = exportFilename(format, workspaceId);
if (format === 'markdown') { if (format === 'markdown') {
const md = result.items
.map((n: NoteDoc) => `# ${n.title}\n\n${n.body}\n\n---\n`)
.join('\n');
reply.header('Content-Type', 'text/markdown'); reply.header('Content-Type', 'text/markdown');
reply.header('Content-Disposition', 'attachment; filename="notes-export.md"'); reply.header('Content-Disposition', `attachment; filename="${filename}"`);
return md; return renderNotesMarkdownExport(payload);
} }
reply.header('Content-Type', 'application/json'); reply.header('Content-Type', 'application/json');
reply.header('Content-Disposition', 'attachment; filename="notes-export.json"'); reply.header('Content-Disposition', `attachment; filename="${filename}"`);
return { exportedAt: new Date().toISOString(), notes: result.items }; return payload;
}); });
app.get('/notes/:id', async req => { app.get('/notes/:id', async req => {

View File

@ -0,0 +1,36 @@
# NoteLett Import And Export Readiness
Date: May 5, 2026
## Export Scope
Note export is production-ready for authenticated users through `GET /api/notes/export`.
Supported formats:
- `format=json` returns `notelett.notes.export.v1` with stable metadata, deterministic field order, sorted note records, and storage-only fields omitted.
- `format=markdown` returns readable Markdown with export metadata and one section per note.
Supported filters:
- all notes for the authenticated user
- notes scoped to a single `workspaceId`
The backend pages through the notes repository in batches of 100 so exports are not silently truncated at the API list limit. Exported records are scoped by authenticated user and `productId: notelett`.
## Import Deferral
Import is intentionally deferred for release 1 because the current product has encrypted note bodies, workspace membership boundaries, version history, agent action audit trails, and artifact references. Import needs an explicit conflict and ownership model before it can be safe.
Acceptance criteria before enabling import:
- Accept only `notelett.notes.export.v1` JSON.
- Validate every note with Zod before persistence.
- Require an authenticated writer and explicit target workspace ownership or membership.
- Never trust exported `createdBy`, `updatedBy`, `workspaceId`, or `userId` without remapping to the current account/workspace.
- Support dry-run validation that reports create/update/skip counts.
- Define conflict handling for duplicate note IDs, title collisions, tags, links, and source URIs.
- Write note versions and agent audit entries for imported updates.
- Keep field encryption enabled for imported body content.
- Add backend tests for malformed import, cross-user export replay, workspace remap, conflict decisions, and encrypted storage.
- Add web import UX only after dry-run and conflict behavior are available.

View File

@ -6,7 +6,7 @@ import { Suspense, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { CreateWorkspaceModal } from "@/components/CreateWorkspaceModal"; import { CreateWorkspaceModal } from "@/components/CreateWorkspaceModal";
import { Button } from "@/components/ui/Primitives"; import { Button } from "@/components/ui/Primitives";
import { exportNotes, listNoteSummaries, listWorkspaceSummaries, deleteWorkspace } from "@/lib/notes-client"; import { downloadNotesExport, exportNotes, listNoteSummaries, listWorkspaceSummaries, deleteWorkspace } from "@/lib/notes-client";
import { buildWorkspaceContextPackMarkdown } from "@/lib/context-pack"; import { buildWorkspaceContextPackMarkdown } from "@/lib/context-pack";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import type { NoteSummary, WorkspaceSummary } from "@/lib/types"; import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
@ -77,6 +77,15 @@ function WorkspacesPageInner() {
} }
} }
async function handleDownloadExport(format: "json" | "markdown", workspaceId?: string) {
try {
await downloadNotesExport(format, workspaceId);
toast.success(`${format === "markdown" ? "Markdown" : "JSON"} export downloaded`);
} catch (err) {
setError(err instanceof Error ? err.message : "Export failed");
}
}
async function handleDelete(id: string, name: string) { async function handleDelete(id: string, name: string) {
if (!confirm(`Delete workspace "${name}"? This cannot be undone.`)) return; if (!confirm(`Delete workspace "${name}"? This cannot be undone.`)) return;
try { try {
@ -150,24 +159,11 @@ function WorkspacesPageInner() {
> >
+ Workspace + Workspace
</Button> </Button>
<Button <Button variant="secondary" onClick={() => void handleDownloadExport("json")}>
variant="secondary" Export JSON
onClick={async () => { </Button>
try { <Button variant="secondary" onClick={() => void handleDownloadExport("markdown")}>
const data = await exportNotes("json"); Export Markdown
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "notes-export.json";
a.click();
URL.revokeObjectURL(url);
} catch (err) {
setError(err instanceof Error ? err.message : "Export failed");
}
}}
>
Export Notes
</Button> </Button>
</div> </div>
} }
@ -231,6 +227,22 @@ function WorkspacesPageInner() {
<Link href={`/workspaces?q=${encodeURIComponent(workspace.owner)}`} style={{ color: "var(--nl-text-secondary)" }}> <Link href={`/workspaces?q=${encodeURIComponent(workspace.owner)}`} style={{ color: "var(--nl-text-secondary)" }}>
Owner: {workspace.owner} Owner: {workspace.owner}
</Link> </Link>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void handleDownloadExport("json", workspace.id)}
>
Export JSON
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void handleDownloadExport("markdown", workspace.id)}
>
Export Markdown
</Button>
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"

View File

@ -13,7 +13,7 @@ vi.mock("@/lib/offline-queue", () => ({
getOfflineQueue: () => ({ enqueue: enqueueMock }), getOfflineQueue: () => ({ enqueue: enqueueMock }),
})); }));
import { createNote, getNoteDetail, updateNoteDetail } from "@/lib/notes-client"; import { createNote, downloadNotesExport, getNoteDetail, notesExportFilename, updateNoteDetail } from "@/lib/notes-client";
import { OFFLINE_QUEUE_MESSAGE } from "@/lib/mutation-retry"; import { OFFLINE_QUEUE_MESSAGE } from "@/lib/mutation-retry";
function setOnline(value: boolean) { function setOnline(value: boolean) {
@ -184,3 +184,40 @@ describe("retryable note mutations", () => {
}); });
}); });
}); });
describe("notes export downloads", () => {
beforeEach(() => {
fetchMock.mockReset();
enqueueMock.mockClear();
setOnline(true);
});
it("uses deterministic filenames for export downloads", () => {
expect(notesExportFilename("json")).toBe("notelett-notes-all.json");
expect(notesExportFilename("markdown", "Workspace 1 / Launch")).toBe("notelett-notes-Workspace-1-Launch.md");
});
it("downloads markdown exports with the backend scoped query", async () => {
fetchMock.mockResolvedValueOnce("# Export\n");
const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:export");
const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const click = vi.fn();
const createElement = vi.spyOn(document, "createElement").mockReturnValue({
href: "",
download: "",
click,
} as unknown as HTMLAnchorElement);
await downloadNotesExport("markdown", "ws-1");
expect(fetchMock).toHaveBeenCalledWith("/notes/export?format=markdown&workspaceId=ws-1");
expect(createObjectURL).toHaveBeenCalledOnce();
expect(createElement.mock.results[0]?.value.download).toBe("notelett-notes-ws-1.md");
expect(click).toHaveBeenCalledOnce();
expect(revokeObjectURL).toHaveBeenCalledWith("blob:export");
createObjectURL.mockRestore();
revokeObjectURL.mockRestore();
createElement.mockRestore();
});
});

View File

@ -448,6 +448,28 @@ export async function exportNotes(format: "json" | "markdown", workspaceId?: str
return typeof res === "string" ? res : JSON.stringify(res, null, 2); return typeof res === "string" ? res : JSON.stringify(res, null, 2);
} }
export function notesExportFilename(format: "json" | "markdown", workspaceId?: string): string {
const suffix = workspaceId ? sanitizeFilenamePart(workspaceId) : "all";
return `notelett-notes-${suffix}.${format === "markdown" ? "md" : "json"}`;
}
export async function downloadNotesExport(format: "json" | "markdown", workspaceId?: string): Promise<void> {
const data = await exportNotes(format, workspaceId);
const blob = new Blob([data], {
type: format === "markdown" ? "text/markdown" : "application/json",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = notesExportFilename(format, workspaceId);
link.click();
URL.revokeObjectURL(url);
}
function sanitizeFilenamePart(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "workspace";
}
export async function createWorkspace(input: { export async function createWorkspace(input: {
id: string; id: string;
name: string; name: string;