Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
777 changes: 777 additions & 0 deletions api/v1.test.ts

Large diffs are not rendered by default.

296 changes: 296 additions & 0 deletions api/v1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
import cors from "@fastify/cors";
import { createHash, timingSafeEqual } from "node:crypto";
import type { FastifyInstance } from "fastify";
import type { DbHandle } from "../db/index.js";
import { boards } from "../db/schema.js";
import { getItemForUi, listItemsForApi } from "../db/hydrate.js";
import { patchItemFields, deleteItemWithAssets } from "../db/item-actions.js";
import { addItemSkill } from "../skills/add-item.js";
import { INBOX_BOARD_ID } from "../db/seed.js";
import { captureRegistry } from "../capture/adapter.js";
import { assignItems } from "../enrichment/assign.js";
import { suggestBoardForItem } from "../enrichment/suggest.js";
import { recordAssignmentChoice } from "../db/suggestion-override.js";
import { buildCtx, disabledLlm, type JobQueue, type LLMProvider, type Logger } from "../skills/types.js";

// Story 12.1 — the encapsulated `/api/v1` surface: a static bearer-token guard +
// CORS, both scoped to this plugin's routes only. Registering with a prefix gives
// Fastify-level encapsulation: the onRequest hook and CORS added INSIDE this plugin
// cannot reach the root app's routes (SPA, legacy /api/bookmarks, /api/collections,
// /skills). That structural boundary is how NFR-BC is guaranteed, not merely intended.
//
// 12.2 registers the CRUD routes inside this same plugin (behind this guard). 12.1
// ships a trivial GET /api/v1/ping probe so the surface is testable before CRUD lands.

export interface V1Options {
/** SHA-256 hash (hex) of the configured bearer token, or null when unconfigured. */
apiTokenHash: string | null;
/** Allowlisted cross-origin origins; empty = no cross-origin allowed. */
corsOrigins: string[];
/**
* Story 12.2 — CRUD collaborators. `resolveDb` is lazy (the established
* `opts.db ?? getDb()` pattern) so opt-less callers never open the real DB.
* All CRUD reuses existing helpers — no parallel write path (NFR-BC).
*/
resolveDb: () => DbHandle;
queue: JobQueue;
logger: Logger;
llm: LLMProvider;
screenshotsDir: string;
}

/** SHA-256 hex of a string. Exported so the server can hash an injected test token. */
export function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}

/**
* Extract the token from an `Authorization: Bearer <token>` header, or null.
* The scheme match is case-insensitive (RFC 7235 auth schemes are case-insensitive);
* the token itself is not whitespace-normalized (an exact-match secret).
*/
function extractBearer(header: string | undefined): string | null {
if (typeof header !== "string") return null;
const match = /^Bearer (.+)$/i.exec(header.trim());
return match ? match[1] : null;
}

/**
* Constant-time compare of two SHA-256 hex digests. Both are fixed-length (64
* chars), so the buffers are always equal length — no length-based early exit,
* no timing oracle. Returns false if either side is missing.
*/
function hashesMatch(a: string | null, b: string | null): boolean {
if (!a || !b) return false;
const ba = Buffer.from(a, "utf8");
const bb = Buffer.from(b, "utf8");
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}

/**
* Register the encapsulated `/api/v1` plugin on `app`. The bearer guard + CORS live
* inside the prefixed child context, so they apply to v1 routes only.
*/
export async function registerV1Api(app: FastifyInstance, opts: V1Options): Promise<void> {
await app.register(
async (v1) => {
// CORS scoped to v1 only. Empty allowlist → `origin: false` (no cross-origin).
await v1.register(cors, {
origin: opts.corsOrigins.length > 0 ? opts.corsOrigins : false,
});

// Tolerant JSON body parsing scoped to v1: an empty body with a reflexive
// `content-type: application/json` (common for fetch-based DELETE/PATCH clients)
// parses to undefined instead of Fastify's default 400. Encapsulated to this
// plugin — the root app's parser is unchanged (NFR-BC).
v1.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => {
const text = (body as string).trim();
if (text.length === 0) {
done(null, undefined);
return;
}
try {
done(null, JSON.parse(text));
} catch (err) {
(err as { statusCode?: number }).statusCode = 400;
done(err as Error, undefined);
}
});

// Bearer guard. Fail-closed: if no token is configured, the v1 surface rejects
// everything (you cannot authenticate against an unset secret).
v1.addHook("onRequest", async (req, reply) => {
const provided = extractBearer(req.headers.authorization);
const providedHash = provided ? sha256Hex(provided) : null;
if (!hashesMatch(providedHash, opts.apiTokenHash)) {
reply.code(401).send({ error: "Unauthorized" });
return reply; // short-circuit — the route handler never runs
}
});

// Trivial liveness probe so 12.1 has a guarded target (12.2 adds CRUD here).
v1.get("/ping", async () => ({ ok: true }));

// --- Story 12.2: token-authed CRUD over items + the board list ---
// The stable contract every capture client (bookmarklet/PWA/extension) speaks.
// REUSES the existing helpers verbatim (addItemSkill, the single-writer queue,
// patchItemFields, deleteItemWithAssets) — no parallel write path, no new
// delete/cleanup logic. Only the filtered list query (listItemsForApi) is new.

// POST /items — create-from-URL, optimistic pending (async capture on the queue).
v1.post<{ Body: { url?: string; boardId?: string } }>("/items", async (req, reply) => {
const url = (req.body?.url ?? "").trim();
if (!url) {
reply.code(400);
return { error: "url is required" };
}
// Story 13.1 — an omitted/blank target board defaults to the Inbox (the
// capture funnel: save anything without deciding where it goes). A *provided*
// unknown board still errors via addItemSkill's existence check below.
const rawBoardId = typeof req.body?.boardId === "string" ? req.body.boardId.trim() : "";
const boardId = rawBoardId || INBOX_BOARD_ID;
const handle = opts.resolveDb();
const ctx = buildCtx({
db: handle,
queue: opts.queue,
logger: opts.logger,
llm: opts.llm,
boardId,
});
try {
const { itemId } = await addItemSkill.run({ boardId, source: url }, ctx);
reply.code(201);
return getItemForUi(handle, itemId) ?? { id: itemId, url, status: "pending" };
} catch (err) {
// Unknown board (FK insert fails) / invalid input → client error.
reply.code(400);
return { error: (err as Error).message };
}
});

// GET /items — newest-first, filtered + paginated (recent-additions feed).
v1.get<{
Querystring: {
board?: string;
status?: string;
since?: string;
limit?: string;
offset?: string;
};
}>("/items", async (req) => {
const q = req.query;
// Coerce to a finite number or drop to undefined — a junk param (?limit=abc)
// must NOT produce NaN (which would yield a degenerate LIMIT NaN → 500, or a
// silently-empty `since` filter). Malformed → ignored, not an error.
const num = (v: string | undefined) => {
if (v === undefined || v === "") return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
};
return listItemsForApi(opts.resolveDb(), {
boardId: q.board,
status: q.status,
since: num(q.since),
limit: num(q.limit),
offset: num(q.offset),
});
});

// PATCH /items/:id — user-field allowlist (reuses 8.3; disallowed keys ignored).
v1.patch<{ Params: { id: string }; Body: Record<string, unknown> }>(
"/items/:id",
async (req, reply) => {
const handle = opts.resolveDb();
const updated = await patchItemFields(
handle,
req.params.id,
(req.body ?? {}) as Record<string, unknown>,
);
if (!updated) {
reply.code(404);
return { error: "Not found" };
}
return getItemForUi(handle, req.params.id);
},
);

// DELETE /items/:id — row cascade + asset-FILE unlink (reuses 8.3; no orphans).
v1.delete<{ Params: { id: string } }>("/items/:id", async (req, reply) => {
const res = await deleteItemWithAssets(opts.resolveDb(), req.params.id, opts.screenshotsDir);
if (!res.deleted) {
reply.code(404);
return { error: "Not found" };
}
reply.code(204);
return null;
});

// POST /items/assign — the ONE assign verb (Story 14.2). Thin adapter over the
// shared `assignItems` helper (the same path the composer 15.2 reuses): single-FK
// move to the target board THEN earned-tier enrich against the target descriptor.
// Batch-capable. Awaits the earned enrichment so the manual caller gets the
// settled (enriched) result; the bulk composer calls the helper directly and may
// fire-and-forget instead.
v1.post<{ Body: { itemIds?: unknown; boardId?: unknown } }>("/items/assign", async (req, reply) => {
const rawIds = req.body?.itemIds;
const itemIds = Array.isArray(rawIds)
? rawIds.filter((x): x is string => typeof x === "string" && x.length > 0)
: [];
const boardId = typeof req.body?.boardId === "string" ? req.body.boardId.trim() : "";
if (itemIds.length === 0) {
reply.code(400);
return { error: "itemIds (a non-empty array of strings) is required" };
}
// Defensive cap on the manual route: it awaits enrichment (below), which runs
// serially on the single writer, so an unbounded batch could block/timeout the
// response. The bulk composer (15.2) calls assignItems directly (no cap, fire-
// and-forget). 200 is far above any manual triage.
if (itemIds.length > 200) {
reply.code(400);
return { error: "too many itemIds (max 200 per request); use the composer for bulk assignment" };
}
if (!boardId) {
reply.code(400);
return { error: "boardId is required" };
}
try {
const result = await assignItems(opts.resolveDb(), {
itemIds,
boardId,
llm: opts.llm,
registry: captureRegistry,
});
await result.settled; // manual assign returns the enriched result
return {
assigned: result.assigned,
skipped: result.skipped,
notFound: result.notFound,
failed: result.failed,
};
} catch (err) {
reply.code(400);
return { error: (err as Error).message };
}
});

// GET /items/:id/suggestion — Story 14.3 READ-ONLY suggested home board for an
// Inbox item. Returns {suggestedBoardId: null} when no provider is configured or
// a suggestion can't be computed → the client shows the manual picker. Never
// mutates the item.
v1.get<{ Params: { id: string } }>("/items/:id/suggestion", async (req) => {
const providerConfigured = opts.llm !== disabledLlm;
return suggestBoardForItem(opts.resolveDb(), {
itemId: req.params.id,
llm: opts.llm,
providerConfigured,
});
});

// POST /suggestions/override — Story 14.3 records an assignment CHOICE as a
// future-suggestion-quality signal (additive store). The move itself goes through
// the 14.2 assign verb; this only captures suggested-vs-chosen. A confirm (chosen
// === suggested) or a manual pick (no suggestion) records nothing.
v1.post<{ Body: { itemId?: unknown; suggestedBoardId?: unknown; chosenBoardId?: unknown } }>(
"/suggestions/override",
async (req, reply) => {
const itemId = typeof req.body?.itemId === "string" ? req.body.itemId : "";
const chosenBoardId = typeof req.body?.chosenBoardId === "string" ? req.body.chosenBoardId : "";
if (!itemId || !chosenBoardId) {
reply.code(400);
return { error: "itemId and chosenBoardId are required" };
}
const suggestedBoardId =
typeof req.body?.suggestedBoardId === "string" ? req.body.suggestedBoardId : null;
return recordAssignmentChoice(opts.resolveDb(), { itemId, suggestedBoardId, chosenBoardId });
},
);

// GET /boards — lean targeting list ({id,name,view}); no descriptor needed.
v1.get("/boards", async () =>
opts.resolveDb().db.select({ id: boards.id, name: boards.name, view: boards.view }).from(boards).all(),
);
},
{ prefix: "/api/v1" },
);
}
83 changes: 83 additions & 0 deletions capture-clients/bookmarklet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";

import { buildBookmarklet, TOKEN_PLACEHOLDER } from "./bookmarklet.js";
import { buildServer } from "../server.js";

// Story 13.2 — bookmarklet capture client.

// AC 1/2/5 — the payload is a valid javascript: bookmarklet hitting the authed endpoint
test("13.2: buildBookmarklet targets the authed /api/v1/items with url+title, no nav", () => {
const bm = buildBookmarklet({ instanceUrl: "https://board.example", token: "tok-123" });
assert.ok(bm.startsWith("javascript:"), "must be a javascript: bookmarklet");
assert.ok(bm.includes("https://board.example/api/v1/items"), "posts to the instance's authed endpoint");
assert.ok(bm.includes("Bearer "), "carries a Bearer token");
assert.ok(bm.includes("tok-123"), "embeds the configured token");
assert.ok(bm.includes("location.href"), "sends the current tab URL");
assert.ok(bm.includes("document.title"), "sends the current tab title");
assert.ok(bm.includes("'POST'") || bm.includes('"POST"'), "uses POST");
// must NOT navigate the user away (no full-page redirect / window.location assignment)
assert.ok(!/location\s*=/.test(bm) && !/location\.assign/.test(bm) && !/location\.replace/.test(bm),
"must not navigate the page away");
});

// AC 1 — trailing slash on the instance URL is normalized (no double slash)
test("13.2: buildBookmarklet normalizes a trailing slash on the instance URL", () => {
const bm = buildBookmarklet({ instanceUrl: "https://board.example/", token: "t" });
assert.ok(bm.includes("https://board.example/api/v1/items"));
assert.ok(!bm.includes("board.example//api/v1/items"));
});

// AC 1/4 — the help surface is served and renders the bookmarklet template (placeholder
// token, no plaintext from the server), without altering existing routes.
test("13.2: GET /bookmarklet serves the help page with the placeholder template", async () => {
const { initDb } = await import("../db/index.js");
const { seed } = await import("../db/seed.js");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "board-oss-bm-"));
const handle = initDb(path.join(dir, "c.db"));
seed(handle.db);
const app = await buildServer({ db: handle, apiToken: "test-token" });
try {
const res = await app.inject({ method: "GET", url: "/bookmarklet" });
assert.equal(res.statusCode, 200);
assert.match(res.headers["content-type"] ?? "", /text\/html/);
assert.ok(res.body.includes("/api/v1/items"), "page contains the authed endpoint");
assert.ok(res.body.includes(TOKEN_PLACEHOLDER), "page ships a placeholder, never a server-held token");

// existing route unaffected
const cols = await app.inject({ method: "GET", url: "/api/collections" });
assert.equal(cols.statusCode, 200);
} finally {
handle.sqlite.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});

// SECURITY (review fix) — a malicious Host header must NOT break out of the HTML or
// the <script> (reflected XSS). The Host is attacker-controllable behind some proxies.
test("13.2: GET /bookmarklet escapes a malicious Host header (no XSS breakout)", async () => {
const { initDb } = await import("../db/index.js");
const { seed } = await import("../db/seed.js");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "board-oss-bm-"));
const handle = initDb(path.join(dir, "c.db"));
seed(handle.db);
const app = await buildServer({ db: handle, apiToken: "test-token" });
try {
const res = await app.inject({
method: "GET",
url: "/bookmarklet",
headers: { host: `evil"></script><script>alert(1)</script><x y="` },
});
assert.equal(res.statusCode, 200);
// the raw injected </script> must not appear unescaped (would terminate the block)
assert.ok(!res.body.includes("</script><script>alert(1)"), "must not allow a </script> breakout");
// and the raw attribute-breakout quote sequence must be escaped in the HTML context
assert.ok(!res.body.includes(`evil"></script>`), "raw Host must be escaped in HTML");
} finally {
handle.sqlite.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
Loading
Loading