// The pocket server: `/api/*` answered inside a phone's WebView. // // What is under test is the whole of what a pocket vault CAN do and the // honesty of what it cannot. Three claims, and each of them is a promise the // product makes out loud in docs/mobile.md: // // 1. The routes the web client depends on to read, write, navigate and // search a vault answer with the SAME wire shapes the server answers // with — because it is the same client, unmodified, and a shape that // drifts is a screen that renders nothing with no error anywhere. // 2. A save carries the same `baseMtimeMs` precondition and answers the // same `409 code:"stale"`. That precondition is the entire reason two // writers on one vault is survivable, and a pocket vault has two by // definition. // 3. What it cannot do, it REFUSES with a reason. A 501 naming the missing // thing is a sentence a reader can act on; an empty list is a vault that // looks broken. // // The filesystem is a Map (tests/helpers/memoryFs.ts) and git is a stub: what // is interesting here is the server, and a server is only interesting in what // it does with a filesystem, not in which one it was given. import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { PocketIndex, isNote } from "../mobile/src/pocket/index.ts"; import { createPocketServer, isoToday, type PocketGit, type PocketRequest, type PocketResponse } from "../mobile/src/pocket/server.ts"; import { createVaultIo, isHiddenPath, safeVaultPath } from "../mobile/src/pocket/vaultIo.ts"; import { makeMemoryFs, type MemoryFs } from "./helpers/memoryFs.ts"; import type { Backlink, MeData, NoteData, PocketSyncStatus, SearchHit, SettingsResponse, TagCount, TreeNode } from "../shared/types.ts"; const ROOT = "/vault"; const VAULT: Record = { "/vault/Welcome.md": [ "---", "title: Welcome", "tags: [start-here, guide]", "date: 2026-01-02", "---", "", "# Welcome", "", "This is the room. It links to [[Ideas/Astrolabe]] and to [[Missing Note]].", "", "![a diagram](Media/diagram.png)", "", "- [ ] read the guide", ].join("\n"), "/vault/Ideas/Astrolabe.md": [ "---", "aliases:", " - The Instrument", "tags:", " - guide", "status: drafting", "---", "", "## On the instrument", "", "An astrolabe measures the sky. #astronomy", "", "Back to [[Welcome]].", ].join("\n"), "/vault/Ideas/قراءة.md": ["# قراءة", "", "الكتابة بالعربيّة مع التشكيل: الكِتَابَة.", ""].join("\n"), "/vault/Media/diagram.png": "\u0089PNG\r\n\u001a\nnot really a png, but bytes", }; function makeServer(seed: Record = VAULT): { call(method: string, url: string, body?: unknown, headers?: Record): Promise; json(method: string, url: string, body?: unknown): Promise; fs: MemoryFs; index: PocketIndex; commits: string[]; } { const fs = makeMemoryFs(seed); const io = createVaultIo(fs, ROOT); const index = new PocketIndex(); const commits: string[] = []; const git: PocketGit = { async commit(message) { commits.push(message); return `sha${commits.length}`; }, async history(path) { return [ { sha: "abc1234def", short: "abc1234", iso: "2026-02-01T10:00:00.000Z", subject: "Astrolabe pocket: a note", path, added: null, removed: null, }, ]; }, async blobAt() { return "the older text\n"; }, }; const held = new Map(); const server = createPocketServer({ io, git, store: { async get(key) { return held.get(key) ?? null; }, async set(key, value) { held.set(key, value); }, }, index, repo: { name: "owner/vault", branch: "main" }, now: () => 1_700_000_000_000, }); const call = async ( method: string, url: string, body?: unknown, headers?: Record, ): Promise => { const request: PocketRequest = { method, url, headers: headers ?? {} }; if (body !== undefined) request.body = JSON.stringify(body); return server.handle(request); }; return { call, async json(method, url, body) { const answer = await call(method, url, body); return JSON.parse(String(answer.body)); }, fs, index, commits, }; } /** Build the index the way `PocketSession.build()` does, so what the routes * read is what a real open would have read. */ async function loaded(seed: Record = VAULT): Promise> { const made = makeServer(seed); const io = createVaultIo(made.fs, ROOT); for (const file of await io.list()) { if (isHiddenPath(file.path)) continue; if (isNote(file.path)) { const content = await io.readText(file.path); if (content !== null) made.index.put(file.path, content, file.mtimeMs); } else made.index.putAsset(file.path, file.size, file.mtimeMs); } return made; } describe("the pocket server — the session", () => { it("is the owner, alone, with no public half", async () => { const server = await loaded(); const me = (await server.json("GET", "/api/me")) as { admin: boolean; public: boolean; protected: boolean; siteName: string }; // Every one of these is load-bearing in the client: `admin` decides // whether the editor exists at all, `protected` whether a sign-in screen // is meaningful, `public` whether visitor scoping is on. assert.equal(me.admin, true); assert.equal(me.protected, false); assert.equal(me.public, false); assert.equal(me.siteName, "owner/vault"); }); it("refuses to pretend there is somebody to sign in as", async () => { const server = await loaded(); const answer = await server.call("POST", "/api/login", { password: "x" }); assert.equal(answer.status, 501); }); }); describe("the pocket server — the tree", () => { it("is folders, then notes, then attachments, alpha within each", async () => { const server = await loaded(); const tree = (await server.json("GET", "/api/tree")) as TreeNode; assert.equal(tree.type, "folder"); const top = (tree.children ?? []).map((n) => `${n.type}:${n.name}`); assert.deepEqual(top, ["folder:Ideas", "folder:Media", "file:Welcome.md"]); const media = (tree.children ?? []).find((n) => n.name === "Media"); const image = media?.children?.[0]; // An attachment carries its kind and size: a folder of images that // expands to nothing reads as "my files are gone". assert.equal(image?.attachment?.kind, "image"); assert.equal(image?.attachment?.ext, "png"); assert.ok((image?.attachment?.size ?? 0) > 0); }); it("never shows the repository", async () => { const server = await loaded({ ...VAULT, "/vault/.git/config": "[core]\n", "/vault/.trash/Old.md": "gone" }); const tree = (await server.json("GET", "/api/tree")) as TreeNode; const names = (tree.children ?? []).map((n) => n.name); assert.ok(!names.includes(".git")); assert.ok(!names.includes(".trash")); }); it("refuses a path that leaves the vault, before it reaches the filesystem", () => { assert.throws(() => safeVaultPath("../../etc/passwd")); assert.throws(() => safeVaultPath("Notes/../../secret")); assert.throws(() => safeVaultPath(".git/config")); assert.throws(() => safeVaultPath("a\u0000b")); assert.equal(safeVaultPath("/Ideas/Astrolabe.md"), "Ideas/Astrolabe.md"); }); }); describe("the pocket server — reading and writing a note", () => { it("answers a note with its bytes and its mtime, and a miss with 404", async () => { const server = await loaded(); const note = (await server.json("GET", "/api/note?path=Welcome.md")) as NoteData; assert.equal(note.path, "Welcome.md"); assert.match(note.content, /# Welcome/); assert.ok(note.mtimeMs > 0); assert.equal((await server.call("GET", "/api/note?path=Nowhere.md")).status, 404); }); it("saves, reindexes and commits — one commit per save", async () => { const server = await loaded(); const before = (await server.json("GET", "/api/note?path=Welcome.md")) as NoteData; server.fs.tick(); const written = (await server.json("PUT", "/api/note?path=Welcome.md", { content: "# Welcome\n\nA changed line about kestrels.\n", baseMtimeMs: before.mtimeMs, })) as NoteData; assert.match(written.content, /kestrels/); assert.equal(server.commits.length, 1); assert.equal(server.commits[0], "Astrolabe pocket: Welcome"); // The index moved with the file: a save that does not reindex is a search // box that answers about yesterday. const hits = (await server.json("GET", "/api/search?q=kestrels")) as SearchHit[]; assert.equal(hits[0]?.path, "Welcome.md"); }); it("REFUSES a save that would overwrite a version the writer never saw", async () => { const server = await loaded(); const first = (await server.json("GET", "/api/note?path=Welcome.md")) as NoteData; server.fs.tick(); await server.call("PUT", "/api/note?path=Welcome.md", { content: "from the laptop\n" }); server.fs.tick(); const answer = await server.call("PUT", "/api/note?path=Welcome.md", { content: "from the phone\n", baseMtimeMs: first.mtimeMs, }); assert.equal(answer.status, 409); assert.equal(JSON.parse(String(answer.body)).code, "stale"); // …and nothing was written. const now = (await server.json("GET", "/api/note?path=Welcome.md")) as NoteData; assert.equal(now.content, "from the laptop\n"); }); it("creates, renames and deletes, and a delete leaves an undo behind", async () => { const server = await loaded(); assert.equal((await server.call("POST", "/api/note", { path: "Scratch.md" })).status, 200); assert.equal((await server.call("POST", "/api/note", { path: "Scratch.md" })).status, 409); assert.equal((await server.call("POST", "/api/rename", { path: "Scratch.md", toPath: "Ideas/Scratch.md" })).status, 200); assert.equal((await server.call("GET", "/api/note?path=Scratch.md")).status, 404); assert.equal((await server.call("GET", "/api/note?path=Ideas/Scratch.md")).status, 200); const deleted = (await server.json("DELETE", "/api/note?path=Ideas/Scratch.md")) as { ok: boolean; trashPath?: string }; assert.equal(deleted.ok, true); assert.ok(deleted.trashPath?.startsWith(".trash/")); const trash = (await server.json("GET", "/api/trash")) as { name: string }[]; assert.equal(trash.length, 1); const restored = (await server.json("POST", "/api/trash/restore", { name: trash[0]?.name })) as { path: string }; assert.equal(restored.path, "Ideas/Scratch.md"); }); it("answers `note/state` for the open tabs and nulls what is gone", async () => { const server = await loaded(); const states = (await server.json( "GET", "/api/note/state?path=Welcome.md&path=Nowhere.md", )) as { states: { path: string; mtimeMs: number | null }[] }; assert.equal(states.states.length, 2); assert.ok((states.states[0]?.mtimeMs ?? 0) > 0); assert.equal(states.states[1]?.mtimeMs, null); }); }); describe("the pocket server — finding things", () => { it("searches titles and bodies, and folds Arabic the way the vault is typed", async () => { const server = await loaded(); const byTitle = (await server.json("GET", "/api/search?q=Astrolabe")) as SearchHit[]; assert.equal(byTitle[0]?.path, "Ideas/Astrolabe.md"); const byBody = (await server.json("GET", "/api/search?q=sky")) as SearchHit[]; assert.ok(byBody.some((hit) => hit.path === "Ideas/Astrolabe.md")); // The note carries `الكِتَابَة` with harakat; the query has none. One key. const arabic = (await server.json("GET", `/api/search?q=${encodeURIComponent("الكتابة")}`)) as SearchHit[]; assert.ok(arabic.some((hit) => hit.path === "Ideas/قراءة.md"), "pointed Arabic and plain Arabic are one key"); }); it("honours the search operators, and narrows to nothing rather than ignoring one", async () => { const server = await loaded(); const tagged = (await server.json("GET", "/api/search?q=tag%3Aguide")) as SearchHit[]; assert.deepEqual(tagged.map((h) => h.path).sort(), ["Ideas/Astrolabe.md", "Welcome.md"]); const books = (await server.json("GET", "/api/search?q=in%3Abooks+sky")) as SearchHit[]; assert.deepEqual(books, [], "an operator the pocket cannot answer means no results, not all results"); }); it("gives a snippet with no raw markdown in it", async () => { const server = await loaded(); const hits = (await server.json("GET", "/api/search?q=instrument")) as SearchHit[]; const snippet = hits[0]?.snippet ?? ""; assert.ok(snippet.length > 0); assert.ok(!snippet.includes("##"), "a heading's hashes never reach the reader"); assert.ok(!/\[\[/.test(snippet), "wikilink brackets never reach the reader"); }); it("resolves a wikilink by the server's rule, and answers a miss with null", async () => { const server = await loaded(); assert.deepEqual(await server.json("GET", "/api/resolve?name=Astrolabe"), { path: "Ideas/Astrolabe.md" }); assert.deepEqual(await server.json("GET", "/api/resolve?name=Ideas/Astrolabe"), { path: "Ideas/Astrolabe.md" }); assert.deepEqual(await server.json("GET", "/api/resolve?name=The%20Instrument"), { path: "Ideas/Astrolabe.md" }); // A miss is 200 with a null, never a 404: unresolved names are ordinary. assert.deepEqual(await server.json("GET", "/api/resolve?name=Missing%20Note"), { path: null }); }); it("carries backlinks with the line they sit on and the sentence around them", async () => { const server = await loaded(); const back = (await server.json("GET", "/api/backlinks?path=Ideas/Astrolabe.md")) as Backlink[]; assert.equal(back.length, 1); assert.equal(back[0]?.path, "Welcome.md"); assert.ok(back[0]?.line && back[0].line > 1, "the line counts in the FULL source, frontmatter included"); assert.match(back[0]?.context ?? "", /the room/); }); it("draws a graph whose edges are resolved links, counted once", async () => { const server = await loaded(); const graph = (await server.json("GET", "/api/graph")) as { nodes: { id: string; links: number }[]; edges: { source: string; target: string }[] }; assert.equal(graph.nodes.length, 3); assert.deepEqual( graph.edges.map((e) => `${e.source}→${e.target}`).sort(), ["Ideas/Astrolabe.md→Welcome.md", "Welcome.md→Ideas/Astrolabe.md"], ); // `[[Missing Note]]` resolves to nothing and draws no edge to nowhere. assert.ok(!graph.edges.some((e) => e.target.includes("Missing"))); }); it("counts tags and properties", async () => { const server = await loaded(); const tags = (await server.json("GET", "/api/tags")) as TagCount[]; assert.equal(tags.find((t) => t.tag === "guide")?.count, 2); assert.equal(tags.find((t) => t.tag === "astronomy")?.count, 1); const props = (await server.json("GET", "/api/props")) as { key: string; values: { value: string }[] }[]; assert.equal(props.find((p) => p.key === "status")?.values[0]?.value, "drafting"); assert.ok(!props.some((p) => p.key === "tags"), "tags have a shelf of their own"); }); }); describe("the pocket server — attachments", () => { it("serves an attachment's bytes with a type, and refuses to serve a note as one", async () => { const server = await loaded(); const answer = await server.call("GET", "/api/file?path=Media/diagram.png"); assert.equal(answer.status, 200); assert.equal(answer.headers["Content-Type"], "image/png"); assert.equal(answer.headers["X-Content-Type-Options"], "nosniff"); assert.ok(answer.body instanceof Uint8Array); assert.equal((await server.call("GET", "/api/file?path=Welcome.md")).status, 400); }); it("answers a byte range, because a book is read a page at a time", async () => { const server = await loaded(); const whole = await server.call("GET", "/api/file?path=Media/diagram.png"); const size = (whole.body as Uint8Array).byteLength; const part = await server.call("GET", "/api/file?path=Media/diagram.png", undefined, { range: "bytes=0-3" }); assert.equal(part.status, 206); assert.equal(part.headers["Content-Range"], `bytes 0-3/${size}`); assert.equal((part.body as Uint8Array).byteLength, 4); const silly = await server.call("GET", "/api/file?path=Media/diagram.png", undefined, { range: "bytes=99999-" }); assert.equal(silly.status, 416); }); }); describe("the pocket server — a voice note is kept, not transcribed (3.24.0)", () => { // A 16-byte WebM header is enough: the pocket sniffs the container and // writes the bytes, it never decodes them. const webm = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42, 0x86, 0x81, 0x01, 0x42, 0xf7, 0x81, 0x01, 0x42, 0xf2, 0x81]).toString("base64"); it("writes the recording into the vault and links it from the day's inbox, in one commit", async () => { const server = await loaded(); const answer = await server.call("POST", "/api/voice", { audio: webm, date: "2026-09-23", time: "14:02" }); assert.equal(answer.status, 200); const job = JSON.parse(String(answer.body)) as { status: string; audio: string; notePath: string; error: string }; assert.equal(job.status, "kept"); assert.equal(job.error, "pocket"); // The pocket's own attachment setting: vault-root, then Voice/. assert.equal(job.audio, "Voice/2026-09-23 1402.webm"); assert.equal(job.notePath, "Inbox/2026-09-23.md"); const note = (await server.json("GET", "/api/note?path=Inbox/2026-09-23.md")) as NoteData; assert.equal(note.content, "- 14:02 — [[Voice/2026-09-23 1402.webm#t=0|🎙]]\n"); const file = await server.call("GET", "/api/file?path=Voice/2026-09-23 1402.webm"); assert.equal(file.status, 200); assert.equal(server.commits.at(-1), "Astrolabe pocket: voice note"); // A second one in the same minute is (2), and joins the same note. const again = JSON.parse(String((await server.call("POST", "/api/voice", { audio: webm, date: "2026-09-23", time: "14:02" })).body)) as { audio: string }; assert.equal(again.audio, "Voice/2026-09-23 1402 (2).webm"); const both = (await server.json("GET", "/api/note?path=Inbox/2026-09-23.md")) as NoteData; assert.equal(both.content.split("\n").filter(Boolean).length, 2); }); it("refuses what is not a recording, and the transcript half is a 501 with the reason", async () => { const server = await loaded(); const bad = await server.call("POST", "/api/voice", { audio: Buffer.from("hello").toString("base64") }); assert.equal(bad.status, 415); for (const route of ["/api/voice/engine", "/api/voice/some-job"]) { const answer = await server.call("GET", route); assert.equal(answer.status, 501); const body = JSON.parse(String(answer.body)) as { error: string; code: string }; assert.equal(body.code, "pocket"); assert.match(body.error, /server/); } }); it("answers the voice settings as a pocket's facts, and refuses to store a model", async () => { const server = await loaded(); const settings = (await server.json("GET", "/api/settings")) as SettingsResponse; assert.deepEqual(settings.effective.voice, { model: "off", backend: "auto", language: "auto", keepAudio: true }); const answer = await server.call("PATCH", "/api/settings", { voice: { model: "small-q5_1" } }); assert.equal(answer.status, 501); }); it("refuses a voices folder and an external speaker with the reason, and scans nothing", async () => { const server = await loaded(); const settings = (await server.json("GET", "/api/settings")) as SettingsResponse; assert.equal(settings.effective.speak.voicesDir, null); assert.equal(settings.effective.speak.external, null); for (const speak of [{ voicesDir: "/home/me/piper-voices" }, { external: { command: "piper --output_file {out}", langs: ["fr"] } }]) { const answer = await server.call("PATCH", "/api/settings", { speak }); assert.equal(answer.status, 501); const body = JSON.parse(String(answer.body)) as { error: string; code: string; fields: string[] }; assert.equal(body.code, "pocket"); assert.match(body.error, /server/); assert.deepEqual(body.fields, [`speak.${Object.keys(speak)[0]}`]); } const rescan = await server.call("POST", "/api/speak/voices/rescan"); assert.equal(rescan.status, 501); }); }); describe("the pocket server — a captured line (3.27.0)", () => { // Today's capture field on the phone shell asks the pocket what it asks an // instance: `POST /api/capture`, one line under `## Captured`, the same // bytes shared/capture.ts writes on the server, and the same refusal of a // note that moved under the index. it("appends under ## Captured in the note named, and commits it like a save", async () => { const server = await loaded(); const answer = await server.call("POST", "/api/capture", { text: "call the binder", path: "Welcome.md", time: "09:15" }); assert.equal(answer.status, 200); assert.deepEqual(JSON.parse(String(answer.body)), { ok: true, path: "Welcome.md" }); const note = (await server.json("GET", "/api/note?path=Welcome.md")) as NoteData; assert.match(note.content, /\n## Captured\n\n- 09:15 call the binder\n$/); assert.equal(server.commits.at(-1), "Astrolabe pocket: Welcome"); // A second line joins the same section. await server.call("POST", "/api/capture", { text: "and the framer", path: "Welcome.md", time: "09:20" }); const again = (await server.json("GET", "/api/note?path=Welcome.md")) as NoteData; assert.match(again.content, /- 09:15 call the binder\n- 09:20 and the framer\n$/); }); it("writes the day's inbox — where the share sheet and a kept voice note go — when no note is named", async () => { const server = await loaded(); const answer = await server.call("POST", "/api/capture", { text: "a thought", time: "22:40" }); assert.equal(answer.status, 200); const { path } = JSON.parse(String(answer.body)) as { path: string }; assert.equal(path, `Inbox/${isoToday(1_700_000_000_000)}.md`); const note = (await server.json("GET", `/api/note?path=${encodeURIComponent(path)}`)) as NoteData; assert.equal(note.content, "## Captured\n\n- 22:40 a thought\n"); // …and the tree has it at once: the index took the write. const tree = JSON.stringify(await server.json("GET", "/api/tree")); assert.ok(tree.includes(path)); }); it("refuses an empty line, a path that is not a note, and a note that moved under the index", async () => { const server = await loaded(); const empty = await server.call("POST", "/api/capture", { text: " " }); assert.equal(empty.status, 400); assert.equal(JSON.parse(String(empty.body)).code, "captureEmpty"); assert.equal((await server.call("POST", "/api/capture", { text: "x", path: "Media/diagram.png" })).status, 400); // A sync pull rewrote the file behind the index's back. server.fs.tick(); await server.fs.promises.writeFile(`${ROOT}/Welcome.md`, "from the laptop\n", "utf8"); const stale = await server.call("POST", "/api/capture", { text: "lost?", path: "Welcome.md", time: "10:00" }); assert.equal(stale.status, 409); assert.equal(JSON.parse(String(stale.body)).code, "stale"); const text = await server.fs.promises.readFile(`${ROOT}/Welcome.md`, "utf8"); assert.equal(text, "from the laptop\n"); }); }); describe("the pocket server — git is the version history", () => { it("lists a note's past and can read one version back", async () => { const server = await loaded(); const history = (await server.json("GET", "/api/history?path=Welcome.md")) as { repo: boolean; revisions: { sha: string }[] }; assert.equal(history.repo, true); assert.equal(history.revisions[0]?.sha, "abc1234def"); const at = Date.parse("2026-02-01T10:00:00.000Z"); const one = (await server.json("GET", `/api/versions/one?path=Welcome.md&at=${at}`)) as { content: string }; assert.equal(one.content, "the older text\n"); const restored = (await server.json("POST", "/api/versions/restore", { path: "Welcome.md", at })) as NoteData; assert.equal(restored.content, "the older text\n"); }); }); describe("the pocket server — what it cannot do, it says", () => { const cases: [string, string][] = [ ["POST", "/api/publish"], ["GET", "/api/posts"], ["GET", "/api/comments?path=Welcome.md"], ["GET", "/api/clip/token"], ["POST", "/api/fonts/upload"], ["GET", "/api/sync/status"], ["GET", "/api/design/active"], ["GET", "/api/export"], ["POST", "/api/replace"], ["GET", "/api/feeds"], ["POST", "/api/feeds/keep"], ["GET", "/api/webmentions"], ["GET", "/api/webmentions/status"], ["POST", "/api/webmentions/1/verify"], ["POST", "/api/import/preview"], ]; for (const [method, route] of cases) { it(`${method} ${route} is a 501 with a reason in it`, async () => { const server = await loaded(); const answer = await server.call(method, route, method === "GET" ? undefined : {}); assert.equal(answer.status, 501); const body = JSON.parse(String(answer.body)) as { error: string; code: string }; assert.equal(body.code, "pocket"); // Not a stub sentence: it has to name the thing that is missing. assert.ok(body.error.length > 30, `"${body.error}" does not explain anything`); assert.ok(/server|visitor|public|network|disk|data directory|whole vault|phone|repository|corpus|installation/i.test(body.error)); }); } it("an unknown route is a plain 404, as it is on the server", async () => { const server = await loaded(); assert.equal((await server.call("GET", "/api/nonsense")).status, 404); }); }); describe("the pocket server — device state stays on the device", () => { it("merges preferences by their timestamps and hands the whole map back", async () => { const server = await loaded(); await server.json("PUT", "/api/prefs", { keys: { "astrolabe.theme": { v: "sumi", t: 10 } } }); const older = (await server.json("PUT", "/api/prefs", { keys: { "astrolabe.theme": { v: "linen", t: 5 } }, })) as { keys: Record }; assert.equal(older.keys["astrolabe.theme"]?.v, "sumi", "the newest write wins, whichever order it arrived in"); const newer = (await server.json("PUT", "/api/prefs", { keys: { "astrolabe.theme": { v: "linen", t: 20 } }, })) as { keys: Record }; assert.equal(newer.keys["astrolabe.theme"]?.v, "linen"); }); it("keeps the workspace, and refuses what is not one", async () => { const server = await loaded(); assert.deepEqual(await server.json("PUT", "/api/state/workspace", { workspace: { panes: [] } }), { ok: true }); assert.deepEqual(await server.json("GET", "/api/state/workspace"), { workspace: { panes: [] } }); assert.deepEqual(await server.json("PUT", "/api/state/workspace", { workspace: "nope" }), { ok: false }); }); }); describe("the pocket server — the vault by date (Today, the Timeline)", () => { it("answers the Timeline's notes with the day each belongs to", async () => { const server = await loaded({ ...VAULT, "/vault/Inbox/2026-01-05.md": "- 09:10 — a line [[Voice/1.webm#t=0|🎙]]\n- 09:30 — another\n", }); const rows = (await server.json("GET", "/api/timeline")) as { path: string; day: string | null; excerpt: string; captured: number; voice: number }[]; const welcome = rows.find((r) => r.path === "Welcome.md"); assert.equal(welcome?.day, "2026-01-02", "the frontmatter's day names itself"); assert.match(welcome?.excerpt ?? "", /This is the room/); const inbox = rows.find((r) => r.path === "Inbox/2026-01-05.md"); assert.deepEqual([inbox?.captured, inbox?.voice], [2, 1]); }); it("answers on this day with the excerpt, earlier years only", async () => { const server = await loaded(); const hits = (await server.json("GET", "/api/onthisday?date=2027-01-02")) as { path: string; year: number; excerpt: string }[]; assert.deepEqual(hits.map((h) => [h.path, h.year]), [["Welcome.md", 2026]]); assert.match(hits[0]?.excerpt ?? "", /This is the room/); assert.deepEqual(await server.json("GET", "/api/onthisday?date=2026-01-02"), []); }); }); describe("the pocket server — the shelves that are note-based", () => { it("finds the tasks in the vault", async () => { const server = await loaded(); const tasks = (await server.json("GET", "/api/tasks")) as { path: string; task: { done: boolean } }[]; assert.equal(tasks.length, 1); assert.equal(tasks[0]?.path, "Welcome.md"); assert.equal(tasks[0]?.task.done, false); }); it("ticks a task by rewriting exactly its line", async () => { const server = await loaded(); const tasks = (await server.json("GET", "/api/tasks")) as { path: string; task: { line: number } }[]; const line = tasks[0]?.task.line ?? 0; await server.json("POST", "/api/task", { path: "Welcome.md", line, done: true, today: "2026-02-02" }); const note = (await server.json("GET", "/api/note?path=Welcome.md")) as NoteData; assert.match(note.content, /- \[x\] read the guide/); assert.match(note.content, /# Welcome/, "the rest of the note is the bytes it was"); }); it("always has an Orbits shelf to point at, even with no deck fence", async () => { const server = await loaded(); const decks = (await server.json("GET", "/api/orbits?today=2026-02-02")) as { path: string; implicit: boolean }[]; assert.equal(decks.at(-1)?.path, "*"); assert.equal(decks.at(-1)?.implicit, true); }); }); // ─────────────────────────────────────────────────────────────────────────── // THE SETTINGS, AND THE VAULT'S OWN SYNC (3.22.2) // // A friend of the owner installed the APK, opened a vault from GitHub and // reported, in Arabic: "the sync settings don't save — whatever I type and // press Save, it doesn't save; the sync switch keeps turning itself back off." // Two things were wrong at once, and both are pinned below. // // 1. Settings were kept in the phone's own key-value store, so nothing a // reader chose ever reached the laptop over the same repository. They go // into `.astrolabe/settings.json` now — the file server/configMirror.ts // mirrors for an instance, in the same shape — and are committed like a // note save, so a push carries them. // 2. The git-sync rows described a SERVER driving git at a remote, which a // pocket vault has not got. The PATCH refuses those keys with the reason // rather than accepting them and answering a fixed block back. // ─────────────────────────────────────────────────────────────────────────── /** One file out of the working tree, by the same rules the server reads it. */ function vaultFile(fs: MemoryFs, rel: string): Promise { return createVaultIo(fs, ROOT).readText(rel); } /** A SECOND server over the SAME filesystem: a phone reopened, or the laptop * pulling. Its device store is empty, which is the whole point — nothing that * survives here survived in memory. */ function reopened(fs: MemoryFs): { json(method: string, url: string, body?: unknown): Promise; index: PocketIndex } { const index = new PocketIndex(); const server = createPocketServer({ io: createVaultIo(fs, ROOT), git: { async commit() { return null; }, async history() { return []; }, async blobAt() { return null; }, }, store: { async get() { return null; }, async set() { /* a fresh device remembers nothing */ }, }, index, repo: { name: "owner/vault", branch: "main" }, now: () => 1_700_000_000_000, }); return { async json(method, url, body) { const request: PocketRequest = { method, url, headers: {} }; if (body !== undefined) request.body = JSON.stringify(body); return JSON.parse(String((await server.handle(request)).body)); }, index, }; } describe("the pocket server — settings that travel with the vault", () => { it("writes them into the repository and reads them back from a fresh server", async () => { const first = await loaded(); const saved = (await first.json("PATCH", "/api/settings", { siteName: "The Canopy", dateCalendar: "hijri", dailyFolder: "journal", })) as SettingsResponse; assert.equal(saved.effective.siteName, "The Canopy"); assert.equal(saved.effective.dateCalendar, "hijri"); // IN THE VAULT, not on the phone: the same file, in the same shape, that // every instance mirrors into `/.astrolabe/`. const onDisk = await vaultFile(first.fs, ".astrolabe/settings.json"); assert.ok(onDisk !== null, "the settings file is in the working tree"); assert.deepEqual(JSON.parse(onDisk), { siteName: "The Canopy", dateCalendar: "hijri", dailyFolder: "journal", }); // Committed like a note save, so a push carries it to the laptop. assert.ok(first.commits.includes("Astrolabe pocket: settings")); // The reload the friend performed. A new server over the same filesystem, // with an empty device store: everything survives, because none of it was // ever on the device. const again = reopened(first.fs); const read = (await again.json("GET", "/api/settings")) as SettingsResponse; assert.equal(read.effective.siteName, "The Canopy"); assert.equal(read.effective.dateCalendar, "hijri"); assert.equal(read.effective.dailyFolder, "journal"); }); it("clears a key with null and leaves the rest standing", async () => { const server = await loaded(); await server.json("PATCH", "/api/settings", { siteName: "The Canopy", tagline: "notes" }); const after = (await server.json("PATCH", "/api/settings", { tagline: null })) as SettingsResponse; assert.equal(after.effective.siteName, "The Canopy"); assert.equal(after.effective.tagline, null); }); it("carries the properties card's switches on /api/me, so a hidden card stays hidden on the phone (3.40.0)", async () => { const server = await loaded(); const before = (await server.json("GET", "/api/me")) as MeData; assert.equal(before.propsCard, undefined, "a default vault says nothing"); await server.json("PATCH", "/api/settings", { propsCard: false, emptyPropsCard: false }); const me = (await reopened(server.fs).json("GET", "/api/me")) as MeData; assert.equal(me.propsCard, false); assert.equal(me.emptyPropsCard, false); const back = (await server.json("PATCH", "/api/settings", { propsCard: null })) as SettingsResponse; assert.equal(back.effective.propsCard, true); assert.equal(((await server.json("GET", "/api/me")) as MeData).propsCard, undefined); }); it("never shows the settings file as part of the vault", async () => { const server = await loaded(); await server.json("PATCH", "/api/settings", { siteName: "The Canopy" }); const again = reopened(server.fs); const io = createVaultIo(server.fs, ROOT); for (const file of await io.list()) { if (isHiddenPath(file.path)) continue; if (isNote(file.path)) again.index.put(file.path, String(await io.readText(file.path)), file.mtimeMs); else again.index.putAsset(file.path, file.size, file.mtimeMs); } const tree = (await again.json("GET", "/api/tree")) as TreeNode; assert.ok(!(tree.children ?? []).some((n) => n.name === ".astrolabe")); }); it("refuses the git-sync fields with the reason, and writes nothing at all", async () => { const server = await loaded(); // The friend's exact edit: a remote, a branch and the master switch. const answer = await server.call("PATCH", "/api/settings", { gitSync: { enabled: true, remote: "https://example.com/v.git", branch: "main" }, siteName: "The Canopy", }); assert.equal(answer.status, 501); const body = JSON.parse(String(answer.body)) as { error: string; code: string; fields: string[] }; assert.equal(body.code, "pocket"); assert.deepEqual(body.fields, ["gitSync"]); assert.match(body.error, /Backup & sync/); // NOTHING was written — not even the legal half of the patch. A save that // lands halfway and says nothing is the reported bug, restated. assert.equal(await vaultFile(server.fs, ".astrolabe/settings.json"), null); const read = (await server.json("GET", "/api/settings")) as SettingsResponse; assert.notEqual(read.effective.siteName, "The Canopy"); }); it("refuses every key that needs an instance behind it, in a sentence", async () => { const server = await loaded(); for (const patch of [ { gitToken: "ghp_x" }, { commentsEnabled: true }, { publicLayout: "blog" }, { languageFilter: "ar" }, { favicon: "Media/icon.png" }, { fonts: { prose: "lora" } }, { noteVersions: false }, { pdfSearch: true }, { feeds: { fetch: true } }, { webmentions: { accept: true } }, { fediverse: { enabled: true } }, ]) { const answer = await server.call("PATCH", "/api/settings", patch); assert.equal(answer.status, 501, `${Object.keys(patch)[0]} must be refused`); const body = JSON.parse(String(answer.body)) as { error: string }; assert.ok(body.error.length > 20, "the refusal is a sentence, not a code"); } }); it("answers a laptop's public-site settings as the facts a phone actually has", async () => { // The repository has been open on an instance, so the file carries a // public site's configuration. The phone must describe ITSELF. const server = await loaded({ ...VAULT, "/vault/.astrolabe/settings.json": JSON.stringify({ siteName: "The Canopy", commentsEnabled: true, defaultTheme: "cinnabar", footer: "© {year}", fonts: { prose: "lora" }, }), }); const read = (await server.json("GET", "/api/settings")) as SettingsResponse; assert.equal(read.effective.siteName, "The Canopy", "the vault's name IS the vault's"); assert.equal(read.effective.commentsEnabled, false); assert.equal(read.effective.defaultTheme, "follow"); assert.equal(read.effective.footer, null); assert.equal(read.effective.fonts.prose, "system"); // And the STORED half, which is what prefills the panel's fields. assert.equal(read.commentsEnabled, undefined); }); }); describe("the pocket server — the vault's own sync", () => { const status: PocketSyncStatus = { repo: "owner/vault", branch: "main", phase: "idle", ahead: 2, syncedAtMs: 1_699_999_000_000, online: true, error: null, conflicts: [{ path: "Welcome.md", phonePath: "Welcome (phone).md" }], }; /** A server with a shell under it, the way pocket/session.ts builds one. */ function withShell(): { call(method: string, url: string): Promise; left(): boolean } { const fs = makeMemoryFs(VAULT); let left = false; const server = createPocketServer({ io: createVaultIo(fs, ROOT), git: { async commit() { return null; }, async history() { return []; }, async blobAt() { return null; }, }, store: { async get() { return null; }, async set() {}, }, index: new PocketIndex(), repo: { name: "owner/vault", branch: "main" }, shell: { syncState: () => status, syncNow: async () => ({ ...status, ahead: 0, syncedAtMs: 1_700_000_000_000 }), leave: async () => { left = true; }, }, now: () => 1_700_000_000_000, }); return { call: (method, url) => server.handle({ method, url, headers: {} }), left: () => left, }; } it("answers the state the shell's own line is painted from", async () => { const { call } = withShell(); const answer = await call("GET", "/api/pocket/sync"); assert.equal(answer.status, 200); // Every field is load-bearing in the panel: the repository and the branch // name the vault, and the other five decide which of nine sentences the // line is (shared/pocketSync.ts). assert.deepEqual(JSON.parse(String(answer.body)), status); }); it("pulls and pushes, and answers the state afterwards", async () => { const { call } = withShell(); const body = JSON.parse(String((await call("POST", "/api/pocket/sync")).body)) as PocketSyncStatus; assert.equal(body.ahead, 0); assert.equal(body.syncedAtMs, 1_700_000_000_000); }); it("forgets the vault when asked, and only when asked", async () => { const { call, left } = withShell(); assert.equal(left(), false); assert.deepEqual(JSON.parse(String((await call("POST", "/api/pocket/leave")).body)), { ok: true }); assert.equal(left(), true); }); it("refuses with a reason when there is no shell under it", async () => { const server = await loaded(); for (const [method, route] of [ ["GET", "/api/pocket/sync"], ["POST", "/api/pocket/sync"], ["POST", "/api/pocket/leave"], ] as [string, string][]) { const answer = await server.call(method, route); assert.equal(answer.status, 501); assert.equal((JSON.parse(String(answer.body)) as { code: string }).code, "pocket"); } }); it("still refuses the SERVER's sync routes, which are a different thing", async () => { const server = await loaded(); for (const route of ["/api/sync/status", "/api/sync/now", "/api/sync/travel"]) { assert.equal((await server.call("GET", route)).status, 501); } }); it("tells the client where it is, so the panel can stop offering a server", async () => { const server = await loaded(); const me = (await server.json("GET", "/api/me")) as { pocket?: boolean }; assert.equal(me.pocket, true); }); });