diff --git a/packages/extension/scripts/amicode_fixture_seed.mjs b/packages/extension/scripts/amicode_fixture_seed.mjs index 0cf9cf677..fb6c24b86 100644 --- a/packages/extension/scripts/amicode_fixture_seed.mjs +++ b/packages/extension/scripts/amicode_fixture_seed.mjs @@ -285,6 +285,18 @@ export function seedAmicodeSandbox(dir) { ].join("\n"), ); + // --- library (uploaded papers; added_ms = mtime → pinned epochs) ------------- + const libraryDir = join(amico, "library"); + mkdirSync(libraryDir, { recursive: true }); + const paper = (name, content, epoch) => { + const p = join(libraryDir, name); + writeFileSync(p, content); + const t = new Date(epoch); + utimesSync(p, t, t); + }; + paper("rydberg-blockade-2024.pdf", "%PDF-1.4 fake rydberg paper\n", "2026-08-02T00:00:00Z"); + paper("piccolo-trajectory-2023.pdf", "%PDF-1.4 fake piccolo paper\n", "2026-08-06T00:00:00Z"); + // --- stub PATH: `amico` exists (fixed output → deterministic approve fixture), // `amico-vault` does NOT (forces the CLI-less scanMounts path on both // sides, so fixtures never depend on a real CLI being installed). ---------- @@ -304,6 +316,7 @@ export function seedAmicodeSandbox(dir) { // `amico ledger approve` behave identically regardless of the host machine. return { dir, + seededAt: Date.now(), env: { AMICODE_PROFILE_FILE: join(amico, "profile.json"), AMICODE_MOUNTS_FILE: join(amico, "mounts.toml"), @@ -313,6 +326,7 @@ export function seedAmicodeSandbox(dir) { AMICO_VAULTS_ROOT: vaults, AMICO_LEDGER: join(ledger, "runs.jsonl"), AMICODE_PROJECT_DIR: dir, + AMICODE_LIBRARY_DIR: libraryDir, PATH: stubbin, }, }; diff --git a/packages/extension/scripts/record_amicode_fixtures.mjs b/packages/extension/scripts/record_amicode_fixtures.mjs index 41efeac6d..1e21b3b13 100644 --- a/packages/extension/scripts/record_amicode_fixtures.mjs +++ b/packages/extension/scripts/record_amicode_fixtures.mjs @@ -130,6 +130,27 @@ const REQUESTS = [ { method: "GET", path: "/amicode/run-series?run=r20260818-000000Z-4h5i6j", name: "run series — stalled (cold run.log)" }, { method: "GET", path: "/amicode/run-series?run=r20260812-000000Z-5c6d7e&lab=other", name: "run series — explicit lab" }, { method: "GET", path: "/amicode/run-series?run=no-such-run", name: "run series — not_found" }, + // ── library (slice 4) ────────────────────────────────────────────────────── + { method: "GET", path: "/amicode/library", name: "library — seeded papers, newest first" }, + { + method: "POST", + path: "/amicode/library", + body: { filename: "new paper.pdf", data_b64: Buffer.from("%PDF-1.4 uploaded paper body\n").toString("base64") }, + name: "library upload — valid PDF (refreshed listing)", + }, + { + method: "POST", + path: "/amicode/library", + body: { filename: "not-a-pdf.txt", data_b64: Buffer.from("plain text\n").toString("base64") }, + name: "library upload — bad_filetype refusal", + }, + { + method: "POST", + path: "/amicode/library", + body: { filename: "ok.pdf" }, + name: "library upload — missing data_b64 refusal", + }, + { method: "GET", path: "/amicode/library", name: "library — post-upload state" }, ]; async function main() { @@ -139,7 +160,7 @@ async function main() { console.log(`[record] fork binary: ${binary} (${version})`); const sandbox = mkdtempSync(join(tmpdir(), "amicode-fixture-")); - const { env: seedEnv } = seedAmicodeSandbox(sandbox); + const { env: seedEnv, seededAt } = seedAmicodeSandbox(sandbox); const password = "fixture-recording-password"; const port = 4601 + Math.floor(Math.random() * 400); @@ -227,6 +248,7 @@ async function main() { // normalizes these to on both sides before comparing. sandbox: sandbox, sandboxReal: realpathSync(sandbox), + seededAt: seededAt, // added_ms of route-WRITTEN files (uploads) is wall-clock — normalized at replay entries, }, null, diff --git a/packages/extension/src/amicode_service/index.ts b/packages/extension/src/amicode_service/index.ts index 1e478134e..ff8904b04 100644 --- a/packages/extension/src/amicode_service/index.ts +++ b/packages/extension/src/amicode_service/index.ts @@ -27,6 +27,7 @@ import { runSeriesResponse, runStatusResponse, } from "./problems"; +import { libraryBody, saveLibraryFile } from "./library"; export function registerProfileRoutes(server: AmicodeServiceServer): AmicodeServiceServer { server.add("GET", "/amicode/profile", () => ({ body: profileResponse() })); @@ -98,6 +99,14 @@ export function registerProblemRoutes(server: AmicodeServiceServer): AmicodeServ return server; } +export function registerLibraryRoutes(server: AmicodeServiceServer): AmicodeServiceServer { + server.add("GET", "/amicode/library", () => ({ body: libraryBody() })); + + server.add("POST", "/amicode/library", ({ body }) => ({ body: saveLibraryFile(body) })); + + return server; +} + /** The service with every ported slice mounted. The extension wiring slice * boots this at activation; the contract tests boot it in-process. */ export function createAmicodeService(opts: { password?: string } = {}): AmicodeServiceServer { @@ -105,5 +114,6 @@ export function createAmicodeService(opts: { password?: string } = {}): AmicodeS registerProfileRoutes(server); registerVaultRoutes(server); registerProblemRoutes(server); + registerLibraryRoutes(server); return server; } diff --git a/packages/extension/src/amicode_service/library.ts b/packages/extension/src/amicode_service/library.ts new file mode 100644 index 000000000..96967f7c0 --- /dev/null +++ b/packages/extension/src/amicode_service/library.ts @@ -0,0 +1,83 @@ +import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs" +import os from "os" +import path from "path" + +// AMICODE: the user's paper library — PDFs uploaded from the home page that +// make Amico smarter about THIS user's work. Files land in ~/.amico/library; +// the amicode extension grants the agent's file tools read access to that dir, +// so "read the paper I just added" works with zero further plumbing. Same +// never-reject discipline as problems.ts: every body is a JSON string. + +export function libraryRoot(): string { + const env = process.env.AMICODE_LIBRARY_DIR + if (env && env.trim() !== "") return env + return path.join(os.homedir(), ".amico", "library") +} + +export function synthesizeLibrary(code: string, detail: string): string { + return JSON.stringify({ ok: false, papers: [], error: `${code}: ${detail}` }) +} + +const MAX_BYTES = 30 * 1024 * 1024 // a 30MB PDF is a book; bigger is a mistake + +/** Basename-only, conservative charset, single .pdf suffix. */ +export function sanitizeFilename(raw: string): string | null { + const base = path.basename(raw).trim() + if (!/\.pdf$/i.test(base)) return null + const clean = base + .slice(0, -4) + .replace(/[^\w.\- ]+/g, "-") + .replace(/\s+/g, " ") + .trim() + .slice(0, 120) + return clean === "" ? null : `${clean}.pdf` +} + +export function libraryBody(root: string = libraryRoot()): string { + try { + if (!existsSync(root)) return JSON.stringify({ ok: true, papers: [], error: null }) + const papers = readdirSync(root) + .filter((f) => f.toLowerCase().endsWith(".pdf")) + .map((f) => { + const st = statSync(path.join(root, f)) + return { name: f, size: st.size, added_ms: Math.round(st.mtimeMs), path: path.join(root, f) } + }) + .sort((a, b) => b.added_ms - a.added_ms) + return JSON.stringify({ ok: true, papers, error: null }) + } catch (err) { + return synthesizeLibrary("bad_output", String(err)) + } +} + +/** Save one uploaded paper (JSON body: {filename, data_b64}). Returns the + * refreshed listing on success so the client renders in one round-trip. */ +export function saveLibraryFile(rawBody: string, root: string = libraryRoot()): string { + let parsed: { filename?: unknown; data_b64?: unknown } + try { + parsed = JSON.parse(rawBody) + } catch { + return synthesizeLibrary("bad_request", "body must be JSON {filename, data_b64}") + } + if (typeof parsed.filename !== "string" || typeof parsed.data_b64 !== "string") + return synthesizeLibrary("bad_request", "filename and data_b64 are required strings") + const name = sanitizeFilename(parsed.filename) + if (!name) return synthesizeLibrary("bad_filename", "PDFs only; name must survive sanitization") + let bytes: Buffer + try { + bytes = Buffer.from(parsed.data_b64, "base64") + } catch { + return synthesizeLibrary("bad_request", "data_b64 is not valid base64") + } + if (bytes.length === 0) return synthesizeLibrary("bad_request", "empty file") + if (bytes.length > MAX_BYTES) return synthesizeLibrary("too_large", `max ${MAX_BYTES} bytes`) + // magic check: every real PDF opens with %PDF- + if (!bytes.subarray(0, 5).equals(Buffer.from("%PDF-"))) + return synthesizeLibrary("bad_filetype", "not a PDF (missing %PDF- header)") + try { + mkdirSync(root, { recursive: true }) + writeFileSync(path.join(root, name), bytes) + } catch (err) { + return synthesizeLibrary("write_failed", String(err)) + } + return libraryBody(root) +} diff --git a/packages/extension/test/amicode_service_contract.test.ts b/packages/extension/test/amicode_service_contract.test.ts index 4f58a7488..5af495b4c 100644 --- a/packages/extension/test/amicode_service_contract.test.ts +++ b/packages/extension/test/amicode_service_contract.test.ts @@ -25,7 +25,7 @@ import { createAmicodeService } from "../src/amicode_service"; const FIXTURE = fileURLToPath(new URL("./fixtures/amicode/golden.json", import.meta.url)); // Loaded at module scope: the it() cases are GENERATED at collection time, // before beforeAll runs. -const META: { fork: { tag: string }; sandbox: string; sandboxReal: string; entries: any[] } = +const META: { fork: { tag: string }; sandbox: string; sandboxReal: string; seededAt: number; entries: any[] } = JSON.parse(readFileSync(FIXTURE, "utf8")); describe("amicode service — golden-fixture parity with the fork", () => { @@ -35,6 +35,7 @@ describe("amicode service — golden-fixture parity with the fork", () => { let base: string; let auth: string; let meta = META; + let testSeededAt = 0; /** Normalize the sandbox roots (unresolved + realpath forms) to . */ const normalize = (text: string, dirs: string[]): string => { @@ -63,6 +64,21 @@ describe("amicode service — golden-fixture parity with the fork", () => { * so the value legitimately differs between recording and replay. It is the * ONLY wall-clock field; terminal runs pin FINISHED mtimes to fixed epochs * and stay exactly comparable. */ + /** added_ms on a route-WRITTEN paper (an upload) is its write time — + * wall-clock, legitimately different between recording and replay. Seeded + * papers have pinned epochs and compare exactly; anything newer than this + * side's seed time normalizes to on both sides. Sort order is + * unaffected (fresh > pinned). */ + const normalizeFreshTimestamps = (obj: any, seededAt: number): any => { + if (obj && typeof obj === "object" && Array.isArray(obj.papers)) { + const papers = obj.papers.map((p: any) => + typeof p?.added_ms === "number" && p.added_ms > seededAt ? { ...p, added_ms: "" } : p, + ); + return { ...obj, papers }; + } + return obj; + }; + const normalizeWallClock = (obj: any): any => { if ( obj && @@ -78,9 +94,9 @@ describe("amicode service — golden-fixture parity with the fork", () => { }; beforeAll(async () => { - meta = JSON.parse(readFileSync(FIXTURE, "utf8")); sandbox = mkdtempSync(join(tmpdir(), "amicode-parity-")); - const { env } = seedAmicodeSandbox(sandbox); + const { env, seededAt } = seedAmicodeSandbox(sandbox); + testSeededAt = seededAt; // Redirect every state root to the sandbox (the port resolves these at // call time). PATH is pinned to the seeded stub dir on BOTH sides (the // recorder pins it for the fork spawn) so `amico` is the stub and @@ -136,8 +152,9 @@ describe("amicode service — golden-fixture parity with the fork", () => { const received = normalize(await r.text(), [sandbox, realpathSync(sandbox)]); // Deep-equal on parsed JSON: key order in the serialized body is the // port's business, structure and values are the contract. - const canon = (o: any) => normalizeListOrder(normalizeWallClock(o)); - expect(canon(JSON.parse(received))).toEqual(canon(JSON.parse(expected))); + const canon = (o: any, seededAt: number) => + normalizeListOrder(normalizeWallClock(normalizeFreshTimestamps(o, seededAt))); + expect(canon(JSON.parse(received), testSeededAt)).toEqual(canon(JSON.parse(expected), meta.seededAt)); }); } diff --git a/packages/extension/test/fixtures/amicode/golden.json b/packages/extension/test/fixtures/amicode/golden.json index eafe32bee..331fa49a1 100644 --- a/packages/extension/test/fixtures/amicode/golden.json +++ b/packages/extension/test/fixtures/amicode/golden.json @@ -1,11 +1,12 @@ { - "recordedAt": "2026-08-20T20:40:09.600Z", + "recordedAt": "2026-08-20T20:47:23.374Z", "fork": { "version": "1.18.10", "tag": "v1.18.10-amicode.11" }, - "sandbox": "/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-b9qBZY", - "sandboxReal": "/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-b9qBZY", + "sandbox": "/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-ctt9wd", + "sandboxReal": "/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-ctt9wd", + "seededAt": 1787258842274, "entries": [ { "name": "cold read — synthesized identity + stats + remembers", @@ -62,7 +63,7 @@ } }, "status": 200, - "body": "{\"ok\":true,\"name\":\"attachable-demo\",\"kind\":\"personal\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-b9qBZY/vaults/attachable-demo\"}" + "body": "{\"ok\":true,\"name\":\"attachable-demo\",\"kind\":\"personal\",\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-ctt9wd/vaults/attachable-demo\"}" }, { "name": "vaults — post-attach (cache bust, new mount)", @@ -156,7 +157,7 @@ "path": "/amicode/resolve-file?path=%7BSANDBOX%7D%2Fdocs%2Freadme.md" }, "status": 200, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-b9qBZY/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-ctt9wd/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" }, { "name": "resolve mount-prefixed (tier 3)", @@ -165,7 +166,7 @@ "path": "/amicode/resolve-file?path=personal-main%2Fnotes%2Fnote.md" }, "status": 200, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-b9qBZY/vaults/personal-main/notes/note.md\",\"mount\":\"personal-main\",\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/private/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-ctt9wd/vaults/personal-main/notes/note.md\",\"mount\":\"personal-main\",\"kind\":\"file\"}" }, { "name": "resolve relative w/ dir part (tier 4 → project dir)", @@ -174,7 +175,7 @@ "path": "/amicode/resolve-file?path=docs%2Freadme.md" }, "status": 200, - "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-b9qBZY/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" + "body": "{\"ok\":true,\"found\":true,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-ctt9wd/docs/readme.md\",\"mount\":null,\"kind\":\"file\"}" }, { "name": "resolve bare typed-prefix — miss (tier 5)", @@ -291,7 +292,7 @@ "path": "/amicode/run-series?run=r20260810-000000Z-3d4e5f" }, "status": 200, - "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260810-000000Z-3d4e5f\",\"lab\":\"default\",\"status\":\"solving\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.35,\"last_f\":0.35,\"elapsed_ms\":938407998,\"series\":[{\"iter\":1,\"f\":0.4},{\"iter\":2,\"f\":0.35}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=4.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=3.5e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" + "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260810-000000Z-3d4e5f\",\"lab\":\"default\",\"status\":\"solving\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.35,\"last_f\":0.35,\"elapsed_ms\":938842272,\"series\":[{\"iter\":1,\"f\":0.4},{\"iter\":2,\"f\":0.35}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=4.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=3.5e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" }, { "name": "run series — failed (result.toml ≠ finished)", @@ -309,7 +310,7 @@ "path": "/amicode/run-series?run=r20260818-000000Z-4h5i6j" }, "status": 200, - "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260818-000000Z-4h5i6j\",\"lab\":\"default\",\"status\":\"stalled\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.59,\"last_f\":0.59,\"elapsed_ms\":240007999,\"series\":[{\"iter\":1,\"f\":0.6},{\"iter\":2,\"f\":0.59}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=6.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=5.9e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" + "body": "{\"ok\":true,\"run\":{\"run_id\":\"r20260818-000000Z-4h5i6j\",\"lab\":\"default\",\"status\":\"stalled\",\"iteration\":2,\"fidelity\":null,\"best_f\":0.59,\"last_f\":0.59,\"elapsed_ms\":240442273,\"series\":[{\"iter\":1,\"f\":0.6},{\"iter\":2,\"f\":0.59}],\"pulse\":{\"iter\":2,\"dt\":0.2,\"values\":[0.01,0.02,0.03,0.04,0.05,0.06]},\"pulse_meta\":{\"drives\":2,\"knots\":3,\"labels\":[\"a_1\",\"a_2\"]},\"tail\":[\"AMICODE_PULSE_META drives=2 knots=3 labels=\\\"a_1\\\",\\\"a_2\\\" bounds=-0.2:0.2,-0.2:0.2\",\"AMICODE_ITER iter=1 f=6.0e-01 inf_pr=1.0e-01 inf_du=5.0e-01\",\"AMICODE_ITER iter=2 f=5.9e-01 inf_pr=1.0e-02 inf_du=5.0e-02\",\"AMICODE_PULSE iter=2 dt=0.2 a=0.01,0.02,0.03;0.04,0.05,0.06\"]},\"error\":null}" }, { "name": "run series — explicit lab", @@ -328,6 +329,62 @@ }, "status": 200, "body": "{\"ok\":false,\"run\":null,\"error\":\"not_found:no-such-run: no such run dir\"}" + }, + { + "name": "library — seeded papers, newest first", + "request": { + "method": "GET", + "path": "/amicode/library" + }, + "status": 200, + "body": "{\"ok\":true,\"papers\":[{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-ctt9wd/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-ctt9wd/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" + }, + { + "name": "library upload — valid PDF (refreshed listing)", + "request": { + "method": "POST", + "path": "/amicode/library", + "body": { + "filename": "new paper.pdf", + "data_b64": "JVBERi0xLjQgdXBsb2FkZWQgcGFwZXIgYm9keQo=" + } + }, + "status": 200, + "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1787258842870,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-ctt9wd/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-ctt9wd/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-ctt9wd/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" + }, + { + "name": "library upload — bad_filetype refusal", + "request": { + "method": "POST", + "path": "/amicode/library", + "body": { + "filename": "not-a-pdf.txt", + "data_b64": "cGxhaW4gdGV4dAo=" + } + }, + "status": 200, + "body": "{\"ok\":false,\"papers\":[],\"error\":\"bad_filename: PDFs only; name must survive sanitization\"}" + }, + { + "name": "library upload — missing data_b64 refusal", + "request": { + "method": "POST", + "path": "/amicode/library", + "body": { + "filename": "ok.pdf" + } + }, + "status": 200, + "body": "{\"ok\":false,\"papers\":[],\"error\":\"bad_request: filename and data_b64 are required strings\"}" + }, + { + "name": "library — post-upload state", + "request": { + "method": "GET", + "path": "/amicode/library" + }, + "status": 200, + "body": "{\"ok\":true,\"papers\":[{\"name\":\"new paper.pdf\",\"size\":29,\"added_ms\":1787258842870,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-ctt9wd/.amico/library/new paper.pdf\"},{\"name\":\"piccolo-trajectory-2023.pdf\",\"size\":28,\"added_ms\":1785974400000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-ctt9wd/.amico/library/piccolo-trajectory-2023.pdf\"},{\"name\":\"rydberg-blockade-2024.pdf\",\"size\":28,\"added_ms\":1785628800000,\"path\":\"/var/folders/vn/fc7_8xkn0b52hmf4pk5wdx280000gn/T/amicode-fixture-ctt9wd/.amico/library/rydberg-blockade-2024.pdf\"}],\"error\":null}" } ] }