diff --git a/web/src/domain/validation.ts b/web/src/domain/validation.ts index 91675e7..348cdbc 100644 --- a/web/src/domain/validation.ts +++ b/web/src/domain/validation.ts @@ -1,4 +1,11 @@ import type { Note, NoteDocument, RichNode } from "./contracts"; +import { validateNoteTarget } from "../links/reference"; +export type DocumentPolicy = "plain" | "notes"; +export type DocumentInspection = { + document: NoteDocument; + nodeVisits: number; + jsonUnits: number; +}; const blocks = new Set([ "paragraph", "heading", @@ -13,6 +20,28 @@ const blocks = new Set([ "hardBreak", "text", ]); +function referencePath( + parent: string, + child: RichNode, + index: number, +): boolean { + if (parent === "paragraph" || parent === "heading") + return ["text", "hardBreak", "noteLink"].includes(child.type); + if (parent === "bulletList" || parent === "orderedList") + return child.type === "listItem"; + if (parent === "taskList") return child.type === "taskItem"; + if (parent === "listItem" || parent === "taskItem") + return index === 0 + ? child.type === "paragraph" + : blocks.has(child.type) && + !["text", "hardBreak", "listItem", "taskItem"].includes(child.type); + if (parent === "doc" || parent === "blockquote") + return ( + blocks.has(child.type) && + !["text", "hardBreak", "listItem", "taskItem"].includes(child.type) + ); + return false; +} export function safeHref(value: unknown): value is string { return ( typeof value === "string" && @@ -20,9 +49,20 @@ export function safeHref(value: unknown): value is string { !/[\u0000-\u0020\u007f]/.test(value) ); } -export function validateDocument(value: unknown): NoteDocument { +export function inspectNoteDocument( + value: unknown, + policy: DocumentPolicy, +): DocumentInspection { + if (policy !== "plain" && policy !== "notes") + throw new Error("invalid_document: Unsupported document policy."); let count = 0; - function node(v: unknown, depth: number): RichNode { + let references = 0; + function node( + v: unknown, + depth: number, + parent?: string, + validReferencePath = true, + ): RichNode { if (++count > 50000 || depth > 32 || !v || typeof v !== "object") throw new Error( "invalid_document: The document is too large or malformed.", @@ -30,9 +70,39 @@ export function validateDocument(value: unknown): NoteDocument { const n = v as RichNode; if ( typeof n.type !== "string" || - (depth === 0 ? n.type !== "doc" : !blocks.has(n.type)) + (depth === 0 + ? n.type !== "doc" + : !blocks.has(n.type) && !(policy === "notes" && n.type === "noteLink")) ) throw new Error("invalid_document: Unsupported document node."); + if (n.type === "noteLink") { + if ( + !validReferencePath || + (parent !== "paragraph" && parent !== "heading") + ) + throw new Error( + "invalid_document: A note reference must be inline text content.", + ); + if (++references > 1000) + throw new Error("invalid_document: Too many note references."); + // A reference is an identity atom, never cached visible text, HTML, or + // another kind of link. PM attribute containers may have a null prototype. + if ( + Object.keys(n).length !== 2 || + !Object.hasOwn(n, "type") || + !Object.hasOwn(n, "attrs") || + !n.attrs || + typeof n.attrs !== "object" || + Array.isArray(n.attrs) || + Object.keys(n.attrs).length !== 2 || + !Object.hasOwn(n.attrs, "version") || + !Object.hasOwn(n.attrs, "target") || + n.attrs.version !== 1 + ) + throw new Error("invalid_document: Invalid note reference."); + validateNoteTarget(n.attrs.target); + return n; + } if ( n.text !== undefined && (typeof n.text !== "string" || n.text.length > 1000000) @@ -58,22 +128,42 @@ export function validateDocument(value: unknown): NoteDocument { if (n.content !== undefined) { if (!Array.isArray(n.content)) throw new Error("invalid_document: Invalid content."); - n.content.forEach((c) => node(c, depth + 1)); + n.content.forEach((c, index) => + node( + c, + depth + 1, + n.type, + validReferencePath && referencePath(n.type, c, index), + ), + ); } return n; } node(value, 0); if (!Array.isArray((value as NoteDocument).content)) throw new Error("invalid_document: Missing content."); - if (JSON.stringify(value).length > 2000000) + const jsonUnits = JSON.stringify(value).length; + if (jsonUnits > 2000000) throw new Error("invalid_document: Document exceeds 2 MB."); - return structuredClone(value as NoteDocument); + return { + document: structuredClone(value as NoteDocument), + nodeVisits: count, + jsonUnits, + }; } -export function validateInput(input: { +/** Generic documents, including Journal and legacy Notes, never admit references. */ +export function validateDocument(value: unknown): NoteDocument { + return inspectNoteDocument(value, "plain").document; +} +export function validateNoteDocument(value: unknown): NoteDocument { + return inspectNoteDocument(value, "notes").document; +} +type NoteInput = { title: string; document: NoteDocument; tags: string[]; -}): typeof input { +}; +function validateInputMetadata(input: NoteInput): void { if ( typeof input.title !== "string" || input.title.length > 500 || @@ -82,13 +172,25 @@ export function validateInput(input: { input.tags.some((t) => typeof t !== "string" || t.length > 100) ) throw new Error("invalid_note: Invalid title or tags."); +} +export function validateInput(input: NoteInput): NoteInput { + return inputForPolicy(input, "plain"); +} +export function validateNoteInput(input: NoteInput): NoteInput { + return inputForPolicy(input, "notes"); +} +function inputForPolicy(input: NoteInput, policy: DocumentPolicy): NoteInput { + validateInputMetadata(input); return { title: input.title, - document: validateDocument(input.document), + document: inspectNoteDocument(input.document, policy).document, tags: [...new Set(input.tags)], }; } -export function validateNotes(value: unknown): Note[] { +export function inspectNotes( + value: unknown, + policy: DocumentPolicy = "plain", +): Array<{ note: Note; nodeVisits: number; jsonUnits: number }> { if (!Array.isArray(value) || value.length > 10000) throw new Error("invalid_library: Invalid library."); const ids = new Set(); @@ -108,6 +210,18 @@ export function validateNotes(value: unknown): Note[] { ) throw new Error("invalid_library: Invalid note metadata."); ids.add(v.id); - return { ...v, ...validateInput(v) }; + validateInputMetadata(v); + const inspected = inspectNoteDocument(v.document, policy); + return { + note: { ...v, document: inspected.document, tags: [...new Set(v.tags)] }, + nodeVisits: inspected.nodeVisits, + jsonUnits: inspected.jsonUnits, + }; }); } +export function validateNotes( + value: unknown, + policy: DocumentPolicy = "plain", +): Note[] { + return inspectNotes(value, policy).map(({ note }) => note); +} diff --git a/web/src/editor/noteLink.ts b/web/src/editor/noteLink.ts new file mode 100644 index 0000000..3326db6 --- /dev/null +++ b/web/src/editor/noteLink.ts @@ -0,0 +1,55 @@ +import { Node, type NodeViewRenderer } from "@tiptap/core"; +import { validateNoteTarget } from "../links/reference"; + +export type NoteLinkOptions = { nodeView?: NodeViewRenderer }; + +/** Shared identity-only schema. The optional view changes presentation, not data. */ +const NoteLink = Node.create({ + name: "noteLink", + group: "inline", + inline: true, + atom: true, + selectable: true, + marks: "", + addOptions() { + return {}; + }, + addAttributes() { + return { + version: { + default: 1, + rendered: false, + validate(value: unknown) { + if (value !== 1) throw new Error("Invalid note reference version."); + }, + }, + target: { + isRequired: true, + rendered: false, + validate(value: unknown) { + validateNoteTarget(value); + }, + }, + }; + }, + // Untrusted HTML/attributes never confer a typed reference. Notes-only paste + // uses the separate bounded reference codec and actual editor admission. + parseHTML() { + return []; + }, + renderHTML() { + return ["span", { "data-type": "note-link" }, "Note reference"]; + }, + renderText() { + return "Note reference"; + }, + addNodeView() { + return this.options.nodeView ?? null; + }, +}); + +export function createNoteLinkExtension( + options: NoteLinkOptions = {}, +): Node { + return NoteLink.configure(options); +} diff --git a/web/src/editor/schema.ts b/web/src/editor/schema.ts new file mode 100644 index 0000000..b19062d --- /dev/null +++ b/web/src/editor/schema.ts @@ -0,0 +1,38 @@ +import { + getSchema, + type Extensions, + type NodeViewRenderer, +} from "@tiptap/core"; +import type { Schema } from "@tiptap/pm/model"; +import StarterKit from "@tiptap/starter-kit"; +import TaskList from "@tiptap/extension-task-list"; +import TaskItem from "@tiptap/extension-task-item"; +import { safeHref } from "../domain/validation"; +import { createNoteLinkExtension } from "./noteLink"; + +function allowedUri(value: string): boolean { + if (!safeHref(value)) return false; + try { + return ["http:", "https:", "mailto:"].includes(new URL(value).protocol); + } catch { + return false; + } +} + +/** Identical configuration for the real editor and headless persisted-step verification. */ +export function createNoteExtensions( + options: { noteLinkNodeView?: NodeViewRenderer } = {}, +): Extensions { + return [ + StarterKit.configure({ + trailingNode: false, + link: { openOnClick: false, autolink: false, isAllowedUri: allowedUri }, + }), + TaskList.configure({}), + TaskItem.configure({ nested: true }), + createNoteLinkExtension({ nodeView: options.noteLinkNodeView }), + ]; +} +export function createNoteSchema(): Schema { + return getSchema(createNoteExtensions()); +} diff --git a/web/src/links/contracts.ts b/web/src/links/contracts.ts new file mode 100644 index 0000000..e754efa --- /dev/null +++ b/web/src/links/contracts.ts @@ -0,0 +1,156 @@ +export type NoteTarget = Readonly<{ + kind: "note"; + accountId: string; + libraryEpoch: string; + noteId: string; +}>; +export type NoteLinkNode = { + type: "noteLink"; + attrs: { version: 1; target: NoteTarget }; +}; +export type NoteLinksRow = { + noteId: string; + noteRevision: number; + targets: { target: NoteTarget; occurrences: number }[]; +}; +export type LinkToken = { epoch: string; revision: number }; +export type LinkProgress = { + verifiedNotes: number; + totalNotes: number; + verifiedHistory: number; + totalHistory: number; + utf8Units: number; + nodeVisits: number; +}; +export type LinkNotReady = + | { status: "pending"; progress: LinkProgress } + | { status: "paused-limit"; progress: LinkProgress } + | { + status: "invalid"; + code: "index-mismatch" | "index-invalid" | "legacy-premise"; + }; +export type LinkQuery = + { status: "ready"; token: LinkToken; items: T[] } | LinkNotReady; +export type NoteLinkResolution = + | { + status: "available"; + token: LinkToken; + target: NoteTarget; + noteRevision: number; + title: string; + } + | { + status: "unavailable"; + reason: "missing" | "trashed" | "locked" | "scope"; + }; +export type Backlink = { + sourceId: string; + sourceRevision: number; + title: string; + occurrences: number; + snippet: string; +}; +export type OutgoingLink = { + target: NoteTarget; + occurrences: number; + resolution: NoteLinkResolution; +}; +export type LinkCandidate = { + target: NoteTarget; + title: string; + noteRevision: number; + updatedAt: string; + thisNote: boolean; +}; +declare const cursorBrand: unique symbol; +declare const repairBrand: unique symbol; +declare const exportBrand: unique symbol; +export type LinkSearchCursor = Readonly<{ [cursorBrand]: true }>; +export type LinkRepairIntent = Readonly<{ + operationId: string; + [repairBrand]: true; +}>; +export type LinkRepairResult = Readonly<{ + operationId: string; + committedToken: LinkToken; + indexDigest: string; + replayed: boolean; +}>; +export type NoteExportIntent = Readonly<{ [exportBrand]: true }>; +export type NoteExportFile = { filename: string; content: string }; +export type NoteExportResult = Readonly<{ + token: LinkToken; + files: readonly NoteExportFile[]; + warnings: readonly string[]; +}>; +export type OwnerAdmission = + | { + mode: "legacy"; + rawVersion: 1 | 2; + token: LinkToken; + premise: "pending" | "paused-limit" | "verified"; + } + | { + mode: "current"; + token: LinkToken; + graph: "pending" | "paused-limit" | "ready"; + } + | { + mode: "index-recovery"; + token: LinkToken; + reason: "index-invalid" | "index-mismatch"; + }; +export interface NoteLinkService { + admission(): Promise; + currentToken(): Promise; + search( + query: string, + sourceId?: string, + cursor?: LinkSearchCursor, + ): Promise<{ + token: LinkToken; + items: LinkCandidate[]; + next: LinkSearchCursor | null; + }>; + reference(noteId: string): Promise<{ token: LinkToken; target: NoteTarget }>; + resolve(target: NoteTarget): Promise; + outgoing(noteId: string): Promise>; + backlinks(noteId: string): Promise>; + linkedNoteIds(): Promise>; + verifyGraph(): Promise<{ status: "ready"; token: LinkToken } | LinkNotReady>; + cancelVerification(): void; + prepareRepair(expected: LinkToken): Promise; + commitRepair(intent: LinkRepairIntent): Promise; + prepareExport(noteIds: readonly string[]): Promise; + exportFiles(intent: NoteExportIntent): Promise; + assertFreshExport(result: NoteExportResult): Promise; + subscribe(listener: () => void): () => void; + onInvalidate(listener: (reason: "epoch" | "closed") => void): () => void; + close(): void; +} +export type NoteLinkErrorCode = + | "note_link_invalid" + | "note_link_limit" + | "note_link_revoked" + | "note_link_unavailable" + | "note_link_migration_pending" + | "note_link_index_invalid" + | "note_link_verification_limit" + | "note_link_reference_too_large" + | "note_link_reference_unencodable"; +export class NoteLinkError extends Error { + constructor( + public readonly code: NoteLinkErrorCode, + message = "The note link could not be used.", + ) { + super(message); + this.name = "NoteLinkError"; + } +} +export class NoteLinkConflictError extends Error { + readonly code = "note_link_conflict"; + constructor(public readonly current: LinkToken) { + super("The notes changed. Refresh before trying again."); + this.name = "NoteLinkConflictError"; + } +} diff --git a/web/src/links/reference.ts b/web/src/links/reference.ts new file mode 100644 index 0000000..bb7b008 --- /dev/null +++ b/web/src/links/reference.ts @@ -0,0 +1,162 @@ +import { NoteLinkError, type NoteTarget } from "./contracts"; + +export const NOTE_REFERENCE_LIMITS = Object.freeze({ + uri: 8192, + payload64: 8000, + jsonBytes: 6000, + noteId64: 5462, + noteIdBytes: 4096, +}); +const PREFIX = "orbyn-notes://note/v1/"; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const encoder = new TextEncoder(); +function invalid(): never { + throw new NoteLinkError("note_link_invalid"); +} +function large(): never { + throw new NoteLinkError("note_link_reference_too_large"); +} +export function isWellFormedReferenceId(value: string): boolean { + for (let i = 0; i < value.length; i++) { + const unit = value.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(++i); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + } else if (unit >= 0xdc00 && unit <= 0xdfff) return false; + } + return true; +} +export function validateNoteTarget(input: unknown): NoteTarget { + if ( + !input || + typeof input !== "object" || + Array.isArray(input) || + (Object.getPrototypeOf(input) !== Object.prototype && + Object.getPrototypeOf(input) !== null) + ) + invalid(); + const row = input as Record; + if ( + Object.keys(row).length !== 4 || + !["kind", "accountId", "libraryEpoch", "noteId"].every((key) => + Object.hasOwn(row, key), + ) || + row.kind !== "note" || + typeof row.accountId !== "string" || + !UUID.test(row.accountId) || + typeof row.libraryEpoch !== "string" || + !UUID.test(row.libraryEpoch) || + typeof row.noteId !== "string" + ) + invalid(); + // Storage admits opaque UTF16 IDs unchanged. Transport encoding checks their + // well-formedness and its narrower byte cap separately. + return Object.freeze({ + kind: "note", + accountId: row.accountId, + libraryEpoch: row.libraryEpoch, + noteId: row.noteId, + }); +} +function encode64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} +function decode64( + value: string, + maxChars: number, + maxBytes: number, +): Uint8Array { + if (value.length > maxChars) large(); + if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) invalid(); + let binary: string; + try { + binary = atob(value.replace(/-/g, "+").replace(/_/g, "/")); + } catch { + invalid(); + } + if (binary.length > maxBytes) large(); + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + if (encode64(bytes) !== value) invalid(); + return bytes; +} +function canonical(target: NoteTarget, noteId64: string): string { + return JSON.stringify({ + v: 1, + kind: "note", + accountId: target.accountId, + libraryEpoch: target.libraryEpoch, + noteId64, + }); +} +export function encodeNoteReference(input: NoteTarget): string { + const target = validateNoteTarget(input); + if (target.noteId.length > NOTE_REFERENCE_LIMITS.noteIdBytes) large(); + if (!isWellFormedReferenceId(target.noteId)) + throw new NoteLinkError("note_link_reference_unencodable"); + const id = encoder.encode(target.noteId); + if (id.length > NOTE_REFERENCE_LIMITS.noteIdBytes) large(); + const bytes = encoder.encode(canonical(target, encode64(id))); + if (bytes.length > NOTE_REFERENCE_LIMITS.jsonBytes) large(); + const payload = encode64(bytes); + const uri = PREFIX + payload; + if ( + payload.length > NOTE_REFERENCE_LIMITS.payload64 || + uri.length > NOTE_REFERENCE_LIMITS.uri + ) + large(); + return uri; +} +export function decodeNoteReference(input: unknown): NoteTarget { + if (typeof input !== "string") invalid(); + if (input.length > NOTE_REFERENCE_LIMITS.uri) large(); + if (!input.startsWith(PREFIX)) invalid(); + const bytes = decode64( + input.slice(PREFIX.length), + NOTE_REFERENCE_LIMITS.payload64, + NOTE_REFERENCE_LIMITS.jsonBytes, + ); + let text: string, value: unknown; + try { + text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + bytes, + ); + value = JSON.parse(text); + } catch { + invalid(); + } + if (!value || typeof value !== "object" || Array.isArray(value)) invalid(); + const row = value as Record; + if ( + Object.keys(row).length !== 5 || + row.v !== 1 || + row.kind !== "note" || + typeof row.noteId64 !== "string" + ) + invalid(); + const id = decode64( + row.noteId64, + NOTE_REFERENCE_LIMITS.noteId64, + NOTE_REFERENCE_LIMITS.noteIdBytes, + ); + let noteId: string; + try { + noteId = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + id, + ); + } catch { + invalid(); + } + const target = validateNoteTarget({ + kind: "note", + accountId: row.accountId, + libraryEpoch: row.libraryEpoch, + noteId, + }); + if (canonical(target, row.noteId64) !== text) invalid(); + return target; +} diff --git a/web/src/transfer/index.ts b/web/src/transfer/index.ts index 5445e41..1efa0e7 100644 --- a/web/src/transfer/index.ts +++ b/web/src/transfer/index.ts @@ -8,7 +8,9 @@ const parser = new MarkdownIt({ typographer: false, }); type Token = ReturnType[number]; -const MAX_FILE_BYTES = 2_000_000; +export const MAX_MARKDOWN_FILE_BYTES = 2_000_000; +export const MAX_MARKDOWN_BATCH_BYTES = 10_000_000; +export const MAX_MARKDOWN_FILES = 100; function inline(tokens: Token[]): RichNode[] { const nodes: RichNode[] = []; let marks: NonNullable = []; @@ -53,6 +55,10 @@ function inline(tokens: Token[]): RichNode[] { return nodes; } function document(tokens: Token[]): NoteDocument { + if (tokens.some((token) => token.type === "table_open")) + throw new Error( + "unsupported_table: Markdown tables are not supported yet. Convert the table to text before importing. Nothing was imported.", + ); const root: NoteDocument = { type: "doc", content: [] }; const stack: RichNode[] = [root]; const kinds: Record = { @@ -87,25 +93,31 @@ function document(tokens: Token[]): NoteDocument { else if (token.type === "hr") parent.content!.push({ type: "horizontalRule" }); } - function tasks(node: RichNode) { - node.content?.forEach(tasks); - if ( - node.type === "bulletList" && - node.content?.some((item) => - /^\[[ xX]\] /.test(item.content?.[0]?.content?.[0]?.text ?? ""), - ) - ) { - node.type = "taskList"; + function tasks(nodes: RichNode[]): RichNode[] { + return nodes.flatMap((node) => { + if (node.content) node.content = tasks(node.content); + if (node.type !== "bulletList") return [node]; + const groups: RichNode[] = []; for (const item of node.content ?? []) { - item.type = "taskItem"; const text = item.content?.[0]?.content?.[0]; const match = /^\[([ xX])\] /.exec(text?.text ?? ""); - item.attrs = { checked: !!match && match[1] !== " " }; - if (match && text?.text) text.text = text.text.slice(match[0].length); + const type = match ? "taskList" : "bulletList"; + if (match) { + item.type = "taskItem"; + item.attrs = { checked: match[1] !== " " }; + text!.text = text!.text!.slice(match[0].length); + } + let group = groups[groups.length - 1]; + if (group?.type !== type) { + group = { type, content: [] }; + groups.push(group); + } + group.content!.push(item); } - } + return groups; + }); } - root.content.forEach(tasks); + root.content = tasks(root.content); return root.content.length ? root : { type: "doc", content: [{ type: "paragraph", content: [] }] }; @@ -157,7 +169,7 @@ function frontmatter(source: string): { export function parseMarkdown( files: Array<{ name: string; content: string }>, ): Array<{ title: string; document: NoteDocument; tags: string[] }> { - if (files.length > 100) + if (files.length > MAX_MARKDOWN_FILES) throw new Error("too_many_files: Import at most 100 files at once."); let total = 0; return files.map((file) => { @@ -169,7 +181,7 @@ export function parseMarkdown( throw new Error("invalid_file: Choose Markdown files."); const size = new TextEncoder().encode(file.content).length; total += size; - if (size > MAX_FILE_BYTES || total > 10_000_000) + if (size > MAX_MARKDOWN_FILE_BYTES || total > MAX_MARKDOWN_BATCH_BYTES) throw new Error( "file_too_large: Import files up to 2 MB and batches up to 10 MB.", ); @@ -190,7 +202,7 @@ export function parseMarkdown( }); }); } -function escape(text: string): string { +export function escapeMarkdownText(text: string): string { return ( text .replace(/&/g, "&") @@ -207,23 +219,126 @@ function escape(text: string): string { .replace(/\n/g, " ") ); } -function renderInline(nodes: RichNode[] = []): string { +export function markdownCodeSpan(text: string): string { + let longest = 0; + for (const match of text.matchAll(/`+/g)) + longest = Math.max(longest, match[0].length); + const fence = "`".repeat(longest + 1); + return `${fence} ${text} ${fence}`; +} +type MarkdownExtent = { bytes: number; newlines: number }; +export type MarkdownAdmission = { + admit: (upperUtf8Bytes: number) => void; + referenceBound: (node: RichNode) => number; + startIndex?: number; +}; +const lineCount = (text: string) => { + let count = 0; + for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) count++; + return count; +}; +function inlineExtent( + nodes: RichNode[], + reference: (node: RichNode) => number, +): MarkdownExtent { + let bytes = 0, + newlines = 0; + for (const node of nodes) { + if (node.type === "hardBreak") { + bytes += 3; + newlines++; + continue; + } + if (node.type === "noteLink") { + bytes += reference(node); + continue; + } + const text = node.text ?? ""; + bytes += text.length * 6 + 4; + if (node.marks?.some((mark) => mark.type === "code")) + newlines += lineCount(text); + for (const mark of node.marks ?? []) + bytes += + 4 + + (mark.type === "link" ? String(mark.attrs?.href ?? "").length * 3 : 0); + } + return { bytes, newlines }; +} +/** An upper output bound is computed before any escaped/fenced output exists. + * Newline counts stay separate: indentation does not multiply line counts. */ +function blockExtent( + nodes: RichNode[], + reference: (node: RichNode) => number, +): MarkdownExtent { + let bytes = Math.max(0, nodes.length - 1) * 2; + let newlines = bytes; + for (const node of nodes) { + const children = node.content ?? []; + let value: MarkdownExtent; + if (node.type === "paragraph" || node.type === "heading") { + value = inlineExtent(children, reference); + if (node.type === "heading") value.bytes += 7; + } else if (node.type === "horizontalRule") + value = { bytes: 3, newlines: 0 }; + else if (node.type === "codeBlock") { + let size = 0, + lines = 2; + for (const child of children) { + size += (child.text ?? "").length; + lines += lineCount(child.text ?? ""); + } + value = { + bytes: size * 5 + 8 + String(node.attrs?.language ?? "").length * 3, + newlines: lines, + }; + } else if (["bulletList", "orderedList", "taskList"].includes(node.type)) { + value = { + bytes: Math.max(0, children.length - 1), + newlines: Math.max(0, children.length - 1), + }; + for (let i = 0; i < children.length; i++) { + const child = blockExtent(children[i]!.content ?? [], reference); + const prefix = + node.type === "orderedList" + ? `${(Number(node.attrs?.start) || 1) + i}. `.length + : node.type === "taskList" + ? 6 + : 2; + value.bytes += child.bytes + prefix * (child.newlines + 1); + value.newlines += child.newlines; + } + } else { + value = blockExtent(children, reference); + if (node.type === "blockquote") value.bytes += 2 * (value.newlines + 1); + } + bytes += value.bytes; + newlines += value.newlines; + } + return { bytes, newlines }; +} +function renderInline( + nodes: RichNode[] = [], + reference?: (node: RichNode) => string, +): string { return nodes .map((n) => { if (n.type === "hardBreak") return " \n"; - let text = escape(n.text ?? ""); + if (n.type === "noteLink") { + if (!reference) + throw new Error( + "invalid_document: A note reference needs an authorized export projection.", + ); + return reference(n); + } + let text = escapeMarkdownText(n.text ?? ""); + let code: string | undefined; for (const mark of n.marks ?? []) { if (mark.type === "bold") text = `**${text}**`; else if (mark.type === "italic") text = `*${text}*`; else if (mark.type === "strike") text = `~~${text}~~`; else if (mark.type === "code") { - const fence = "`".repeat( - Math.max( - 0, - ...((n.text ?? "").match(/`+/g) ?? []).map((x) => x.length), - ) + 1, - ); - text = `${fence} ${n.text ?? ""} ${fence}`; + code ??= markdownCodeSpan(n.text ?? ""); + text = code; } else if (mark.type === "link") { if (!safeHref(mark.attrs?.href)) throw new Error("unsafe_link: Invalid link."); @@ -234,27 +349,31 @@ function renderInline(nodes: RichNode[] = []): string { }) .join(""); } -function renderBlocks(nodes: RichNode[] = []): string { +function renderBlocks( + nodes: RichNode[] = [], + reference?: (node: RichNode) => string, +): string { return nodes .map((n) => { const children = n.content ?? []; switch (n.type) { case "paragraph": - return renderInline(children); + return renderInline(children, reference); case "heading": - return `${"#".repeat(Math.max(1, Math.min(6, Number(n.attrs?.level) || 1)))} ${renderInline(children)}`; + return `${"#".repeat(Math.max(1, Math.min(6, Number(n.attrs?.level) || 1)))} ${renderInline(children, reference)}`; case "horizontalRule": return "---"; case "blockquote": - return renderBlocks(children) + return renderBlocks(children, reference) .split("\n") .map((l) => `> ${l}`) .join("\n"); case "codeBlock": { const text = children.map((c) => c.text ?? "").join(""); - const fence = "`".repeat( - Math.max(2, ...(text.match(/`+/g) ?? []).map((s) => s.length)) + 1, - ); + let longest = 2; + for (const match of text.matchAll(/`+/g)) + longest = Math.max(longest, match[0].length); + const fence = "`".repeat(longest + 1); const language = String(n.attrs?.language ?? "").replace( /[^a-zA-Z0-9_+-]/g, "", @@ -272,7 +391,7 @@ function renderBlocks(nodes: RichNode[] = []): string { : n.type === "taskList" ? `- [${item.attrs?.checked ? "x" : " "}] ` : "- "; - return renderBlocks(item.content) + return renderBlocks(item.content, reference) .split("\n") .map((line, i) => i === 0 ? prefix + line : " ".repeat(prefix.length) + line, @@ -281,24 +400,38 @@ function renderBlocks(nodes: RichNode[] = []): string { }) .join("\n"); default: - return renderBlocks(children); + return renderBlocks(children, reference); } }) .join("\n\n"); } export function exportMarkdown( notes: Note[], +): Array<{ filename: string; content: string }> { + for (const note of notes) validateInput(note); + return renderMarkdownSnapshot(notes); +} +/** Pure formatter only. Callers must validate and authorize their own snapshot. */ +export function renderMarkdownSnapshot( + notes: readonly Note[], + reference?: (node: RichNode) => string, + admission?: MarkdownAdmission, ): Array<{ filename: string; content: string }> { return notes.map((note, index) => { - validateInput(note); + admission?.admit( + blockExtent(note.document.content, admission.referenceBound).bytes + + note.title.length * 6 + + note.tags.reduce((sum, tag) => sum + tag.length * 6 + 4, 0) + + 128, + ); const name = (note.title || "Untitled") .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "-") .replace(/[. ]+$/g, "") .slice(0, 100) || "Untitled"; return { - filename: `${name}-${index + 1}.md`, - content: `---\ntitle: ${JSON.stringify(note.title)}\ntags: ${JSON.stringify(note.tags)}\n---\n\n${renderBlocks(note.document.content)}\n`, + filename: `${name}-${index + (admission?.startIndex ?? 0) + 1}.md`, + content: `---\ntitle: ${JSON.stringify(note.title)}\ntags: ${JSON.stringify(note.tags)}\n---\n\n${renderBlocks(note.document.content, reference)}\n`, }; }); } diff --git a/web/src/transfer/noteLinks.ts b/web/src/transfer/noteLinks.ts new file mode 100644 index 0000000..9358aff --- /dev/null +++ b/web/src/transfer/noteLinks.ts @@ -0,0 +1,249 @@ +import type { Note } from "../domain/contracts"; +import { inspectNotes } from "../domain/validation"; +import type { + LinkToken, + NoteExportFile, + NoteLinkResolution, + NoteTarget, +} from "../links/contracts"; +import { NoteLinkError } from "../links/contracts"; +import { encodeNoteReference, validateNoteTarget } from "../links/reference"; +import { + escapeMarkdownText, + markdownCodeSpan, + renderMarkdownSnapshot, +} from "./index"; + +export type AuthorizedNoteExportProjection = Readonly<{ + accountId: string; + token: LinkToken; + references: readonly Readonly<{ + target: NoteTarget; + resolution: NoteLinkResolution; + }>[]; + consumedWork?: Readonly<{ utf8Units: number; nodeVisits: number }>; +}>; +export const NOTE_EXPORT_LIMITS = Object.freeze({ + utf8Units: 64 * 1024 * 1024, + nodeVisits: 250000, + references: 100000, +}); +const key = (target: NoteTarget) => + JSON.stringify([ + target.kind, + target.accountId, + target.libraryEpoch, + target.noteId, + ]); +const invalid = (): never => { + throw new NoteLinkError("note_link_invalid"); +}; +const limit = (): never => { + throw new NoteLinkError("note_link_limit"); +}; + +/** Formatting is not authorization. The real service captures this projection + * and checks its generation/token again before publishing or downloading files. */ +export function renderNoteExport( + notes: readonly Note[], + projection: AuthorizedNoteExportProjection, +): { files: NoteExportFile[]; warnings: string[] } { + validateNoteTarget({ + kind: "note", + accountId: projection.accountId, + libraryEpoch: projection.token.epoch, + noteId: "", + }); + if ( + !Number.isSafeInteger(projection.token.revision) || + projection.token.revision < 0 + ) + invalid(); + if ( + !Array.isArray(notes) || + notes.length > 10000 || + !Array.isArray(projection.references) || + projection.references.length > NOTE_EXPORT_LIMITS.references + ) + limit(); + const labels = new Map(); + let units = projection.consumedWork?.utf8Units ?? 0, + visits = projection.consumedWork?.nodeVisits ?? 0; + if ( + !Number.isSafeInteger(units) || + units < 0 || + !Number.isSafeInteger(visits) || + visits < 0 + ) + invalid(); + if ( + units > NOTE_EXPORT_LIMITS.utf8Units || + visits > NOTE_EXPORT_LIMITS.nodeVisits + ) + limit(); + const charge = (text: string) => { + const before = units; + // Exact UTF8 length, including replacement bytes for unpaired UTF16, without + // allocating an oversized encoded buffer merely to discover the limit. + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + let size = c < 0x80 ? 1 : c < 0x800 ? 2 : 3; + if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) { + const next = text.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + size = 4; + i++; + } + } + if (units + size > NOTE_EXPORT_LIMITS.utf8Units) limit(); + units += size; + } + return units - before; + }; + const reserve = (upperBytes: number) => { + if ( + !Number.isSafeInteger(upperBytes) || + upperBytes < 0 || + units + upperBytes > NOTE_EXPORT_LIMITS.utf8Units + ) + limit(); + units += upperBytes; + }; + for (const item of projection.references) { + const target = validateNoteTarget(item.target); + charge(key(target)); + let label = "Note reference"; + const resolution = item.resolution; + if (resolution.status === "available") { + if ( + key(validateNoteTarget(resolution.target)) !== key(target) || + target.accountId !== projection.accountId || + target.libraryEpoch !== projection.token.epoch || + resolution.token.epoch !== projection.token.epoch || + resolution.token.revision !== projection.token.revision || + typeof resolution.title !== "string" || + resolution.title.length > 500 || + !Number.isSafeInteger(resolution.noteRevision) || + resolution.noteRevision < 1 + ) + invalid(); + label = resolution.title || "Untitled"; + } else if (resolution.status !== "unavailable") invalid(); + const identity = key(target); + if (labels.has(identity) && labels.get(identity) !== label) invalid(); + labels.set(identity, label); + } + const serialized: string[] = [], + markdown: NoteExportFile[] = [], + ids = new Set(); + let serializedBytes = 0; + const warnings = new Set([ + "Markdown import does not restore note-link relationships or map original note IDs to imported notes.", + "These files are plaintext and are not a complete workspace backup. They contain Notes only; Journal, Study, Clips, source anchors and version history are not included.", + ]); + for (const candidate of notes) { + const inspected = inspectNotes([candidate], "notes")[0]!; + visits += inspected.nodeVisits; + units += inspected.jsonUnits * 3; + if ( + visits > NOTE_EXPORT_LIMITS.nodeVisits || + units > NOTE_EXPORT_LIMITS.utf8Units + ) + limit(); + const n = inspected.note; + if (ids.has(n.id)) invalid(); + ids.add(n.id); + // Export the Note contract, never unrelated engine/private object fields. + const note: Note = { + id: n.id, + title: n.title, + document: n.document, + tags: n.tags, + createdAt: n.createdAt, + updatedAt: n.updatedAt, + trashedAt: n.trashedAt, + revision: n.revision, + }; + // Admit serialization before constructing it. JSON string escaping costs + // at most six ASCII bytes per metadata UTF16 unit; inspected document JSON + // needs at most three UTF8 bytes per already-serialized unit. + const metadataUnits = + note.id.length + + note.title.length + + note.createdAt.length + + note.updatedAt.length + + (note.trashedAt?.length ?? 0) + + note.tags.reduce((sum, tag) => sum + tag.length + 1, 0); + reserve(inspected.jsonUnits * 3 + metadataUnits * 6 + 256); + const encoded = JSON.stringify(note); + serializedBytes += charge(encoded); + serialized.push(encoded); + // The estimate traverses and the formatter renders one bounded document. + // Neither receives a fresh node allowance after owner/validation work. + if (visits + inspected.nodeVisits * 2 > NOTE_EXPORT_LIMITS.nodeVisits) + limit(); + visits += inspected.nodeVisits * 2; + const rendered = renderMarkdownSnapshot( + [note], + (node) => { + const target = validateNoteTarget(node.attrs?.target); + const label = labels.get(key(target)) ?? "Note reference"; + let reference: string; + try { + reference = encodeNoteReference(target); + } catch (failure) { + if ( + !(failure instanceof NoteLinkError) || + ![ + "note_link_reference_too_large", + "note_link_reference_unencodable", + ].includes(failure.code) + ) + throw failure; + reference = JSON.stringify(target); + warnings.add( + "A target cannot be encoded as a portable link. Its exact identity is retained as JSON in Markdown and notes.json; it was not shortened or retargeted.", + ); + } + return `${escapeMarkdownText(label)} ${markdownCodeSpan(reference)}`; + }, + { + admit: reserve, + startIndex: markdown.length, + referenceBound: (node) => { + const target = validateNoteTarget(node.attrs?.target); + // Covers escaped current label, both transport/base64 layers OR exact + // JSON fallback, and worst-case backtick fence expansion, before encode. + return target.noteId.length * 8 + 4096; + }, + }, + ); + markdown.push(...rendered); + } + const jsonPrefix = '{"format":"orbyn-notes-notes","version":1,"notes":['; + reserve( + jsonPrefix.length + + 2 + + serializedBytes + + Math.max(0, serialized.length - 1), + ); + const json = `${jsonPrefix}${serialized.join(",")}]}`; + const readmeLines = [ + "Orbyn Notes export", + "", + ...warnings, + "", + "Reference tokens identify an account, library epoch and note. They do not grant access.", + "notes.json retains actual note IDs and structured documents for manual recovery. Automatic bundle import, account rebinding and graph restoration are not implemented.", + ]; + reserve(readmeLines.reduce((sum, line) => sum + line.length * 3 + 1, 0)); + const readme = readmeLines.join("\n"); + return { + files: [ + ...markdown, + { filename: "notes.json", content: json }, + { filename: "README.txt", content: readme }, + ], + warnings: [...warnings], + }; +} diff --git a/web/tests/data/note-link-document.test.ts b/web/tests/data/note-link-document.test.ts new file mode 100644 index 0000000..e1dc82f --- /dev/null +++ b/web/tests/data/note-link-document.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; +import { + inspectNoteDocument, + inspectNotes, + safeHref, + validateDocument, + validateInput, + validateNoteDocument, + validateNoteInput, + validateNotes, +} from "../../src/domain/validation"; + +const target = { + kind: "note", + accountId: "11111111-1111-4111-8111-111111111111", + libraryEpoch: "22222222-2222-4222-8222-222222222222", + noteId: "opaque / ID, ๐Ÿงช", +} as const; +function document() { + return { + type: "doc" as const, + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Before " }, + { type: "noteLink", attrs: { version: 1, target: { ...target } } }, + { type: "text", text: " after" }, + ], + }, + ], + }; +} +const input = () => ({ + title: "A note", + document: document(), + tags: ["exact,tag"], +}); +const note = () => ({ + id: "legacy source ID", + ...input(), + revision: 1, + createdAt: "2026-11-11T00:00:00.000Z", + updatedAt: "2026-11-11T00:00:00.000Z", + trashedAt: null, +}); + +describe("Notes-only reference document admission", () => { + it("keeps generic/Journal and legacy defaults strict while Notes opts in", () => { + expect(() => validateDocument(document())).toThrow( + /Unsupported document node/, + ); + expect(() => validateInput(input())).toThrow(); + expect(() => validateNotes([note()])).toThrow(); + expect(validateNoteDocument(document())).toEqual(document()); + expect(validateNoteInput(input())).toEqual(input()); + expect(validateNotes([note()], "notes")).toEqual([note()]); + expect(() => validateNotes([note()], "plain")).toThrow(); + expect(safeHref("orbyn-notes://note/v1/token")).toBe(false); + }); + + it("counts the actual single validation pass and returns an immutable-input snapshot", () => { + const value = document(); + const result = inspectNoteDocument(value, "notes"); + expect(result.nodeVisits).toBe(5); + expect(result.jsonUnits).toBe(JSON.stringify(value).length); + expect(result.document).toEqual(value); + value.content[0]!.content[0]!.text = "Changed later"; + expect(result.document).not.toEqual(value); + expect(() => inspectNoteDocument(document(), "plain")).toThrow(); + expect(inspectNotes([note()], "notes")).toEqual([ + { + note: note(), + nodeVisits: 5, + jsonUnits: JSON.stringify(note().document).length, + }, + ]); + }); + + it("rejects inherited identity substitution before returning a document snapshot", () => { + const inherited = Object.assign(Object.create({ noteId: "inherited" }), { + kind: target.kind, + accountId: target.accountId, + libraryEpoch: target.libraryEpoch, + title: "Cached title must never survive admission", + }); + const withTarget = (value: unknown) => ({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "noteLink", attrs: { version: 1, target: value } }], + }, + ], + }); + expect(() => validateNoteDocument(withTarget(inherited))).toThrow(); + const own = Object.assign(Object.create(null), target); + expect(validateNoteDocument(withTarget(own))).toEqual(withTarget(target)); + }); + + it.each([ + { type: "noteLink", attrs: { version: 2, target } }, + { type: "noteLink", attrs: { version: 1, target, title: "Cached title" } }, + { + type: "noteLink", + attrs: { version: 1, target: { ...target, title: "Hidden title" } }, + }, + { + type: "noteLink", + attrs: { version: 1, target: { ...target, kind: "task" } }, + }, + { + type: "noteLink", + attrs: { version: 1, target: { ...target, accountId: "invalid" } }, + }, + { + type: "noteLink", + attrs: { version: 1, target: { ...target, libraryEpoch: "invalid" } }, + }, + { + type: "noteLink", + attrs: { version: 1, target: { ...target, noteId: 4 } }, + }, + { type: "noteLink", attrs: { version: 1, target }, content: [] }, + { type: "noteLink", attrs: { version: 1, target }, text: "Cached text" }, + { + type: "noteLink", + attrs: { version: 1, target }, + marks: [{ type: "bold" }], + }, + { + type: "noteLink", + attrs: { version: 1, target }, + href: "https://example.test", + }, + ])( + "rejects malformed/foreign-kind/content-bearing reference nodes: %j", + (link) => { + expect(() => + validateNoteDocument({ + type: "doc", + content: [{ type: "paragraph", content: [link] }], + }), + ).toThrow(); + }, + ); + + it("retains valid opaque IDs in documents even if a portable token cannot encode them", () => { + for (const noteId of ["", "\ud800", "x".repeat(5000)]) { + const value = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "noteLink", + attrs: { version: 1, target: { ...target, noteId } }, + }, + ], + }, + ], + }; + expect(validateNoteDocument(value)).toEqual(value); + } + }); + + it.each(["doc", "codeBlock", "listItem"])( + "rejects a reference in non-inline %s content", + (parent) => { + const link = { type: "noteLink", attrs: { version: 1, target } }; + const value = + parent === "doc" + ? { type: "doc", content: [link] } + : { type: "doc", content: [{ type: parent, content: [link] }] }; + expect(() => validateNoteDocument(value)).toThrow(); + }, + ); + + it.each(["paragraph", "codeBlock"])( + "rejects a reference hidden through an invalid %s ancestor", + (parent) => { + const value = { + type: "doc", + content: [ + { + type: parent, + content: [ + { + type: "paragraph", + content: [{ type: "noteLink", attrs: { version: 1, target } }], + }, + ], + }, + ], + }; + expect(() => validateNoteDocument(value)).toThrow(); + }, + ); + + it("enforces the reference count alongside the existing document/node/depth bounds", () => { + const link = { type: "noteLink", attrs: { version: 1, target } }; + const value = (count: number) => ({ + type: "doc", + content: [ + { + type: "paragraph", + content: Array.from({ length: count }, () => structuredClone(link)), + }, + ], + }); + expect(validateNoteDocument(value(1000))).toEqual(value(1000)); + expect(() => validateNoteDocument(value(1001))).toThrow(); + expect(() => + validateNoteDocument({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "x".repeat(1_000_001) }], + }, + ], + }), + ).toThrow(); + }); +}); diff --git a/web/tests/data/note-links-reference.test.ts b/web/tests/data/note-links-reference.test.ts new file mode 100644 index 0000000..5fe6b91 --- /dev/null +++ b/web/tests/data/note-links-reference.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment node +import { readFile } from "node:fs/promises"; +import { expect, it } from "vitest"; +import { + decodeNoteReference, + encodeNoteReference, + isWellFormedReferenceId, + validateNoteTarget, +} from "../../src/links/reference"; +import { NoteLinkError } from "../../src/links/contracts"; +const scope = { + kind: "note" as const, + accountId: "12345678-1234-4234-8234-123456789abc", + libraryEpoch: "abcdef12-3456-4789-abcd-123456789abc", +}; +const pack = (text: string) => + "orbyn-notes://note/v1/" + Buffer.from(text).toString("base64url"); +it("round trips opaque IDs without trim, case or Unicode normalization", () => { + for (const noteId of [ + "", + "Note /?## %", + "e\u0301", + "รฉ", + "๐Ÿ“", + "\ufeff", + "x".repeat(4096), + ]) { + const target = { ...scope, noteId }; + expect(decodeNoteReference(encodeNoteReference(target))).toEqual(target); + } + expect(encodeNoteReference({ ...scope, noteId: "รฉ" })).not.toBe( + encodeNoteReference({ ...scope, noteId: "e\u0301" }), + ); +}); +it("storage target validation preserves IDs that cannot honestly be copied", () => { + for (const noteId of ["\ud800", "\udc00", "x\ud800y"]) { + expect(validateNoteTarget({ ...scope, noteId }).noteId).toBe(noteId); + expect(isWellFormedReferenceId(noteId)).toBe(false); + expect(() => encodeNoteReference({ ...scope, noteId })).toThrow( + expect.objectContaining({ code: "note_link_reference_unencodable" }), + ); + } + const huge = { ...scope, noteId: "x".repeat(4097) }; + expect(validateNoteTarget(huge)).toEqual(huge); + expect(() => encodeNoteReference(huge)).toThrow( + expect.objectContaining({ code: "note_link_reference_too_large" }), + ); + expect(() => + encodeNoteReference({ ...scope, noteId: "็•Œ".repeat(1366) }), + ).toThrow(NoteLinkError); +}); +it("rejects alternative JSON/base64/URI spelling, duplicates and malformed UTF8", () => { + const canonical = JSON.stringify({ + v: 1, + kind: "note", + accountId: scope.accountId, + libraryEpoch: scope.libraryEpoch, + noteId64: "YQ", + }); + const bad = [ + pack(canonical.replace('"v":1', '"v":1,"v":1')), + pack(canonical.replace('"v":1', '"v":2')), + pack(canonical.replace("{", "{ ")), + pack(canonical.replace('"YQ"', '"YR"')), + pack(canonical.replace('"YQ"', '"YQ=="')), + pack(canonical.replace('"YQ"', '"_w"')), + pack(canonical.replace('"note"', '"no\\u0074e"')), + pack("\ufeff" + canonical), + pack(canonical) + "=", + pack(canonical) + "#fragment", + pack(canonical).replace("orbyn-notes", "ORBYN-NOTES"), + pack(canonical).replace("note/v1", "note/v2"), + "orbyn-notes://note/v1/_w", + "orbyn-notes://note/v1/A", + ]; + for (const value of bad) + expect(() => decodeNoteReference(value), value).toThrow(NoteLinkError); +}); +it("rejects size violations before decoding and exact target-shape violations", () => { + for (const value of [ + null, + {}, + 4, + "x".repeat(8193), + "orbyn-notes://note/v1/" + "A".repeat(8001), + ]) + expect(() => decodeNoteReference(value)).toThrow(NoteLinkError); + for (const value of [ + { ...scope, noteId: "x", title: "leak" }, + { ...scope, noteId: "x", accountId: "wrong" }, + { ...scope, noteId: 1 }, + ]) + expect(() => validateNoteTarget(value)).toThrow(NoteLinkError); + const inherited = Object.assign(Object.create({ noteId: "inherited" }), { + ...scope, + title: "cached", + }); + expect(() => validateNoteTarget(inherited)).toThrow(NoteLinkError); + expect( + validateNoteTarget( + Object.assign(Object.create(null), { ...scope, noteId: "own" }), + ), + ).toEqual({ ...scope, noteId: "own" }); +}); +it("matches shared native/web golden vectors including numeric surrogate cases", async () => { + const vectors = JSON.parse( + await readFile( + new URL("../vectors/note-reference.json", import.meta.url), + "utf8", + ), + ); + for (const row of vectors.valid) { + expect(encodeNoteReference(row.target)).toBe(row.uri); + expect(decodeNoteReference(row.uri)).toEqual(row.target); + } + for (const row of vectors.invalid) + expect(() => decodeNoteReference(row.uri)).toThrow(NoteLinkError); + for (const row of vectors.unencodable) + expect(() => + encodeNoteReference({ + ...scope, + noteId: String.fromCharCode(...row.utf16), + }), + ).toThrow( + expect.objectContaining({ code: "note_link_reference_unencodable" }), + ); +}); diff --git a/web/tests/transfer/markdown.test.ts b/web/tests/transfer/markdown.test.ts index 89cd3cd..124dcd6 100644 --- a/web/tests/transfer/markdown.test.ts +++ b/web/tests/transfer/markdown.test.ts @@ -2,6 +2,22 @@ import { describe, expect, it } from "vitest"; import { exportMarkdown, parseMarkdown } from "../../src/transfer"; import type { Note } from "../../src/domain/contracts"; describe("Markdown transfer boundary", () => { + it("keeps formatting and text when paragraphs start or end with a mark", () => { + const result = parseMarkdown([{ name: "marks.md", content: "**Bold** and *italic* then ~~strike~~" }])[0]!; + const nodes = result.document.content[0]!.content!; + expect(nodes.map(n => n.text ?? "").join("")).toBe("Bold and italic then strike"); + expect(nodes.filter(n => n.text).map(n => [n.text, n.marks?.map(m => m.type) ?? []])).toEqual([ + ["Bold", ["bold"]], [" and ", []], ["italic", ["italic"]], [" then ", []], ["strike", ["strike"]], + ]); + }); + it("rejects unsupported tables with a useful error before any import", () => { + expect(() => parseMarkdown([{ name: "table.md", content: "| Name | Value |\n| --- | --- |\n| secret | retained |" }])).toThrow("unsupported_table"); + }); + it("preserves ordinary list items alongside tasks without turning them into checkboxes", () => { + const result = parseMarkdown([{ name: "mixed.md", content: "- Plain\n- [x] Done\n- Other\n- [ ] Next" }])[0]!; + expect(result.document.content.map(n => n.type)).toEqual(["bulletList", "taskList", "bulletList", "taskList"]); + expect(result.document.content.map(n => n.content?.[0]?.attrs?.checked ?? null)).toEqual([null, true, null, false]); + }); it("round trips title, tags, paragraphs, headings, ordered lists, tasks and links", () => { const source = '---\ntitle: "A safe title"\ntags: ["work", "a:b"]\n---\n\n# Heading\n\nA **bold** paragraph and [link](https://example.test).\n\n3. Third\n4. Fourth\n\n- [x] Done\n- [ ] Next\n'; diff --git a/web/tests/transfer/note-links.test.ts b/web/tests/transfer/note-links.test.ts new file mode 100644 index 0000000..1a60d97 --- /dev/null +++ b/web/tests/transfer/note-links.test.ts @@ -0,0 +1,229 @@ +import { expect, it, vi } from "vitest"; +import type { Note } from "../../src/domain/contracts"; +import type { NoteLinkResolution, NoteTarget } from "../../src/links/contracts"; +import { encodeNoteReference } from "../../src/links/reference"; +import { exportMarkdown, parseMarkdown } from "../../src/transfer"; +import { + NOTE_EXPORT_LIMITS, + renderNoteExport, +} from "../../src/transfer/noteLinks"; + +vi.mock("../../src/links/reference", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, encodeNoteReference: vi.fn(actual.encodeNoteReference) }; +}); + +const target: NoteTarget = { + kind: "note", + accountId: "11111111-1111-4111-8111-111111111111", + libraryEpoch: "22222222-2222-4222-8222-222222222222", + noteId: "opaque target", +}; +const token = { epoch: target.libraryEpoch, revision: 4 }; +function note(to: NoteTarget = target): Note { + return { + id: "source", + title: "Readable note", + document: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "See " }, + { type: "noteLink", attrs: { version: 1, target: to } }, + ], + }, + ], + }, + tags: ["exact,tag"], + revision: 2, + createdAt: "2026-11-11T00:00:00.000Z", + updatedAt: "2026-11-11T00:00:00.000Z", + trashedAt: null, + }; +} +const available = (to = target): NoteLinkResolution => ({ + status: "available", + token, + target: to, + noteRevision: 3, + title: "Current [title] ", +}); +const projection = ( + to = target, + resolution: NoteLinkResolution = available(to), +) => ({ + accountId: target.accountId, + token, + references: [{ target: to, resolution }], +}); + +it("exports current authorized labels and exact tokens plus actual structured Notes, without unrelated fields", () => { + const value = { ...note(), unrelatedSecret: "must not export" }; + const result = renderNoteExport([value], projection()); + const markdown = result.files.find((file) => + file.filename.endsWith(".md"), + )!.content; + expect(markdown).toContain("Current \\[title\\] \\"); + expect(markdown).toContain(encodeNoteReference(target)); + expect(markdown).not.toContain(""); + const exact = JSON.parse( + result.files.find((file) => file.filename === "notes.json")!.content, + ); + expect(exact).toEqual({ + format: "orbyn-notes-notes", + version: 1, + notes: [note()], + }); + expect(result.files.map((file) => file.content).join("\n")).not.toContain( + "must not export", + ); + expect( + result.files.find((file) => file.filename === "README.txt")!.content, + ).toContain("not a complete workspace backup"); +}); + +it("keeps generic/Journal export strict and Markdown imports truthful plain token text", () => { + expect(() => exportMarkdown([note()])).toThrow(); + const exported = renderNoteExport([note()], projection()).files.find((file) => + file.filename.endsWith(".md"), + )!; + const imported = parseMarkdown([ + { name: exported.filename, content: exported.content }, + ])[0]!; + expect(JSON.stringify(imported.document)).toContain( + encodeNoteReference(target), + ); + expect(JSON.stringify(imported.document)).not.toContain('"type":"noteLink"'); +}); + +it("never projects a foreign/unavailable title and refuses a mixed-scope or stale authorized projection", () => { + const foreign = { + ...target, + accountId: "33333333-3333-4333-8333-333333333333", + }; + const unavailable = { + status: "unavailable", + reason: "scope", + title: "SECRET FOREIGN TITLE", + } as unknown as NoteLinkResolution; + const result = renderNoteExport( + [note(foreign)], + projection(foreign, unavailable), + ); + expect(result.files.map((file) => file.content).join("\n")).not.toContain( + "SECRET FOREIGN TITLE", + ); + expect(result.files[0]!.content).toContain("Note reference"); + expect(() => + renderNoteExport([note(foreign)], projection(foreign, available(foreign))), + ).toThrow(); + expect(() => + renderNoteExport( + [note()], + projection(target, { + ...available(), + token: { ...token, revision: 5 }, + } as NoteLinkResolution), + ), + ).toThrow(); +}); + +it.each(["\ud800", "x".repeat(5000)])( + "preserves an unencodable/oversize identity exactly without a fake portable token", + (noteId) => { + const to = { ...target, noteId }; + const result = renderNoteExport([note(to)], projection(to)); + expect(result.files[0]!.content).toContain(JSON.stringify(to)); + expect( + JSON.parse( + result.files.find((file) => file.filename === "notes.json")!.content, + ).notes[0].document, + ).toEqual(note(to).document); + expect( + result.warnings.some((warning) => warning.includes("not shortened")), + ).toBe(true); + }, +); + +it("charges work already spent by the owner instead of granting a new renderer allowance", () => { + expect(() => + renderNoteExport([note()], { + ...projection(), + consumedWork: { utf8Units: NOTE_EXPORT_LIMITS.utf8Units, nodeVisits: 0 }, + }), + ).toThrow(); + expect(() => + renderNoteExport([note()], { + ...projection(), + consumedWork: { utf8Units: 0, nodeVisits: NOTE_EXPORT_LIMITS.nodeVisits }, + }), + ).toThrow(); + expect(() => + renderNoteExport([], { + ...projection(), + consumedWork: { utf8Units: -1, nodeVisits: 0 }, + }), + ).toThrow(); +}); + +it("handles many literal backtick runs without argument spreading or repeated code rescans", () => { + const value = note(); + value.document = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "`x".repeat(250000), + marks: [{ type: "code" }], + }, + ], + }, + ], + }; + const result = exportMarkdown([value]); + expect(result[0]!.content).toContain("`` `x`x"); +}); + +it("admits escape expansion before encoding a reference in a low-budget file", () => { + const value = note(); + value.document.content[0]!.content!.unshift({ + type: "text", + text: " ".repeat(20000), + }); + vi.mocked(encodeNoteReference).mockClear(); + expect(() => + renderNoteExport([value], { + ...projection(), + consumedWork: { + utf8Units: NOTE_EXPORT_LIMITS.utf8Units - 200000, + nodeVisits: 0, + }, + }), + ).toThrow(expect.objectContaining({ code: "note_link_limit" })); + expect(encodeNoteReference).not.toHaveBeenCalled(); +}); + +it("stops a batch before formatting later file references after exhaustion", () => { + const value = note(); + value.document.content[0]!.content!.unshift({ + type: "text", + text: " ".repeat(20000), + }); + vi.mocked(encodeNoteReference).mockClear(); + expect(() => + renderNoteExport([value, { ...value, id: "second" }], { + ...projection(), + consumedWork: { + utf8Units: NOTE_EXPORT_LIMITS.utf8Units - 400000, + nodeVisits: 0, + }, + }), + ).toThrow(expect.objectContaining({ code: "note_link_limit" })); + expect(encodeNoteReference).toHaveBeenCalledOnce(); +}); diff --git a/web/tests/vectors/note-reference.json b/web/tests/vectors/note-reference.json new file mode 100644 index 0000000..aa7612e --- /dev/null +++ b/web/tests/vectors/note-reference.json @@ -0,0 +1,78 @@ +{ + "version": 1, + "valid": [ + { + "name": "empty", + "target": { + "kind": "note", + "accountId": "12345678-1234-4234-8234-123456789abc", + "libraryEpoch": "abcdef12-3456-4789-abcd-123456789abc", + "noteId": "" + }, + "uri": "orbyn-notes://note/v1/eyJ2IjoxLCJraW5kIjoibm90ZSIsImFjY291bnRJZCI6IjEyMzQ1Njc4LTEyMzQtNDIzNC04MjM0LTEyMzQ1Njc4OWFiYyIsImxpYnJhcnlFcG9jaCI6ImFiY2RlZjEyLTM0NTYtNDc4OS1hYmNkLTEyMzQ1Njc4OWFiYyIsIm5vdGVJZDY0IjoiIn0" + }, + { + "name": "opaque", + "target": { + "kind": "note", + "accountId": "12345678-1234-4234-8234-123456789abc", + "libraryEpoch": "abcdef12-3456-4789-abcd-123456789abc", + "noteId": "Note /?## %" + }, + "uri": "orbyn-notes://note/v1/eyJ2IjoxLCJraW5kIjoibm90ZSIsImFjY291bnRJZCI6IjEyMzQ1Njc4LTEyMzQtNDIzNC04MjM0LTEyMzQ1Njc4OWFiYyIsImxpYnJhcnlFcG9jaCI6ImFiY2RlZjEyLTM0NTYtNDc4OS1hYmNkLTEyMzQ1Njc4OWFiYyIsIm5vdGVJZDY0IjoiVG05MFpTQXZQeU1qSUNVIn0" + }, + { + "name": "unicode", + "target": { + "kind": "note", + "accountId": "12345678-1234-4234-8234-123456789abc", + "libraryEpoch": "abcdef12-3456-4789-abcd-123456789abc", + "noteId": "๐Ÿ“eฬ" + }, + "uri": "orbyn-notes://note/v1/eyJ2IjoxLCJraW5kIjoibm90ZSIsImFjY291bnRJZCI6IjEyMzQ1Njc4LTEyMzQtNDIzNC04MjM0LTEyMzQ1Njc4OWFiYyIsImxpYnJhcnlFcG9jaCI6ImFiY2RlZjEyLTM0NTYtNDc4OS1hYmNkLTEyMzQ1Njc4OWFiYyIsIm5vdGVJZDY0IjoiOEotVG5XWE1nUSJ9" + }, + { + "name": "scope-case", + "target": { + "kind": "note", + "accountId": "12345678-1234-4234-8234-123456789ABC", + "libraryEpoch": "abcdef12-3456-4789-abcd-123456789abc", + "noteId": "A" + }, + "uri": "orbyn-notes://note/v1/eyJ2IjoxLCJraW5kIjoibm90ZSIsImFjY291bnRJZCI6IjEyMzQ1Njc4LTEyMzQtNDIzNC04MjM0LTEyMzQ1Njc4OUFCQyIsImxpYnJhcnlFcG9jaCI6ImFiY2RlZjEyLTM0NTYtNDc4OS1hYmNkLTEyMzQ1Njc4OWFiYyIsIm5vdGVJZDY0IjoiUVEifQ" + } + ], + "invalid": [ + { + "name": "upper-scheme", + "uri": "ORBYN-NOTES://note/v1/eyJ2IjoxLCJraW5kIjoibm90ZSIsImFjY291bnRJZCI6IjEyMzQ1Njc4LTEyMzQtNDIzNC04MjM0LTEyMzQ1Njc4OWFiYyIsImxpYnJhcnlFcG9jaCI6ImFiY2RlZjEyLTM0NTYtNDc4OS1hYmNkLTEyMzQ1Njc4OWFiYyIsIm5vdGVJZDY0IjoiVG05MFpTQXZQeU1qSUNVIn0" + }, + { + "name": "padding", + "uri": "orbyn-notes://note/v1/eyJ2IjoxLCJraW5kIjoibm90ZSIsImFjY291bnRJZCI6IjEyMzQ1Njc4LTEyMzQtNDIzNC04MjM0LTEyMzQ1Njc4OWFiYyIsImxpYnJhcnlFcG9jaCI6ImFiY2RlZjEyLTM0NTYtNDc4OS1hYmNkLTEyMzQ1Njc4OWFiYyIsIm5vdGVJZDY0IjoiVG05MFpTQXZQeU1qSUNVIn0=" + }, + { + "name": "fragment", + "uri": "orbyn-notes://note/v1/eyJ2IjoxLCJraW5kIjoibm90ZSIsImFjY291bnRJZCI6IjEyMzQ1Njc4LTEyMzQtNDIzNC04MjM0LTEyMzQ1Njc4OWFiYyIsImxpYnJhcnlFcG9jaCI6ImFiY2RlZjEyLTM0NTYtNDc4OS1hYmNkLTEyMzQ1Njc4OWFiYyIsIm5vdGVJZDY0IjoiVG05MFpTQXZQeU1qSUNVIn0#x" + }, + { + "name": "invalid-utf8", + "uri": "orbyn-notes://note/v1/_w" + }, + { + "name": "invalid-mod4", + "uri": "orbyn-notes://note/v1/A" + } + ], + "unencodable": [ + { + "utf16": [55296] + }, + { + "utf16": [56320] + }, + { + "utf16": [120, 55296, 121] + } + ] +}