From 7b0a6738dda8faf5c55fe5fb9d2b24ed076e76f4 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:10:39 +0100 Subject: [PATCH 1/9] feat(core): API support helpers: token list, suggestions, attachment links, connection runs, device auth, file-mode sync Co-Authored-By: Claude Opus 5.5 --- packages/core/src/attachments.ts | 68 +++++++++++- packages/core/src/auth.ts | 28 +++++ packages/core/src/connections.ts | 123 ++++++++++++++++++++-- packages/core/src/entries.ts | 31 ++++++ packages/core/test/api-support.test.ts | 140 +++++++++++++++++++++++++ 5 files changed, 378 insertions(+), 12 deletions(-) create mode 100644 packages/core/test/api-support.test.ts diff --git a/packages/core/src/attachments.ts b/packages/core/src/attachments.ts index f86b93b..6c85d32 100644 --- a/packages/core/src/attachments.ts +++ b/packages/core/src/attachments.ts @@ -3,7 +3,7 @@ import { dirname, join } from "node:path"; import type { Db } from "@sttracker/db/db"; import { newId, nowIso } from "@sttracker/db/ids"; import type { AttachmentEntity, AttachmentsTable } from "@sttracker/db/schema"; -import { ValidationError } from "./errors.ts"; +import { NotFoundError, ValidationError } from "./errors.ts"; export type Attachment = AttachmentsTable; @@ -127,15 +127,15 @@ export async function readAttachment( dataDir: string, userId: string, id: string, -): Promise<{ file: ReturnType; mime: string } | null> { +): Promise<{ file: ReturnType; mime: string; filename: string } | null> { const row = await db.k .selectFrom("attachments") - .select(["sha256", "mime"]) + .select(["sha256", "mime", "filename"]) .where("id", "=", id) .where("user_id", "=", userId) .executeTakeFirst(); if (!row) return null; - return { file: Bun.file(pathFor(dataDir, row.sha256)), mime: row.mime }; + return { file: Bun.file(pathFor(dataDir, row.sha256)), mime: row.mime, filename: row.filename }; } /** Attachments of `userId` linked to the given entity, oldest first. */ @@ -201,3 +201,63 @@ export async function unlinkAttachment( if (orphan) await rm(pathFor(dataDir, att.sha256), { force: true }); }); } + +const LINK_ENTITIES: readonly AttachmentEntity[] = ["entry", "engagement", "copy", "list", "work"]; + +/** + * Throw `NotFoundError` unless `entityId` is a live `entity` the user may attach to: their own entry, + * engagement, copy or list, or a work that is shared or theirs. Call before `storeAttachment`. + */ +export async function requireLinkTarget(db: Db, userId: string, entity: AttachmentEntity, entityId: string): Promise { + if (!LINK_ENTITIES.includes(entity)) throw new ValidationError("entity", "invalid"); + const k = db.k; + let found: unknown; + switch (entity) { + case "entry": + found = await k.selectFrom("entries").select("id").where("id", "=", entityId).where("user_id", "=", userId).where("deleted_at", "is", null).executeTakeFirst(); + break; + case "engagement": + found = await k + .selectFrom("engagements as g") + .innerJoin("entries as e", "e.id", "g.entry_id") + .select("g.id") + .where("g.id", "=", entityId) + .where("e.user_id", "=", userId) + .where("g.deleted_at", "is", null) + .where("e.deleted_at", "is", null) + .executeTakeFirst(); + break; + case "copy": + found = await k.selectFrom("copies").select("id").where("id", "=", entityId).where("user_id", "=", userId).where("deleted_at", "is", null).executeTakeFirst(); + break; + case "list": + found = await k.selectFrom("lists").select("id").where("id", "=", entityId).where("user_id", "=", userId).where("deleted_at", "is", null).executeTakeFirst(); + break; + case "work": + found = await k + .selectFrom("works") + .select("id") + .where("id", "=", entityId) + .where((eb) => eb.or([eb("owner_user_id", "is", null), eb("owner_user_id", "=", userId)])) + .executeTakeFirst(); + break; + } + if (!found) throw new NotFoundError(entity); +} + +/** The links of one of the user's attachments (empty for another user's or a missing attachment). */ +export async function listAttachmentLinks( + db: Db, + userId: string, + attachmentId: string, +): Promise<{ id: string; entity: AttachmentEntity; entityId: string }[]> { + const rows = await db.k + .selectFrom("attachment_links as l") + .innerJoin("attachments as a", "a.id", "l.attachment_id") + .select(["l.id", "l.entity", "l.entity_id"]) + .where("a.id", "=", attachmentId) + .where("a.user_id", "=", userId) + .orderBy("l.id") + .execute(); + return rows.map((r) => ({ id: r.id, entity: r.entity, entityId: r.entity_id })); +} diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index b10f1f6..8bf546d 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -198,3 +198,31 @@ export async function revokeAppToken(db: Db, userId: string, id: string): Promis .where("revoked_at", "is", null) .execute(); } + +export interface AppTokenInfo { + id: string; + name: string; + scopes: TokenScope[]; + lastUsedAt: string | null; + revokedAt: string | null; + createdAt: string; +} + +/** The user's app tokens, newest first. Metadata only: the token and its hash are never returned. */ +export async function listAppTokens(db: Db, userId: string): Promise { + const rows = await db.k + .selectFrom("app_tokens") + .select(["id", "name", "scopes", "last_used_at", "revoked_at", "created_at"]) + .where("user_id", "=", userId) + .orderBy("created_at", "desc") + .orderBy("id", "desc") + .execute(); + return rows.map((r) => ({ + id: r.id, + name: r.name, + scopes: fromJson(r.scopes, []), + lastUsedAt: r.last_used_at, + revokedAt: r.revoked_at, + createdAt: r.created_at, + })); +} diff --git a/packages/core/src/connections.ts b/packages/core/src/connections.ts index 7a2c275..bb12cc8 100644 --- a/packages/core/src/connections.ts +++ b/packages/core/src/connections.ts @@ -1,14 +1,14 @@ import type { Db } from "@sttracker/db/db"; import { newId, nowIso } from "@sttracker/db/ids"; import { fromJson, toJson } from "@sttracker/db/json"; -import type { ConnectionStatus } from "@sttracker/db/schema"; +import type { ConnectionStatus, RunMode, RunStatus } from "@sttracker/db/schema"; import { createHttpClient, type HttpClientOptions } from "@sttracker/plugin-sdk/http"; import type { HttpClient, PluginContext, PluginManifest } from "@sttracker/plugin-sdk/types"; import { decryptJson, encryptJson } from "./crypto.ts"; import { CryptoError, NotFoundError, ValidationError } from "./errors.ts"; import { DropJobError, NonRetryableError, type JobContext } from "./jobs.ts"; import type { PluginRegistry } from "./registry.ts"; -import { runSync, type Connection, type RunSummary } from "./sync/run.ts"; +import { runSync, type Connection, type RunSummary, type SyncMode } from "./sync/run.ts"; const WEEK_MS = 7 * 24 * 60 * 60 * 1000; @@ -143,6 +143,104 @@ export async function listConnections(db: Db, userId: string): Promise): boolean { + return c.status === "paused" && !c.hasCredentials; +} + +async function ownedRow(db: Db, userId: string, id: string) { + const r = await db.k.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId).executeTakeFirst(); + if (!r || (r.status === "paused" && r.credentials_enc === null)) throw new NotFoundError("connection"); + return r; +} + +/** One of the user's live connections, without secrets. Deleted or foreign connections throw `NotFoundError`. */ +export async function getConnection(db: Db, userId: string, id: string): Promise { + const r = await ownedRow(db, userId, id); + return { + id: r.id, + pluginId: r.plugin_id, + name: r.name, + config: fromJson>(r.config, {}), + scheduleMinutes: r.schedule_minutes, + status: r.status, + lastRunAt: r.last_run_at, + createdAt: r.created_at, + hasCredentials: r.credentials_enc !== null, + }; +} + +export interface RunView { + id: string; + mode: RunMode; + status: RunStatus; + startedAt: string; + finishedAt: string | null; + counts: Record; + log: string; + error: string | null; +} + +/** Runs of one of the user's connections, newest first (at most `limit`, default 50). */ +export async function listRuns(db: Db, userId: string, connectionId: string, limit = 50): Promise { + await ownedRow(db, userId, connectionId); + const rows = await db.k + .selectFrom("source_runs") + .selectAll() + .where("connection_id", "=", connectionId) + .orderBy("started_at", "desc") + .orderBy("id", "desc") + .limit(Math.min(Math.max(limit, 1), 200)) + .execute(); + return rows.map((r) => ({ + id: r.id, + mode: r.mode, + status: r.status, + startedAt: r.started_at, + finishedAt: r.finished_at, + counts: fromJson>(r.counts, {}), + log: r.log, + error: r.error, + })); +} + +async function deviceContext(db: Db, secret: string, registry: PluginRegistry, userId: string, id: string) { + const r = await ownedRow(db, userId, id); + const plugin = registry.get(r.plugin_id); + if (!plugin) throw new ValidationError("pluginId", "not_installed"); + if (!plugin.deviceAuth) throw new ValidationError("pluginId", "no_device_auth"); + const conn: Connection = { + id: r.id, + userId: r.user_id, + pluginId: r.plugin_id, + name: r.name, + config: fromJson>(r.config, {}), + credentialsEnc: r.credentials_enc, + scheduleMinutes: r.schedule_minutes, + cursor: r.cursor, + status: r.status, + lastRunAt: r.last_run_at, + createdAt: r.created_at, + }; + const ctx = await makePluginContext(db, secret, conn, "device", plugin.manifest); + return { deviceAuth: plugin.deviceAuth, ctx }; +} + +/** Start a device-code sign-in for one of the user's connections. */ +export async function deviceStart(db: Db, secret: string, registry: PluginRegistry, userId: string, id: string) { + const { deviceAuth, ctx } = await deviceContext(db, secret, registry, userId, id); + return deviceAuth.start(ctx); +} + +/** Poll a device-code sign-in. On success the returned credentials are merged into the encrypted blob. */ +export async function devicePoll(db: Db, secret: string, registry: PluginRegistry, userId: string, id: string, deviceCode: string): Promise<"pending" | "connected"> { + const { deviceAuth, ctx } = await deviceContext(db, secret, registry, userId, id); + const res = await deviceAuth.poll(ctx, deviceCode); + if (res === "pending") return "pending"; + await ctx.credentials.save(res); + return "connected"; +} + export const USER_AGENT = "STTracker/0.1 (+self-hosted)"; export interface ContextOpts { @@ -215,7 +313,7 @@ export interface SyncHandlerDeps { const MSG_CRYPTO = "Cannot decrypt this source's credentials. Was STTRACKER_SECRET changed? Reconnect the source to fix it."; /** Record a run that failed before `runSync` could start, flag the connection, and stop the job without retry. */ -async function failBeforeRun(db: Db, connectionId: string, mode: "full" | "changes", message: string): Promise { +async function failBeforeRun(db: Db, connectionId: string, mode: RunMode, message: string): Promise { const now = nowIso(); await db.k .insertInto("source_runs") @@ -232,16 +330,25 @@ async function failBeforeRun(db: Db, connectionId: string, mode: "full" | "chang * run, mark the connection `error` and fail the job without retry. A failed run is rethrown so the worker retries. * `ctx.log` output is appended to the run's `source_runs.log`. */ -export function syncHandler(deps: SyncHandlerDeps): (payload: { connectionId: string; mode?: "full" | "changes" }, jc?: JobContext) => Promise { +export interface SyncPayload { + connectionId: string; + mode?: "full" | "changes"; + /** File mode: run the plugin's `importFile` over these bytes instead of pulling. */ + file?: { name: string; base64: string }; +} + +export function syncHandler(deps: SyncHandlerDeps): (payload: SyncPayload, jc?: JobContext) => Promise { return async (payload, jc) => { const { db } = deps; const full = await db.k.selectFrom("source_connections").selectAll().where("id", "=", payload.connectionId).executeTakeFirst(); if (!full) throw new DropJobError("connection not found"); if (full.status === "paused") throw new DropJobError("connection is paused"); const plugin = deps.registry.get(full.plugin_id); - if (!plugin) return failBeforeRun(db, full.id, payload.mode ?? "full", `Plugin '${full.plugin_id}' is not installed or failed to load.`); - let mode: "full" | "changes" = "full"; - if (plugin.pullChanges && payload.mode !== "full") { + if (!plugin) return failBeforeRun(db, full.id, payload.file ? "file" : (payload.mode ?? "full"), `Plugin '${full.plugin_id}' is not installed or failed to load.`); + let mode: SyncMode = "full"; + if (payload.file) { + mode = { file: { name: payload.file.name, bytes: new Uint8Array(Buffer.from(payload.file.base64, "base64")) } }; + } else if (plugin.pullChanges && payload.mode !== "full") { const cutoff = new Date(Date.now() - WEEK_MS).toISOString(); const recentFull = await db.k .selectFrom("source_runs") @@ -270,7 +377,7 @@ export function syncHandler(deps: SyncHandlerDeps): (payload: { connectionId: st try { creds = await readCreds(deps.secret, conn.credentialsEnc); } catch (e) { - if (e instanceof CryptoError) return failBeforeRun(db, full.id, mode, MSG_CRYPTO); + if (e instanceof CryptoError) return failBeforeRun(db, full.id, typeof mode === "string" ? mode : "file", MSG_CRYPTO); throw e; } const logs: string[] = []; diff --git a/packages/core/src/entries.ts b/packages/core/src/entries.ts index 94dbcef..142f87c 100644 --- a/packages/core/src/entries.ts +++ b/packages/core/src/entries.ts @@ -975,3 +975,34 @@ export async function rejectSuggestion(db: Db, userId: string, suggestionId: str if (s.status !== "pending") throw new ValidationError("status", "not_pending"); await db.k.updateTable("match_suggestions").set({ status: "rejected" }).where("id", "=", s.id).execute(); } + +export interface MatchSuggestion { + id: string; + workId: string; + candidateWorkId: string; + score: number; + status: "pending" | "accepted" | "rejected"; + createdAt: string; +} + +/** The user's pending match suggestions for a work, best first. Throws `NotFoundError` if the work is not visible. */ +export async function listSuggestions(db: Db, userId: string, workId: string): Promise { + await resolveVisibleWork(db.k, userId, workId); + const rows = await db.k + .selectFrom("match_suggestions") + .selectAll() + .where("user_id", "=", userId) + .where("work_id", "=", workId) + .where("status", "=", "pending") + .orderBy("score", "desc") + .orderBy("id") + .execute(); + return rows.map((r) => ({ + id: r.id, + workId: r.work_id, + candidateWorkId: r.candidate_work_id, + score: r.score, + status: r.status, + createdAt: r.created_at, + })); +} diff --git a/packages/core/test/api-support.test.ts b/packages/core/test/api-support.test.ts new file mode 100644 index 0000000..552134c --- /dev/null +++ b/packages/core/test/api-support.test.ts @@ -0,0 +1,140 @@ +import { expect, spyOn, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { newId, nowIso } from "@sttracker/db/ids"; +import { forEachDialect } from "@sttracker/db/testing"; +import { definePlugin, type SourcePlugin } from "@sttracker/plugin-sdk/types"; +import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment } from "../src/attachments.ts"; +import { createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts"; +import { createConnection, deleteConnection, devicePoll, deviceStart, getConnection, listRuns, syncHandler } from "../src/connections.ts"; +import { listSuggestions, quickAdd } from "../src/entries.ts"; +import { addEngagement } from "../src/engagements.ts"; +import { NotFoundError, ValidationError } from "../src/errors.ts"; +import type { PluginRegistry } from "../src/registry.ts"; +import { loadPlugins } from "../src/registry.ts"; +import { makeUser, makeWork } from "./user-layer-fixtures.ts"; + +const SECRET = "test-secret"; +const warn = spyOn(console, "warn").mockImplementation(() => {}); +const reg = await loadPlugins([join(import.meta.dir, "fixtures/plugins")]); +warn.mockRestore(); +const echo = reg.get("echo")!; + +const fileish: SourcePlugin = definePlugin({ + manifest: { id: "fileish", name: "Fileish", version: "1", sdkVersion: 1, mediaTypes: ["book"], capabilities: ["importFile"], config: [] }, + async *importFile(_ctx, file) { + yield { + kind: "entry", + key: file.name, + raw: new TextDecoder().decode(file.bytes), + work: { title: new TextDecoder().decode(file.bytes), mediaType: "book", externalIds: { isbn13: "9780000000009" } }, + entry: { status: "planned" }, + }; + }, + deviceAuth: { + start: async () => ({ userCode: "ABCD", verificationUrl: "https://x.test/device", deviceCode: "dc", intervalSec: 5, expiresInSec: 600 }), + poll: async (_ctx, deviceCode) => (deviceCode === "dc" ? { access_token: "tok" } : "pending"), + }, +}); +const reg2: PluginRegistry = { get: (id) => (id === "fileish" ? fileish : reg.get(id)), list: () => [...reg.list(), fileish.manifest], reload: async () => {} }; + +forEachDialect("api support", (getDb) => { + test("listAppTokens returns metadata only, revoked flagged, scoped to user", async () => { + const db = getDb(); + const u = await makeUser(db); + const other = await makeUser(db, "bob"); + const t = await createAppToken(db, u, "script", ["read"]); + await createAppToken(db, other, "x", ["write"]); + await revokeAppToken(db, u, t.id); + const list = await listAppTokens(db, u); + expect(list).toHaveLength(1); + expect(list[0]).toMatchObject({ id: t.id, name: "script", scopes: ["read"], lastUsedAt: null }); + expect(list[0]!.revokedAt).not.toBeNull(); + expect(JSON.stringify(list)).not.toContain("hash"); + }); + + test("listSuggestions lists pending suggestions for a visible work", async () => { + const db = getDb(); + const u = await makeUser(db); + const draft = await quickAdd(db, u, { title: "Dune??", mediaType: "book" }); + const good = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } }); + await db.k + .insertInto("match_suggestions") + .values({ id: newId(), user_id: u, work_id: draft.workId, candidate_work_id: good, score: 0.75, status: "pending", created_at: nowIso() }) + .execute(); + const s = await listSuggestions(db, u, draft.workId); + expect(s).toHaveLength(1); + expect(s[0]).toMatchObject({ workId: draft.workId, candidateWorkId: good, score: 0.75, status: "pending" }); + const other = await makeUser(db, "bob"); + await expect(listSuggestions(db, other, draft.workId)).rejects.toBeInstanceOf(NotFoundError); + }); + + test("requireLinkTarget checks ownership per entity; links and filename are readable", async () => { + const db = getDb(); + const dir = await mkdtemp(join(tmpdir(), "stt-att-")); + try { + const u = await makeUser(db); + const bob = await makeUser(db, "bob"); + const e = await quickAdd(db, u, { title: "Dune" }); + const g = await addEngagement(db, u, e.id, {}, "user"); + await requireLinkTarget(db, u, "entry", e.id); + await requireLinkTarget(db, u, "engagement", g.id); + await requireLinkTarget(db, u, "work", e.workId); + await expect(requireLinkTarget(db, bob, "entry", e.id)).rejects.toBeInstanceOf(NotFoundError); + await expect(requireLinkTarget(db, bob, "engagement", g.id)).rejects.toBeInstanceOf(NotFoundError); + await expect(requireLinkTarget(db, bob, "work", e.workId)).rejects.toBeInstanceOf(NotFoundError); + await expect(requireLinkTarget(db, u, "nope" as never, e.id)).rejects.toBeInstanceOf(ValidationError); + const a = await storeAttachment(db, dir, u, { bytes: new Uint8Array([1]), mime: "image/png", filename: "a.png" }, { entity: "entry", entityId: e.id }); + const links = await listAttachmentLinks(db, u, a.id); + expect(links).toEqual([{ id: expect.any(String), entity: "entry", entityId: e.id }]); + expect(await listAttachmentLinks(db, bob, a.id)).toEqual([]); + expect((await readAttachment(db, dir, u, a.id))?.filename).toBe("a.png"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("getConnection hides other users and deleted connections; listRuns is newest first", async () => { + const db = getDb(); + const u = await makeUser(db); + const bob = await makeUser(db, "bob"); + const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest); + expect((await getConnection(db, u, id)).pluginId).toBe("echo"); + await expect(getConnection(db, bob, id)).rejects.toBeInstanceOf(NotFoundError); + const sync = syncHandler({ db, secret: SECRET, registry: reg }); + await sync({ connectionId: id, mode: "full" }); + await sync({ connectionId: id, mode: "full" }); + const runs = await listRuns(db, u, id); + expect(runs).toHaveLength(2); + expect(runs[0]!.startedAt >= runs[1]!.startedAt).toBe(true); + expect(runs[0]).toMatchObject({ mode: "full", status: "ok" }); + await expect(listRuns(db, bob, id)).rejects.toBeInstanceOf(NotFoundError); + await deleteConnection(db, u, id); + await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError); + }); + + test("syncHandler runs file mode from a base64 payload", async () => { + const db = getDb(); + const u = await makeUser(db); + const id = await createConnection(db, SECRET, u, { pluginId: "fileish", name: "F", config: {} }, fileish.manifest); + const sync = syncHandler({ db, secret: SECRET, registry: reg2 }); + const s = await sync({ connectionId: id, file: { name: "x.txt", base64: Buffer.from("Dune").toString("base64") } }); + expect(s).toMatchObject({ status: "ok" }); + expect((await listRuns(db, u, id))[0]!.mode).toBe("file"); + const entries = await db.k.selectFrom("entries").select("id").where("user_id", "=", u).execute(); + expect(entries).toHaveLength(1); + }); + + test("deviceStart and devicePoll store credentials on success", async () => { + const db = getDb(); + const u = await makeUser(db); + const id = await createConnection(db, SECRET, u, { pluginId: "fileish", name: "F", config: {} }, fileish.manifest); + expect(await deviceStart(db, SECRET, reg2, u, id)).toMatchObject({ userCode: "ABCD", deviceCode: "dc" }); + expect(await devicePoll(db, SECRET, reg2, u, id, "other")).toBe("pending"); + expect(await devicePoll(db, SECRET, reg2, u, id, "dc")).toBe("connected"); + expect(await getConnection(db, u, id)).toMatchObject({ hasCredentials: true }); + const e = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest); + await expect(deviceStart(db, SECRET, reg2, u, e)).rejects.toBeInstanceOf(ValidationError); + }); +}); From 6963e297b07d8547b7bdefd05fee534e8a807bb9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:15:01 +0100 Subject: [PATCH 2/9] feat(server): REST API, auth, sources, changes, export createApp() serves /api/v1 from a typed route table (zod bodies and queries), with session-cookie and Bearer app-token auth, a CSRF header for cookie writes, login/register rate limiting, body caps, JSON error mapping, OpenAPI generation and a 404 /ingest mount. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/app.ts | 49 ++++ apps/server/src/config.ts | 30 +++ apps/server/src/http.ts | 316 ++++++++++++++++++++++++ apps/server/src/openapi.ts | 64 +++++ apps/server/src/routes/attachments.ts | 76 ++++++ apps/server/src/routes/auth.ts | 76 ++++++ apps/server/src/routes/changes.ts | 31 +++ apps/server/src/routes/copies.ts | 58 +++++ apps/server/src/routes/custom-fields.ts | 32 +++ apps/server/src/routes/engagements.ts | 55 +++++ apps/server/src/routes/entries.ts | 107 ++++++++ apps/server/src/routes/export.ts | 49 ++++ apps/server/src/routes/form.ts | 8 + apps/server/src/routes/lists.ts | 84 +++++++ apps/server/src/routes/meta.ts | 40 +++ apps/server/src/routes/schemas.ts | 19 ++ apps/server/src/routes/sources.ts | 143 +++++++++++ apps/server/src/routes/works.ts | 56 +++++ apps/server/test/api.test.ts | 306 +++++++++++++++++++++++ 19 files changed, 1599 insertions(+) create mode 100644 apps/server/src/app.ts create mode 100644 apps/server/src/config.ts create mode 100644 apps/server/src/http.ts create mode 100644 apps/server/src/openapi.ts create mode 100644 apps/server/src/routes/attachments.ts create mode 100644 apps/server/src/routes/auth.ts create mode 100644 apps/server/src/routes/changes.ts create mode 100644 apps/server/src/routes/copies.ts create mode 100644 apps/server/src/routes/custom-fields.ts create mode 100644 apps/server/src/routes/engagements.ts create mode 100644 apps/server/src/routes/entries.ts create mode 100644 apps/server/src/routes/export.ts create mode 100644 apps/server/src/routes/form.ts create mode 100644 apps/server/src/routes/lists.ts create mode 100644 apps/server/src/routes/meta.ts create mode 100644 apps/server/src/routes/schemas.ts create mode 100644 apps/server/src/routes/sources.ts create mode 100644 apps/server/src/routes/works.ts create mode 100644 apps/server/test/api.test.ts diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts new file mode 100644 index 0000000..2077395 --- /dev/null +++ b/apps/server/src/app.ts @@ -0,0 +1,49 @@ +import { apiHandler, errorResponse, type AppDeps, type RouteDef } from "./http.ts"; +import { attachmentRoutes } from "./routes/attachments.ts"; +import { authRoutes } from "./routes/auth.ts"; +import { changeRoutes } from "./routes/changes.ts"; +import { copyRoutes } from "./routes/copies.ts"; +import { customFieldRoutes } from "./routes/custom-fields.ts"; +import { engagementRoutes } from "./routes/engagements.ts"; +import { entryRoutes } from "./routes/entries.ts"; +import { exportRoutes } from "./routes/export.ts"; +import { listRoutes } from "./routes/lists.ts"; +import { metaRoutes } from "./routes/meta.ts"; +import { sourceRoutes } from "./routes/sources.ts"; +import { workRoutes } from "./routes/works.ts"; + +export interface App { + /** Handle `/api/v1/*` and `/ingest/*`. Anything else is a JSON 404 (the web app is served by `Bun.serve` routes). */ + fetch(req: Request, server?: { requestIP(req: Request): { address: string } | null }): Promise; + routes: RouteDef[]; +} + +/** The HTTP API, testable without a listening socket. */ +export function createApp(deps: AppDeps): App { + const routes: RouteDef[] = [ + ...authRoutes, + ...entryRoutes, + ...engagementRoutes, + ...workRoutes, + ...listRoutes, + ...copyRoutes, + ...customFieldRoutes, + ...attachmentRoutes, + ...sourceRoutes, + ...changeRoutes, + ...exportRoutes, + ]; + routes.push(...metaRoutes(() => routes)); + const api = apiHandler(routes, deps); + + return { + routes, + async fetch(req, server) { + const { pathname } = new URL(req.url); + if (pathname.startsWith("/api/v1/")) return api(req, server?.requestIP(req)?.address ?? "unknown"); + // Phase 1: the ingest mount exists but no plugin has the `ingest` capability. + if (pathname.startsWith("/ingest/")) return errorResponse(404, "not_found", "No ingest endpoint for this plugin."); + return errorResponse(404, "not_found", "No such endpoint."); + }, + }; +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts new file mode 100644 index 0000000..61980c6 --- /dev/null +++ b/apps/server/src/config.ts @@ -0,0 +1,30 @@ +export interface Config { + databaseUrl: string; + dataDir: string; + secret: string; + publicUrl: string | undefined; + port: number; + pluginDirs: string[]; +} + +export const MISSING_SECRET = "Set STTRACKER_SECRET to a long random string. It encrypts source credentials."; + +/** Read the server config from the environment. Throws when `STTRACKER_SECRET` is missing. */ +export function loadConfig(env: Record = process.env): Config { + const secret = env.STTRACKER_SECRET; + if (!secret) throw new Error(MISSING_SECRET); + const dataDir = env.DATA_DIR || "./data"; + const port = Number(env.PORT || 3000); + if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`PORT must be a port number, got "${env.PORT}"`); + return { + databaseUrl: env.DATABASE_URL || `sqlite://${dataDir}/sttracker.db`, + dataDir, + secret, + publicUrl: env.PUBLIC_URL || undefined, + port, + pluginDirs: (env.PLUGIN_DIRS || `plugins,${dataDir}/plugins`) + .split(",") + .map((d) => d.trim()) + .filter((d) => d !== ""), + }; +} diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts new file mode 100644 index 0000000..77ec8ba --- /dev/null +++ b/apps/server/src/http.ts @@ -0,0 +1,316 @@ +import { getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth"; +import { AuthError, ImportError, NotFoundError, ValidationError } from "@sttracker/core/errors"; +import { isUniqueViolation } from "@sttracker/core/matching"; +import type { PluginRegistry } from "@sttracker/core/registry"; +import type { Db } from "@sttracker/db/db"; +import type { TokenScope } from "@sttracker/db/schema"; +import { z } from "zod"; + +export type AuthMode = "none" | "session" | "token:read" | "token:write" | "any:read" | "any:write"; +export type Method = "GET" | "POST" | "PATCH" | "DELETE"; + +export interface AppDeps { + db: Db; + registry: PluginRegistry; + secret: string; + dataDir: string; + /** When it starts with `https:`, the session cookie is `Secure`. */ + publicUrl?: string; +} + +export const SESSION_COOKIE = "stt_session"; +export const CSRF_HEADER = "x-requested-with"; +export const CSRF_VALUE = "sttracker"; +export const MAX_JSON_BYTES = 1024 * 1024; +export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; +const SESSION_MAX_AGE_S = 30 * 24 * 60 * 60; + +export interface Ctx { + req: Request; + url: URL; + params: Record; + body: B; + query: Q; + /** The authenticated user. Throws on `auth: "none"` routes, which have none. */ + user: User; + /** The session token, when the request was authenticated by cookie. */ + sessionToken: string | null; + /** Parsed multipart body, for `multipart: true` routes. */ + form: FormData; + deps: AppDeps; + ip: string; +} + +export interface RouteDef { + method: Method; + /** Relative to `/api/v1`, with `:param` segments. */ + path: string; + summary: string; + auth: AuthMode; + body?: z.ZodType; + query?: z.ZodType; + /** Parse the body as multipart form data (upload limit) instead of JSON. */ + multipart?: boolean; + /** Rate limit per client IP (login, register). */ + rateLimited?: boolean; + /** Only admins may call it (403 otherwise). */ + admin?: boolean; + handler(ctx: Ctx): Promise; +} + +/** Declare a route; returns it unchanged with the handler's body and query types inferred from the schemas. */ +export function route( + def: Omit, z.output>, "body" | "query"> & { body?: BS; query?: QS }, +): RouteDef { + return def as RouteDef; +} + +/** Throw from a handler to answer with a specific status and error code. */ +export class HttpError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + ) { + super(message); + } +} + +export function json(data: unknown, status = 200, headers: Record = {}): Response { + return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json; charset=utf-8", ...headers } }); +} + +export function errorResponse(status: number, code: string, message: string, headers: Record = {}): Response { + return json({ error: { code, message } }, status, headers); +} + +export const noContent = (): Response => new Response(null, { status: 204 }); + +const AUTH_STATUS: Record = { + invalid_credentials: [401, "unauthenticated", "Wrong username or password."], + unauthenticated: [401, "unauthenticated", "Sign in first."], + forbidden: [403, "forbidden", "You are not allowed to do that."], + invite_required: [400, "invite_required", "Registration needs a valid invite code."], + username_taken: [400, "username_taken", "That username is taken."], + invalid_username: [400, "invalid_username", "Usernames are 2–32 characters: lowercase letters, digits and underscores."], +}; + +/** Map a thrown error to the JSON error response (ruling 5). Unknown errors are logged and hidden. */ +export function toErrorResponse(e: unknown): Response { + if (e instanceof HttpError) return errorResponse(e.status, e.code, e.message); + if (e instanceof z.ZodError) { + const msg = e.issues.map((i) => `${i.path.length ? i.path.join(".") : "body"}: ${i.message}`).join("; "); + return errorResponse(400, "validation", msg); + } + if (e instanceof ValidationError) return errorResponse(400, "validation", `${e.field}: ${e.reason}`); + if (e instanceof NotFoundError) return errorResponse(404, "not_found", e.message); + if (e instanceof AuthError) { + const [status, code, message] = AUTH_STATUS[e.code] ?? [403, "forbidden", "You are not allowed to do that."]; + return errorResponse(status, code, message); + } + if (e instanceof ImportError) { + const status = e.code === "account_not_empty" || e.code === "conflict" ? 409 : 400; + return errorResponse(status, e.code, `Import failed: ${e.code}.`); + } + if (isUniqueViolation(e)) return errorResponse(409, "conflict", "That already exists."); + console.error("[api]", e); + return errorResponse(500, "internal", "Something went wrong on the server."); +} + +export function sessionCookie(token: string, publicUrl: string | undefined): string { + const secure = publicUrl?.startsWith("https:") ? "; Secure" : ""; + return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_MAX_AGE_S}${secure}`; +} + +export function clearSessionCookie(publicUrl: string | undefined): string { + const secure = publicUrl?.startsWith("https:") ? "; Secure" : ""; + return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`; +} + +function readCookie(req: Request, name: string): string | null { + const header = req.headers.get("cookie"); + if (!header) return null; + for (const part of header.split(";")) { + const i = part.indexOf("="); + if (i > 0 && part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim()) || null; + } + return null; +} + +/** Read a request body, failing with 413 once it exceeds `max` bytes (whatever Content-Length claims). */ +async function readLimited(req: Request, max: number): Promise> { + const tooLarge = () => new HttpError(413, "validation", `Request body is too large (max ${max / (1024 * 1024)} MB).`); + const declared = Number(req.headers.get("content-length") ?? NaN); + if (declared > max) throw tooLarge(); + if (!req.body) return new Uint8Array(); + const reader = req.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > max) { + await reader.cancel(); + throw tooLarge(); + } + chunks.push(value); + } + const out = new Uint8Array(total); + let at = 0; + for (const c of chunks) { + out.set(c, at); + at += c.byteLength; + } + return out; +} + +/** Fixed-window-ish limiter: at most `limit` hits per `windowMs` per key. */ +export function rateLimiter(limit: number, windowMs: number): (key: string) => boolean { + const hits = new Map(); + return (key) => { + const now = Date.now(); + if (hits.size > 10_000) for (const [k, v] of hits) if (v.every((t) => now - t >= windowMs)) hits.delete(k); + const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs); + if (recent.length >= limit) { + hits.set(key, recent); + return false; + } + recent.push(now); + hits.set(key, recent); + return true; + }; +} + +const SCOPES: TokenScope[] = ["read", "write", "ingest"]; + +/** Resolve the caller for `mode`. Returns the user and the session token used, if any. */ +async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ user: User | null; sessionToken: string | null }> { + if (mode === "none") return { user: null, sessionToken: null }; + const authz = req.headers.get("authorization"); + const bearer = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : null; + const [kind, scopeName] = mode.split(":") as ["session" | "token" | "any", TokenScope | undefined]; + + if (kind !== "session" && bearer) { + const scope = scopeName!; + const user = await verifyAppToken(db, bearer, scope); + if (user) return { user, sessionToken: null }; + for (const other of SCOPES) { + if (other !== scope && (await verifyAppToken(db, bearer, other))) { + throw new HttpError(403, "forbidden", `This app token lacks the '${scope}' scope.`); + } + } + throw new HttpError(401, "unauthenticated", "Invalid or revoked app token."); + } + if (kind === "token") throw new HttpError(401, "unauthenticated", "Send an app token: Authorization: Bearer ."); + + const token = readCookie(req, SESSION_COOKIE); + const user = token ? await getSessionUser(db, token) : null; + if (!user) throw new HttpError(401, "unauthenticated", "Sign in first."); + // CSRF: browsers cannot set this header cross-site without a CORS preflight, which we never grant. + if (req.method !== "GET" && req.method !== "HEAD" && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) { + throw new HttpError(403, "forbidden", `Cookie-authenticated requests must send the header X-Requested-With: ${CSRF_VALUE}.`); + } + return { user, sessionToken: token }; +} + +interface Compiled { + def: RouteDef; + segments: string[]; +} + +function matchSegments(pattern: string[], parts: string[]): Record | null { + if (pattern.length !== parts.length) return null; + const params: Record = {}; + for (let i = 0; i < pattern.length; i++) { + const p = pattern[i]!; + const v = parts[i]!; + if (p.startsWith(":")) { + if (v === "") return null; + try { + params[p.slice(1)] = decodeURIComponent(v); + } catch { + return null; + } + } else if (p !== v) return null; + } + return params; +} + +/** Build the `/api/v1/*` request handler over a route table. */ +export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/"): (req: Request, ip: string) => Promise { + const compiled: Compiled[] = routes.map((def) => ({ def, segments: def.path.split("/") })); + const limit = rateLimiter(10, 60_000); + + return async (req, ip) => { + try { + const url = new URL(req.url); + const parts = url.pathname.slice(prefix.length).replace(/\/$/, "").split("/"); + let found: { def: RouteDef; params: Record } | null = null; + for (const c of compiled) { + if (c.def.method !== req.method) continue; + const params = matchSegments(c.segments, parts); + if (params) { + found = { def: c.def, params }; + break; + } + } + if (!found) return errorResponse(404, "not_found", "No such endpoint."); + const { def, params } = found; + + if (def.rateLimited && !limit(`${def.path}|${ip}`)) { + return errorResponse(429, "rate_limited", "Too many attempts. Wait a minute and try again.", { "retry-after": "60" }); + } + const { user, sessionToken } = await authenticate(req, def.auth, deps.db); + if (def.admin && user?.role !== "admin") throw new HttpError(403, "forbidden", "Only an admin can do that."); + + let body: unknown; + let form: FormData | undefined; + if (def.multipart) { + const type = req.headers.get("content-type") ?? ""; + if (!type.startsWith("multipart/form-data")) throw new HttpError(400, "validation", "body: expected multipart/form-data"); + const bytes = await readLimited(req, MAX_UPLOAD_BYTES); + try { + form = await new Response(bytes, { headers: { "content-type": type } }).formData(); + } catch { + throw new HttpError(400, "validation", "body: malformed multipart form data"); + } + } else if (def.body) { + const bytes = await readLimited(req, MAX_JSON_BYTES); + let raw: unknown; + if (bytes.byteLength > 0) { + try { + raw = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + throw new HttpError(400, "validation", "body: invalid JSON"); + } + } + body = def.body.parse(raw); + } + const query = def.query ? def.query.parse(Object.fromEntries(url.searchParams)) : undefined; + + const ctx: Ctx = { + req, + url, + params, + body, + query, + get user(): User { + if (!user) throw new Error(`route ${def.method} ${def.path} has no authenticated user`); + return user; + }, + sessionToken, + get form(): FormData { + if (!form) throw new Error(`route ${def.method} ${def.path} is not multipart`); + return form; + }, + deps, + ip, + }; + const out = await def.handler(ctx); + return out instanceof Response ? out : json(out); + } catch (e) { + return toErrorResponse(e); + } + }; +} diff --git a/apps/server/src/openapi.ts b/apps/server/src/openapi.ts new file mode 100644 index 0000000..3012d2f --- /dev/null +++ b/apps/server/src/openapi.ts @@ -0,0 +1,64 @@ +import { z } from "zod"; +import type { RouteDef } from "./http.ts"; + +type JsonSchema = Record; + +const toSchema = (s: z.ZodType): JsonSchema => { + const { $schema: _, ...rest } = z.toJSONSchema(s, { io: "input", unrepresentable: "any" }) as JsonSchema; + return rest; +}; + +const SECURITY: Record = { + none: [], + session: [{ session: [] }], + "token:read": [{ appToken: ["read"] }], + "token:write": [{ appToken: ["write"] }], + "any:read": [{ session: [] }, { appToken: ["read"] }], + "any:write": [{ session: [] }, { appToken: ["write"] }], +}; + +/** OpenAPI 3.1 document generated from the route table (paths, summaries, request schemas). */ +export function buildOpenApi(routes: RouteDef[], prefix = "/api/v1"): JsonSchema { + const paths: Record> = {}; + for (const r of routes) { + const path = `${prefix}/${r.path.replace(/:(\w+)/g, "{$1}")}`; + const parameters: JsonSchema[] = [...r.path.matchAll(/:(\w+)/g)].map((m) => ({ + name: m[1], + in: "path", + required: true, + schema: { type: "string" }, + })); + if (r.query) { + const q = toSchema(r.query) as { properties?: Record; required?: string[] }; + for (const [name, schema] of Object.entries(q.properties ?? {})) { + parameters.push({ name, in: "query", required: q.required?.includes(name) ?? false, schema }); + } + } + const op: JsonSchema = { + operationId: `${r.method.toLowerCase()}_${r.path.replace(/[^\w]+/g, "_")}`, + summary: r.summary, + security: SECURITY[r.auth], + responses: { + default: { + description: "Success, or an error: `{ error: { code, message } }`.", + }, + }, + }; + if (parameters.length > 0) op.parameters = parameters; + if (r.body) op.requestBody = { required: !r.body.safeParse(undefined).success, content: { "application/json": { schema: toSchema(r.body) } } }; + if (r.multipart) op.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object" } } } }; + (paths[path] ??= {})[r.method.toLowerCase()] = op; + } + return { + openapi: "3.1.0", + info: { title: "STTracker API", version: "1" }, + servers: [{ url: "/" }], + components: { + securitySchemes: { + session: { type: "apiKey", in: "cookie", name: "stt_session", description: "Non-GET requests also need X-Requested-With: sttracker." }, + appToken: { type: "http", scheme: "bearer", description: "App token with scopes read, write or ingest." }, + }, + }, + paths, + }; +} diff --git a/apps/server/src/routes/attachments.ts b/apps/server/src/routes/attachments.ts new file mode 100644 index 0000000..f759980 --- /dev/null +++ b/apps/server/src/routes/attachments.ts @@ -0,0 +1,76 @@ +import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment, unlinkAttachment } from "@sttracker/core/attachments"; +import { NotFoundError } from "@sttracker/core/errors"; +import type { AttachmentEntity } from "@sttracker/db/schema"; +import { z } from "zod"; +import { json, noContent, route } from "../http.ts"; +import { formFile } from "./form.ts"; + +const fields = z.object({ + entity: z.enum(["entry", "engagement", "copy", "list", "work"]), + entityId: z.string().min(1), + caption: z.string().max(10_000).optional(), +}); + +/** A filename safe inside a quoted Content-Disposition value. */ +export function safeFilename(name: string): string { + const cleaned = name.replace(/[^\w.\- ]/g, "_").slice(0, 200).trim(); + return cleaned === "" ? "file" : cleaned; +} + +export const attachmentRoutes = [ + route({ + method: "POST", + path: "attachments", + summary: "Upload a file (multipart: file, entity, entityId, caption) and link it to one of your records.", + auth: "any:write", + multipart: true, + handler: async ({ deps, user, form }) => { + const file = formFile(form); + const f = fields.parse({ + entity: form.get("entity") ?? undefined, + entityId: form.get("entityId") ?? undefined, + caption: form.get("caption") ?? undefined, + }); + await requireLinkTarget(deps.db, user.id, f.entity as AttachmentEntity, f.entityId); + const att = await storeAttachment( + deps.db, + deps.dataDir, + user.id, + { bytes: new Uint8Array(await file.arrayBuffer()), mime: file.type || "application/octet-stream", filename: file.name, caption: f.caption }, + { entity: f.entity as AttachmentEntity, entityId: f.entityId }, + ); + return json({ ...att, links: await listAttachmentLinks(deps.db, user.id, att.id) }, 201); + }, + }), + route({ + method: "GET", + path: "attachments/:id", + summary: "Download an attachment's file.", + auth: "any:read", + handler: async ({ deps, user, params }) => { + const a = await readAttachment(deps.db, deps.dataDir, user.id, params.id!); + if (!a || !(await a.file.exists())) throw new NotFoundError("attachment"); + return new Response(a.file, { + headers: { + "content-type": a.mime, + "x-content-type-options": "nosniff", + "content-disposition": `inline; filename="${safeFilename(a.filename)}"`, + "content-security-policy": "default-src 'none'; sandbox", + "cache-control": "private, max-age=3600", + }, + }); + }, + }), + route({ + method: "DELETE", + path: "attachments/:id/links/:linkId", + summary: "Unlink an attachment; it is deleted when no links remain.", + auth: "any:write", + handler: async ({ deps, user, params }) => { + const link = (await listAttachmentLinks(deps.db, user.id, params.id!)).find((l) => l.id === params.linkId); + if (!link) throw new NotFoundError("attachment link"); + await unlinkAttachment(deps.db, deps.dataDir, user.id, params.id!, link.entity, link.entityId); + return noContent(); + }, + }), +]; diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts new file mode 100644 index 0000000..f691def --- /dev/null +++ b/apps/server/src/routes/auth.ts @@ -0,0 +1,76 @@ +import { createAppToken, createInvite, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth"; +import { z } from "zod"; +import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts"; + +const credentials = z.object({ + username: z.string().min(1).max(64), + password: z.string().min(8).max(1024), +}); + +export const authRoutes = [ + route({ + method: "POST", + path: "auth/register", + summary: "Create an account. The first user becomes admin; later ones need an invite code.", + auth: "none", + rateLimited: true, + body: credentials.extend({ displayName: z.string().min(1).max(100).optional(), inviteCode: z.string().max(200).optional() }).strict(), + handler: async ({ deps, body }) => json(await registerUser(deps.db, body), 201), + }), + route({ + method: "POST", + path: "auth/login", + summary: "Sign in; sets the stt_session cookie.", + auth: "none", + rateLimited: true, + body: z.object({ username: z.string().max(64), password: z.string().max(1024) }).strict(), + handler: async ({ deps, body }) => { + const { user, sessionToken } = await login(deps.db, body.username, body.password); + return json(user, 200, { "set-cookie": sessionCookie(sessionToken, deps.publicUrl) }); + }, + }), + route({ + method: "POST", + path: "auth/logout", + summary: "End the current session and clear the cookie.", + auth: "session", + handler: async ({ deps, sessionToken }) => { + if (sessionToken) await logout(deps.db, sessionToken); + return new Response(null, { status: 204, headers: { "set-cookie": clearSessionCookie(deps.publicUrl) } }); + }, + }), + route({ method: "GET", path: "me", summary: "The signed-in user.", auth: "any:read", handler: async ({ user }) => user }), + route({ + method: "GET", + path: "me/tokens", + summary: "List your app tokens (metadata only).", + auth: "session", + handler: async ({ deps, user }) => listAppTokens(deps.db, user.id), + }), + route({ + method: "POST", + path: "me/tokens", + summary: "Create an app token. The token is returned once.", + auth: "session", + body: z.object({ name: z.string().min(1).max(100), scopes: z.array(z.enum(["read", "write", "ingest"])).min(1) }).strict(), + handler: async ({ deps, user, body }) => json(await createAppToken(deps.db, user.id, body.name, [...new Set(body.scopes)]), 201), + }), + route({ + method: "DELETE", + path: "me/tokens/:id", + summary: "Revoke an app token.", + auth: "session", + handler: async ({ deps, user, params }) => { + await revokeAppToken(deps.db, user.id, params.id!); + return noContent(); + }, + }), + route({ + method: "POST", + path: "admin/invites", + summary: "Create an invite code (admin). The code is returned once.", + auth: "session", + body: z.object({ ttlDays: z.number().int().min(1).max(365).optional() }).strict().optional(), + handler: async ({ deps, user, body }) => json({ code: await createInvite(deps.db, user.id, body?.ttlDays) }, 201), + }), +]; diff --git a/apps/server/src/routes/changes.ts b/apps/server/src/routes/changes.ts new file mode 100644 index 0000000..0ca89e4 --- /dev/null +++ b/apps/server/src/routes/changes.ts @@ -0,0 +1,31 @@ +import { listChanges, resolveChange } from "@sttracker/core/sync/changes"; +import { z } from "zod"; +import { noContent, route } from "../http.ts"; +import { qInt } from "./schemas.ts"; + +export const changeRoutes = [ + route({ + method: "GET", + path: "changes", + summary: "The Changes inbox (pending by default; status=all for everything).", + auth: "any:read", + query: z.object({ + status: z.enum(["pending", "all"]).optional(), + connectionId: z.string().optional(), + limit: qInt.optional(), + cursor: z.string().optional(), + }), + handler: async ({ deps, user, query }) => listChanges(deps.db, user.id, query), + }), + route({ + method: "POST", + path: "changes/:id/resolve", + summary: "Resolve a pending change: accept, ignore or keep_mine.", + auth: "any:write", + body: z.object({ action: z.enum(["accept", "ignore", "keep_mine"]) }).strict(), + handler: async ({ deps, user, params, body }) => { + await resolveChange(deps.db, user.id, params.id!, body.action); + return noContent(); + }, + }), +]; diff --git a/apps/server/src/routes/copies.ts b/apps/server/src/routes/copies.ts new file mode 100644 index 0000000..6de9f3b --- /dev/null +++ b/apps/server/src/routes/copies.ts @@ -0,0 +1,58 @@ +import { createCopy, listCopies, softDeleteCopy, updateCopy } from "@sttracker/core/copies"; +import { z } from "zod"; +import { json, noContent, route } from "../http.ts"; +import { customFields, partialDate, text } from "./schemas.ts"; + +const copyPatch = z + .object({ + workId: z.string().min(1).nullable(), + titleHint: z.string().max(1000).nullable(), + format: z.string().max(200).nullable(), + acquiredFrom: z.string().max(500).nullable(), + acquiredAt: partialDate.nullable(), + priceCents: z.number().int().nullable(), + currency: z.string().length(3).nullable(), + notes: text().nullable(), + customFields, + sourceRef: z.string().max(500).nullable(), + }) + .partial() + .strict(); +type CopyArg = Parameters[3]; + +export const copyRoutes = [ + route({ + method: "GET", + path: "copies", + summary: "Your owned copies (optionally of one work).", + auth: "any:read", + query: z.object({ workId: z.string().optional() }), + handler: async ({ deps, user, query }) => listCopies(deps.db, user.id, query), + }), + route({ + method: "POST", + path: "copies", + summary: "Record an owned copy.", + auth: "any:write", + body: copyPatch, + handler: async ({ deps, user, body }) => json(await createCopy(deps.db, user.id, body as CopyArg), 201), + }), + route({ + method: "PATCH", + path: "copies/:id", + summary: "Edit an owned copy.", + auth: "any:write", + body: copyPatch, + handler: async ({ deps, user, params, body }) => updateCopy(deps.db, user.id, params.id!, body as CopyArg), + }), + route({ + method: "DELETE", + path: "copies/:id", + summary: "Soft-delete an owned copy.", + auth: "any:write", + handler: async ({ deps, user, params }) => { + await softDeleteCopy(deps.db, user.id, params.id!); + return noContent(); + }, + }), +]; diff --git a/apps/server/src/routes/custom-fields.ts b/apps/server/src/routes/custom-fields.ts new file mode 100644 index 0000000..5686c88 --- /dev/null +++ b/apps/server/src/routes/custom-fields.ts @@ -0,0 +1,32 @@ +import { CUSTOM_FIELD_TYPES, defineCustomField, listCustomFields } from "@sttracker/core/custom-fields"; +import { z } from "zod"; +import { json, route } from "../http.ts"; + +const level = z.enum(["entry", "engagement", "copy"]); + +export const customFieldRoutes = [ + route({ + method: "GET", + path: "custom-fields", + summary: "Your custom field definitions.", + auth: "any:read", + query: z.object({ mediaType: z.string().optional(), level: level.optional() }), + handler: async ({ deps, user, query }) => listCustomFields(deps.db, user.id, query.mediaType, query.level), + }), + route({ + method: "POST", + path: "custom-fields", + summary: "Define a custom field.", + auth: "any:write", + body: z + .object({ + name: z.string().min(1).max(200), + type: z.enum(CUSTOM_FIELD_TYPES), + options: z.array(z.string().max(200)).max(500).optional(), + appliesTo: z.union([z.literal("all"), z.array(z.string().max(50))]).optional(), + level: level.optional(), + }) + .strict(), + handler: async ({ deps, user, body }) => json(await defineCustomField(deps.db, user.id, body), 201), + }), +]; diff --git a/apps/server/src/routes/engagements.ts b/apps/server/src/routes/engagements.ts new file mode 100644 index 0000000..a239947 --- /dev/null +++ b/apps/server/src/routes/engagements.ts @@ -0,0 +1,55 @@ +import { addEngagement, listEngagements, softDeleteEngagement, updateEngagement } from "@sttracker/core/engagements"; +import { z } from "zod"; +import { json, noContent, route } from "../http.ts"; +import { customFields, partialDate, rating, text } from "./schemas.ts"; + +const engagementPatch = z + .object({ + startedAt: partialDate.nullable(), + finishedAt: partialDate.nullable(), + progress: z.record(z.string(), z.union([z.number(), z.string().max(200)])), + platform: z.string().max(200).nullable(), + rating, + notes: text().nullable(), + customFields, + }) + .partial() + .strict(); + +export const engagementRoutes = [ + route({ + method: "GET", + path: "entries/:id/engagements", + summary: "Reads, watches or plays of an entry.", + auth: "any:read", + handler: async ({ deps, user, params }) => listEngagements(deps.db, user.id, params.id!), + }), + route({ + method: "POST", + path: "entries/:id/engagements", + summary: "Log a read, watch or play.", + auth: "any:write", + body: engagementPatch, + handler: async ({ deps, user, params, body }) => + json(await addEngagement(deps.db, user.id, params.id!, body as Parameters[3], "user"), 201), + }), + route({ + method: "PATCH", + path: "engagements/:id", + summary: "Edit an engagement.", + auth: "any:write", + body: engagementPatch, + handler: async ({ deps, user, params, body }) => + updateEngagement(deps.db, user.id, params.id!, body as Parameters[3], "user"), + }), + route({ + method: "DELETE", + path: "engagements/:id", + summary: "Soft-delete an engagement.", + auth: "any:write", + handler: async ({ deps, user, params }) => { + await softDeleteEngagement(deps.db, user.id, params.id!, "user"); + return noContent(); + }, + }), +]; diff --git a/apps/server/src/routes/entries.ts b/apps/server/src/routes/entries.ts new file mode 100644 index 0000000..c8c5657 --- /dev/null +++ b/apps/server/src/routes/entries.ts @@ -0,0 +1,107 @@ +import { getEntry, listEntries, moveEntryToWork, quickAdd, softDeleteEntry, upsertEntry } from "@sttracker/core/entries"; +import { getHistory, restoreVersion } from "@sttracker/core/history"; +import { z } from "zod"; +import { json, noContent, route } from "../http.ts"; +import { customFields, mediaType, qBool, qInt, rating, status, text, visibility } from "./schemas.ts"; + +const entryPatch = z + .object({ + status, + rating, + review: text().nullable(), + privateNotes: text().nullable(), + favorite: z.boolean(), + tags: z.array(z.string().max(100)).max(200), + customFields, + visibility, + overrides: z.object({ title: z.string().max(1000), coverUrl: z.string().max(2000), year: z.number().int().nullable() }).partial().strict(), + mediaTypeOverride: mediaType.nullable(), + }) + .partial() + .strict(); + +export const entryRoutes = [ + route({ + method: "GET", + path: "entries", + summary: "List your library entries (filter, sort, cursor pagination).", + auth: "any:read", + query: z.object({ + mediaType: z.string().optional(), + status: status.optional(), + tag: z.string().optional(), + draft: qBool.optional(), + q: z.string().max(500).optional(), + parentId: z.string().optional(), + sort: z.enum(["updated", "title", "rating"]).optional(), + limit: qInt.optional(), + cursor: z.string().optional(), + }), + handler: async ({ deps, user, query }) => listEntries(deps.db, user.id, query as Parameters[2]), + }), + route({ + method: "POST", + path: "entries/quick", + summary: "Quick-add an entry by title (creates a private draft work).", + auth: "any:write", + body: z.object({ title: z.string().min(1).max(1000), mediaType: mediaType.optional() }).strict(), + handler: async ({ deps, user, body }) => json(await quickAdd(deps.db, user.id, body), 201), + }), + route({ + method: "GET", + path: "entries/:id", + summary: "One entry with its work.", + auth: "any:read", + handler: async ({ deps, user, params }) => getEntry(deps.db, user.id, params.id!), + }), + route({ + method: "PATCH", + path: "entries/:id", + summary: "Edit an entry. Edited fields become user-owned (syncs stop overwriting them).", + auth: "any:write", + body: entryPatch, + handler: async ({ deps, user, params, body }) => { + const cur = await getEntry(deps.db, user.id, params.id!); + await upsertEntry(deps.db, user.id, cur.workId, body as Parameters[3], "user"); + return getEntry(deps.db, user.id, params.id!); + }, + }), + route({ + method: "DELETE", + path: "entries/:id", + summary: "Soft-delete an entry.", + auth: "any:write", + handler: async ({ deps, user, params }) => { + await softDeleteEntry(deps.db, user.id, params.id!, "user"); + return noContent(); + }, + }), + route({ + method: "GET", + path: "entries/:id/history", + summary: "Change history of an entry, newest first.", + auth: "any:read", + handler: async ({ deps, user, params }) => getHistory(deps.db, user.id, "entry", params.id!), + }), + route({ + method: "POST", + path: "history/:id/restore", + summary: "Restore the values a history row replaced.", + auth: "any:write", + handler: async ({ deps, user, params }) => { + await restoreVersion(deps.db, user.id, params.id!); + return noContent(); + }, + }), + route({ + method: "POST", + path: "entries/:id/move", + summary: "Move an entry to another work (for you only).", + auth: "any:write", + body: z.object({ workId: z.string().min(1) }).strict(), + handler: async ({ deps, user, params, body }) => { + const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, "user"); + return getEntry(deps.db, user.id, moved.id); + }, + }), +]; diff --git a/apps/server/src/routes/export.ts b/apps/server/src/routes/export.ts new file mode 100644 index 0000000..2ccc2d7 --- /dev/null +++ b/apps/server/src/routes/export.ts @@ -0,0 +1,49 @@ +import { NotFoundError } from "@sttracker/core/errors"; +import { exportUserBundle, exportUserCsv, importUserBundle } from "@sttracker/core/export"; +import { z } from "zod"; +import { route } from "../http.ts"; +import { formFile } from "./form.ts"; + +const stamp = () => new Date().toISOString().slice(0, 10); + +export const exportRoutes = [ + route({ + method: "GET", + path: "export/bundle", + summary: "Download your data as an sttracker-export/v1 zip bundle.", + auth: "any:read", + handler: async ({ deps, user }) => + new Response((await exportUserBundle(deps.db, deps.dataDir, user.id)) as Uint8Array, { + headers: { + "content-type": "application/zip", + "content-disposition": `attachment; filename="sttracker-${user.username}-${stamp()}.zip"`, + }, + }), + }), + route({ + method: "GET", + path: "export/csv", + summary: "Download one CSV file of your data (e.g. file=entries.csv).", + auth: "any:read", + query: z.object({ file: z.string().regex(/^[a-z_]+\.csv$/) }), + handler: async ({ deps, user, query }) => { + const files = await exportUserCsv(deps.db, user.id); + const body = files[query.file]; + if (body === undefined) throw new NotFoundError("csv file"); + return new Response(body, { + headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="${query.file}"` }, + }); + }, + }), + route({ + method: "POST", + path: "import/bundle", + summary: "Restore an export bundle (multipart: file) into your empty account.", + auth: "session", + multipart: true, + handler: async ({ deps, user, form }) => { + const file = formFile(form); + return importUserBundle(deps.db, deps.dataDir, user.id, new Uint8Array(await file.arrayBuffer())); + }, + }), +]; diff --git a/apps/server/src/routes/form.ts b/apps/server/src/routes/form.ts new file mode 100644 index 0000000..285599c --- /dev/null +++ b/apps/server/src/routes/form.ts @@ -0,0 +1,8 @@ +import { HttpError } from "../http.ts"; + +/** The `file` part of a multipart body, or a 400. */ +export function formFile(form: FormData, field = "file"): File { + const f = form.get(field); + if (!(f instanceof File)) throw new HttpError(400, "validation", `${field}: a file is required`); + return f; +} diff --git a/apps/server/src/routes/lists.ts b/apps/server/src/routes/lists.ts new file mode 100644 index 0000000..b260d38 --- /dev/null +++ b/apps/server/src/routes/lists.ts @@ -0,0 +1,84 @@ +import { addListItem, createList, getList, listLists, moveListItem, removeListItem, softDeleteList, updateList } from "@sttracker/core/lists"; +import { z } from "zod"; +import { json, noContent, route } from "../http.ts"; +import { rating, text, visibility } from "./schemas.ts"; + +const kind = z.enum(["list", "grouping", "thread"]); +const listPatch = z + .object({ name: z.string().min(1).max(500), description: text(), kind, visibility, rating, review: text().nullable() }) + .partial() + .strict(); +type ListPatchArg = Parameters[3]; + +export const listRoutes = [ + route({ + method: "GET", + path: "lists", + summary: "Your lists.", + auth: "any:read", + query: z.object({ kind: kind.optional() }), + handler: async ({ deps, user, query }) => listLists(deps.db, user.id, query), + }), + route({ + method: "POST", + path: "lists", + summary: "Create a list.", + auth: "any:write", + body: listPatch.required({ name: true }), + handler: async ({ deps, user, body }) => json(await createList(deps.db, user.id, body as ListPatchArg & { name: string }), 201), + }), + route({ + method: "GET", + path: "lists/:id", + summary: "A list with its items.", + auth: "any:read", + handler: async ({ deps, user, params }) => getList(deps.db, user.id, params.id!), + }), + route({ + method: "PATCH", + path: "lists/:id", + summary: "Edit a list.", + auth: "any:write", + body: listPatch, + handler: async ({ deps, user, params, body }) => updateList(deps.db, user.id, params.id!, body as ListPatchArg), + }), + route({ + method: "DELETE", + path: "lists/:id", + summary: "Soft-delete a list.", + auth: "any:write", + handler: async ({ deps, user, params }) => { + await softDeleteList(deps.db, user.id, params.id!); + return noContent(); + }, + }), + route({ + method: "POST", + path: "lists/:id/items", + summary: "Add a work to a list.", + auth: "any:write", + body: z.object({ workId: z.string().min(1), note: text(10_000).nullable().optional(), position: z.number().int().min(0).optional() }).strict(), + handler: async ({ deps, user, params, body }) => json(await addListItem(deps.db, user.id, params.id!, body), 201), + }), + route({ + method: "PATCH", + path: "list-items/:id", + summary: "Move a list item to a new 0-based position.", + auth: "any:write", + body: z.object({ position: z.number().int().min(0) }).strict(), + handler: async ({ deps, user, params, body }) => { + await moveListItem(deps.db, user.id, params.id!, body.position); + return noContent(); + }, + }), + route({ + method: "DELETE", + path: "list-items/:id", + summary: "Remove an item from a list.", + auth: "any:write", + handler: async ({ deps, user, params }) => { + await removeListItem(deps.db, user.id, params.id!); + return noContent(); + }, + }), +]; diff --git a/apps/server/src/routes/meta.ts b/apps/server/src/routes/meta.ts new file mode 100644 index 0000000..15e8c94 --- /dev/null +++ b/apps/server/src/routes/meta.ts @@ -0,0 +1,40 @@ +import { route, type RouteDef } from "../http.ts"; +import { buildOpenApi } from "../openapi.ts"; + +/** Routes that describe the API and instance. `table` returns the full route table (for OpenAPI). */ +export function metaRoutes(table: () => RouteDef[]): RouteDef[] { + let doc: unknown; + return [ + route({ + method: "GET", + path: "openapi.json", + summary: "This API's OpenAPI document.", + auth: "none", + handler: async () => (doc ??= buildOpenApi(table())), + }), + route({ + method: "GET", + path: "about", + summary: "Instance information and data-source attributions.", + auth: "none", + handler: async ({ deps }) => ({ + name: "STTracker", + attributions: deps.registry + .list() + .filter((m) => m.attribution) + .map((m) => ({ pluginId: m.id, name: m.name, attribution: m.attribution })), + }), + }), + route({ + method: "POST", + path: "admin/plugins/reload", + summary: "Rescan the plugin directories (admin).", + auth: "session", + admin: true, + handler: async ({ deps }) => { + await deps.registry.reload(); + return deps.registry.list(); + }, + }), + ]; +} diff --git a/apps/server/src/routes/schemas.ts b/apps/server/src/routes/schemas.ts new file mode 100644 index 0000000..e2445a0 --- /dev/null +++ b/apps/server/src/routes/schemas.ts @@ -0,0 +1,19 @@ +import { MEDIA_TYPES, STATUSES, VISIBILITIES } from "@sttracker/core/entries"; +import { z } from "zod"; + +/** Shared request schemas. Core functions re-validate; these give early, documented 400s. */ +export const rating = z.number().int().min(0).max(100).nullable(); +export const mediaType = z.enum(MEDIA_TYPES); +export const status = z.enum(STATUSES as [string, ...string[]]); +export const visibility = z.enum(VISIBILITIES as [string, ...string[]]); +export const partialDate = z.object({ + value: z.string().max(40), + precision: z.enum(["day", "month", "year", "unknown"]), +}); +export const customFields = z.record(z.string(), z.unknown()); +export const text = (max = 100_000) => z.string().max(max); + +/** Query-string boolean: "true" / "false". */ +export const qBool = z.enum(["true", "false"]).transform((v) => v === "true"); +/** Query-string positive integer. */ +export const qInt = z.coerce.number().int().min(1).max(1000); diff --git a/apps/server/src/routes/sources.ts b/apps/server/src/routes/sources.ts new file mode 100644 index 0000000..b8a27fb --- /dev/null +++ b/apps/server/src/routes/sources.ts @@ -0,0 +1,143 @@ +import { + createConnection, + deleteConnection, + devicePoll, + deviceStart, + getConnection, + isDeletedConnection, + listConnections, + listRuns, + updateConnection, +} from "@sttracker/core/connections"; +import { ValidationError } from "@sttracker/core/errors"; +import { enqueue } from "@sttracker/core/jobs"; +import type { PluginRegistry } from "@sttracker/core/registry"; +import { z } from "zod"; +import { HttpError, json, noContent, route } from "../http.ts"; +import { formFile } from "./form.ts"; +import { qInt } from "./schemas.ts"; + +const ALREADY_RUNNING = "A sync for this source is already queued or running."; + +function pluginOf(registry: PluginRegistry, pluginId: string) { + const plugin = registry.get(pluginId); + if (!plugin) throw new ValidationError("pluginId", "not_installed"); + return plugin; +} + +async function enqueueSync(db: Parameters[0], connectionId: string, payload: Record): Promise { + const jobId = await enqueue(db, "sync", { connectionId, ...payload }, { lockKey: `sync:${connectionId}` }); + if (jobId === null) throw new HttpError(409, "conflict", ALREADY_RUNNING); + return json({ jobId }, 202); +} + +const schedule = z.number().int().min(1).nullable(); +const config = z.record(z.string(), z.unknown()); +const creds = z.record(z.string(), z.string()); + +export const sourceRoutes = [ + route({ + method: "GET", + path: "plugins", + summary: "Installed source plugins (manifests).", + auth: "any:read", + handler: async ({ deps }) => deps.registry.list(), + }), + route({ + method: "GET", + path: "sources", + summary: "Your source connections (never includes secrets).", + auth: "any:read", + handler: async ({ deps, user }) => (await listConnections(deps.db, user.id)).filter((c) => !isDeletedConnection(c)), + }), + route({ + method: "POST", + path: "sources", + summary: "Connect a source. Config fields typed secret are stored encrypted.", + auth: "session", + body: z + .object({ pluginId: z.string().min(1), name: z.string().min(1).max(200), config: config.optional(), credentials: creds.optional(), scheduleMinutes: schedule.optional() }) + .strict(), + handler: async ({ deps, user, body }) => { + const plugin = pluginOf(deps.registry, body.pluginId); + const id = await createConnection(deps.db, deps.secret, user.id, { ...body, config: body.config ?? {} }, plugin.manifest); + return json(await getConnection(deps.db, user.id, id), 201); + }, + }), + route({ + method: "PATCH", + path: "sources/:id", + summary: "Edit a source connection. Omitted secrets are kept.", + auth: "session", + body: z + .object({ name: z.string().min(1).max(200), config, credentials: creds, scheduleMinutes: schedule, status: z.enum(["active", "paused"]) }) + .partial() + .strict(), + handler: async ({ deps, user, params, body }) => { + const conn = await getConnection(deps.db, user.id, params.id!); + await updateConnection(deps.db, deps.secret, user.id, conn.id, body, pluginOf(deps.registry, conn.pluginId).manifest); + return getConnection(deps.db, user.id, conn.id); + }, + }), + route({ + method: "DELETE", + path: "sources/:id", + summary: "Disconnect a source: pauses it and wipes its credentials. Imported data stays.", + auth: "session", + handler: async ({ deps, user, params }) => { + await getConnection(deps.db, user.id, params.id!); + await deleteConnection(deps.db, user.id, params.id!); + return noContent(); + }, + }), + route({ + method: "POST", + path: "sources/:id/run", + summary: "Queue a sync now. 409 if one is already queued or running.", + auth: "any:write", + body: z.object({ mode: z.enum(["full", "changes"]).optional() }).strict().optional(), + handler: async ({ deps, user, params, body }) => { + const conn = await getConnection(deps.db, user.id, params.id!); + return enqueueSync(deps.db, conn.id, body?.mode ? { mode: body.mode } : {}); + }, + }), + route({ + method: "POST", + path: "sources/:id/import", + summary: "Upload an export file (multipart: file) and queue a file-mode sync.", + auth: "any:write", + multipart: true, + handler: async ({ deps, user, params, form }) => { + const conn = await getConnection(deps.db, user.id, params.id!); + if (!pluginOf(deps.registry, conn.pluginId).importFile) throw new ValidationError("pluginId", "cannot_import_files"); + const file = formFile(form); + const base64 = Buffer.from(await file.arrayBuffer()).toString("base64"); + return enqueueSync(deps.db, conn.id, { file: { name: file.name, base64 } }); + }, + }), + route({ + method: "POST", + path: "sources/:id/device/start", + summary: "Start a device-code sign-in with the source.", + auth: "session", + handler: async ({ deps, user, params }) => deviceStart(deps.db, deps.secret, deps.registry, user.id, params.id!), + }), + route({ + method: "POST", + path: "sources/:id/device/poll", + summary: "Poll a device-code sign-in: pending or connected.", + auth: "session", + body: z.object({ deviceCode: z.string().min(1).max(2000) }).strict(), + handler: async ({ deps, user, params, body }) => ({ + status: await devicePoll(deps.db, deps.secret, deps.registry, user.id, params.id!, body.deviceCode), + }), + }), + route({ + method: "GET", + path: "sources/:id/runs", + summary: "Recent sync runs of a source, newest first.", + auth: "any:read", + query: z.object({ limit: qInt.optional() }), + handler: async ({ deps, user, params, query }) => listRuns(deps.db, user.id, params.id!, query.limit), + }), +]; diff --git a/apps/server/src/routes/works.ts b/apps/server/src/routes/works.ts new file mode 100644 index 0000000..cede4ff --- /dev/null +++ b/apps/server/src/routes/works.ts @@ -0,0 +1,56 @@ +import { acceptSuggestion, listSuggestions, rejectSuggestion, resolveVisibleWork } from "@sttracker/core/entries"; +import { NotFoundError } from "@sttracker/core/errors"; +import { getWork, searchWorks } from "@sttracker/core/works"; +import { z } from "zod"; +import { noContent, route } from "../http.ts"; +import { mediaType, qInt } from "./schemas.ts"; + +export const workRoutes = [ + route({ + method: "GET", + path: "works/search", + summary: "Search works visible to you by title.", + auth: "any:read", + query: z.object({ q: z.string().min(1).max(500), mediaType: mediaType.optional(), limit: qInt.optional() }), + handler: async ({ deps, user, query }) => searchWorks(deps.db, user.id, query.q, { mediaType: query.mediaType, limit: query.limit }), + }), + route({ + method: "GET", + path: "works/:id", + summary: "One work (follows merges and your aliases).", + auth: "any:read", + handler: async ({ deps, user, params }) => { + const visible = await resolveVisibleWork(deps.db.k, user.id, params.id!); + const work = await getWork(deps.db, visible.id); + if (!work) throw new NotFoundError("work"); + return work; + }, + }), + route({ + method: "GET", + path: "works/:id/suggestions", + summary: "Pending match suggestions for a work.", + auth: "any:read", + handler: async ({ deps, user, params }) => listSuggestions(deps.db, user.id, params.id!), + }), + route({ + method: "POST", + path: "suggestions/:id/accept", + summary: "Accept a match suggestion (moves your entry to the candidate work).", + auth: "any:write", + handler: async ({ deps, user, params }) => { + await acceptSuggestion(deps.db, user.id, params.id!); + return noContent(); + }, + }), + route({ + method: "POST", + path: "suggestions/:id/reject", + summary: "Reject a match suggestion.", + auth: "any:write", + handler: async ({ deps, user, params }) => { + await rejectSuggestion(deps.db, user.id, params.id!); + return noContent(); + }, + }), +]; diff --git a/apps/server/test/api.test.ts b/apps/server/test/api.test.ts new file mode 100644 index 0000000..1085897 --- /dev/null +++ b/apps/server/test/api.test.ts @@ -0,0 +1,306 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadPlugins, type PluginRegistry } from "@sttracker/core/registry"; +import { forEachDialect } from "@sttracker/db/testing"; +import { createApp } from "../src/app.ts"; +import { loadConfig } from "../src/config.ts"; +import { sessionCookie as buildSessionCookie } from "../src/http.ts"; +import { safeFilename } from "../src/routes/attachments.ts"; + +const ECHO_DIR = join(import.meta.dir, "../../../packages/core/test/fixtures/plugins"); +const registry: PluginRegistry = await loadPlugins([ECHO_DIR]); +const BASE = "http://localhost/api/v1"; + +type App = ReturnType; +interface CallOpts { + body?: unknown; + cookie?: string; + token?: string; + headers?: Record; + form?: FormData; +} + +async function call(app: App, method: string, path: string, o: CallOpts = {}): Promise { + const headers: Record = { ...o.headers }; + if (o.cookie) { + headers.cookie = o.cookie; + headers["x-requested-with"] ??= "sttracker"; + } + if (o.token) headers.authorization = `Bearer ${o.token}`; + let body: BodyInit | undefined; + if (o.form) body = o.form; + else if (o.body !== undefined) { + headers["content-type"] = "application/json"; + body = JSON.stringify(o.body); + } + const url = path.startsWith("http") ? path : `${BASE}/${path}`; + return app.fetch(new Request(url, { method, headers, body })); +} + +function sessionCookie(res: Response): string { + const set = res.headers.get("set-cookie") ?? ""; + const m = /stt_session=([^;]*)/.exec(set); + if (!m) throw new Error(`no session cookie in ${set}`); + return `stt_session=${m[1]}`; +} + +async function signUp(app: App, username: string, inviteCode?: string): Promise { + const reg = await call(app, "POST", "auth/register", { body: { username, password: "correct horse", inviteCode } }); + expect(reg.status).toBe(201); + const res = await call(app, "POST", "auth/login", { body: { username, password: "correct horse" } }); + expect(res.status).toBe(200); + return sessionCookie(res); +} + +test("booting without STTRACKER_SECRET throws the exact message", () => { + expect(() => loadConfig({})).toThrow("Set STTRACKER_SECRET to a long random string. It encrypts source credentials."); +}); + +test("safeFilename strips quotes, newlines and path characters", () => { + expect(safeFilename('a"b\r\nc/../d.png')).toBe("a_b__c_.._d.png"); + expect(safeFilename("")).toBe("file"); +}); + +test("session cookie is Secure only behind an https PUBLIC_URL", () => { + expect(buildSessionCookie("t", "https://st.example")).toContain("; Secure"); + expect(buildSessionCookie("t", "http://localhost:3000")).not.toContain("Secure"); + expect(buildSessionCookie("t", undefined)).toContain("HttpOnly; SameSite=Lax"); +}); + +test("config defaults", () => { + const c = loadConfig({ STTRACKER_SECRET: "s" }); + expect(c).toMatchObject({ + dataDir: "./data", + databaseUrl: "sqlite://./data/sttracker.db", + port: 3000, + pluginDirs: ["plugins", "./data/plugins"], + }); + const d = loadConfig({ STTRACKER_SECRET: "s", DATA_DIR: "/srv/st", PORT: "4000", PLUGIN_DIRS: "a, b" }); + expect(d).toMatchObject({ databaseUrl: "sqlite:///srv/st/sttracker.db", port: 4000, pluginDirs: ["a", "b"] }); +}); + +forEachDialect("api", (getDb) => { + let dataDir: string; + let app: App; + + beforeEach(async () => { + dataDir = await mkdtemp(join(tmpdir(), "stt-api-")); + app = createApp({ db: getDb(), registry, secret: "test-secret", dataDir }); + }); + afterEach(async () => { + await rm(dataDir, { recursive: true, force: true }); + }); + + test("register, login cookie, GET me", async () => { + const reg = await call(app, "POST", "auth/register", { body: { username: "ana", password: "correct horse" } }); + expect(reg.status).toBe(201); + const login = await call(app, "POST", "auth/login", { body: { username: "ana", password: "correct horse" } }); + expect(login.status).toBe(200); + const set = login.headers.get("set-cookie")!; + expect(set).toContain("HttpOnly"); + expect(set).toContain("SameSite=Lax"); + expect(set).not.toContain("Secure"); + const me = await call(app, "GET", "me", { cookie: sessionCookie(login) }); + expect(me.status).toBe(200); + const body = (await me.json()) as Record; + expect(body).toMatchObject({ username: "ana", role: "admin" }); + expect(JSON.stringify(body)).not.toContain("password"); + }); + + test("bad login is 401 JSON", async () => { + await call(app, "POST", "auth/register", { body: { username: "ana", password: "correct horse" } }); + const res = await call(app, "POST", "auth/login", { body: { username: "ana", password: "nope nope" } }); + expect(res.status).toBe(401); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe("unauthenticated"); + }); + + test("unauthenticated GET entries is 401 JSON", async () => { + const res = await call(app, "GET", "entries"); + expect(res.status).toBe(401); + expect(res.headers.get("content-type")).toContain("application/json"); + const body = (await res.json()) as { error: { code: string; message: string } }; + expect(body.error.code).toBe("unauthenticated"); + expect(typeof body.error.message).toBe("string"); + }); + + test("quick add, then the list shows a draft", async () => { + const cookie = await signUp(app, "ana"); + const add = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune", mediaType: "book" } }); + expect(add.status).toBe(201); + const list = await call(app, "GET", "entries?draft=true", { cookie }); + expect(list.status).toBe(200); + const body = (await list.json()) as { items: { draft: boolean; work: { title: string } }[] }; + expect(body.items).toHaveLength(1); + expect(body.items[0]).toMatchObject({ draft: true, work: { title: "Dune" } }); + }); + + test("PATCH rating 150 is 400 validation; 80 works", async () => { + const cookie = await signUp(app, "ana"); + const add = (await (await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" } })).json()) as { id: string }; + const bad = await call(app, "PATCH", `entries/${add.id}`, { cookie, body: { rating: 150 } }); + expect(bad.status).toBe(400); + const err = (await bad.json()) as { error: { code: string; message: string } }; + expect(err.error.code).toBe("validation"); + expect(err.error.message).toContain("rating"); + const ok = await call(app, "PATCH", `entries/${add.id}`, { cookie, body: { rating: 80 } }); + expect(ok.status).toBe(200); + expect(((await ok.json()) as { rating: number }).rating).toBe(80); + }); + + test("an app token with read can GET but cannot PATCH (403)", async () => { + const cookie = await signUp(app, "ana"); + const add = (await (await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" } })).json()) as { id: string }; + const mk = await call(app, "POST", "me/tokens", { cookie, body: { name: "script", scopes: ["read"] } }); + expect(mk.status).toBe(201); + const { token } = (await mk.json()) as { token: string }; + expect((await call(app, "GET", `entries/${add.id}`, { token })).status).toBe(200); + const res = await call(app, "PATCH", `entries/${add.id}`, { token, body: { rating: 10 } }); + expect(res.status).toBe(403); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe("forbidden"); + const list = (await (await call(app, "GET", "me/tokens", { cookie })).json()) as Record[]; + expect(list).toHaveLength(1); + expect(JSON.stringify(list)).not.toContain("hash"); + expect((await call(app, "GET", "entries", { token: "stt_bogus" })).status).toBe(401); + }); + + test("another user's entry is 404", async () => { + const ana = await signUp(app, "ana"); + const inv = await call(app, "POST", "admin/invites", { cookie: ana }); + expect(inv.status).toBe(201); + const { code } = (await inv.json()) as { code: string }; + const bob = await signUp(app, "bob", code); + const add = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string }; + expect((await call(app, "GET", `entries/${add.id}`, { cookie: bob })).status).toBe(404); + const patch = await call(app, "PATCH", `entries/${add.id}`, { cookie: bob, body: { rating: 10 } }); + expect(patch.status).toBe(404); + expect(((await patch.json()) as { error: { code: string } }).error.code).toBe("not_found"); + expect((await call(app, "POST", "admin/invites", { cookie: bob })).status).toBe(403); + }); + + test("cookie-authenticated writes need the CSRF header", async () => { + const cookie = await signUp(app, "ana"); + const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, headers: { "x-requested-with": "" } }); + expect(res.status).toBe(403); + }); + + test("login is rate limited to 10 per minute", async () => { + let last = 0; + for (let i = 0; i < 11; i++) { + last = (await call(app, "POST", "auth/login", { body: { username: "x", password: "y" } })).status; + } + expect(last).toBe(429); + }); + + test("JSON bodies over 1 MB are rejected", async () => { + const cookie = await signUp(app, "ana"); + const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "x".repeat(1_100_000) } }); + expect(res.status).toBe(413); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe("validation"); + }); + + test("sources: create with echo, run twice gives 409, secrets never returned", async () => { + const cookie = await signUp(app, "ana"); + const plugins = (await (await call(app, "GET", "plugins", { cookie })).json()) as { id: string }[]; + expect(plugins.map((p) => p.id)).toContain("echo"); + const mk = await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E", config: { label: "x", token: "sekrit" } } }); + expect(mk.status).toBe(201); + const { id } = (await mk.json()) as { id: string }; + const listed = await (await call(app, "GET", "sources", { cookie })).text(); + expect(listed).not.toContain("sekrit"); + expect(listed).toContain('"hasCredentials":true'); + expect((await call(app, "POST", `sources/${id}/run`, { cookie })).status).toBe(202); + const again = await call(app, "POST", `sources/${id}/run`, { cookie }); + expect(again.status).toBe(409); + expect(await again.json()).toEqual({ + error: { code: "conflict", message: "A sync for this source is already queued or running." }, + }); + expect((await call(app, "GET", `sources/${id}/runs`, { cookie })).status).toBe(200); + expect((await call(app, "DELETE", `sources/${id}`, { cookie })).status).toBe(204); + expect((await (await call(app, "GET", "sources", { cookie })).json()) as unknown[]).toHaveLength(0); + expect((await call(app, "POST", `sources/${id}/run`, { cookie })).status).toBe(404); + }); + + test("sources: unknown plugin is 400; file import without importFile is 400", async () => { + const cookie = await signUp(app, "ana"); + const bad = await call(app, "POST", "sources", { cookie, body: { pluginId: "nope", name: "E" } }); + expect(bad.status).toBe(400); + const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string }; + const form = new FormData(); + form.set("file", new File(["a,b"], "x.csv", { type: "text/csv" })); + expect((await call(app, "POST", `sources/${id}/import`, { cookie, form })).status).toBe(400); + }); + + test("export bundle is a zip; csv picks a file", async () => { + const cookie = await signUp(app, "ana"); + const res = await call(app, "GET", "export/bundle", { cookie }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/zip"); + const bytes = new Uint8Array(await res.arrayBuffer()); + expect([bytes[0], bytes[1]]).toEqual([0x50, 0x4b]); + const csv = await call(app, "GET", "export/csv?file=entries.csv", { cookie }); + expect(csv.status).toBe(200); + expect(csv.headers.get("content-type")).toContain("text/csv"); + expect((await call(app, "GET", "export/csv?file=nope.csv", { cookie })).status).toBe(404); + }); + + test("attachments: upload to own entry, serve with safe headers; another user's entry is 404", async () => { + const ana = await signUp(app, "ana"); + const add = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string }; + const form = new FormData(); + form.set("file", new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], 'wena;me?.png', { type: "image/png" })); + form.set("entity", "entry"); + form.set("entityId", add.id); + const up = await call(app, "POST", "attachments", { cookie: ana, form }); + expect(up.status).toBe(201); + const att = (await up.json()) as { id: string; links: { id: string }[] }; + const got = await call(app, "GET", `attachments/${att.id}`, { cookie: ana }); + expect(got.status).toBe(200); + expect(got.headers.get("content-type")).toBe("image/png"); + expect(got.headers.get("x-content-type-options")).toBe("nosniff"); + expect(got.headers.get("content-disposition")).toBe('inline; filename="we_ird_na_me_.png"'); + + const form2 = new FormData(); + form2.set("file", new File([new Uint8Array([1])], "a.png", { type: "image/png" })); + form2.set("entity", "entry"); + form2.set("entityId", "0190f000-0000-7000-8000-000000000000"); + expect((await call(app, "POST", "attachments", { cookie: ana, form: form2 })).status).toBe(404); + + expect((await call(app, "DELETE", `attachments/${att.id}/links/${att.links[0]!.id}`, { cookie: ana })).status).toBe(204); + expect((await call(app, "GET", `attachments/${att.id}`, { cookie: ana })).status).toBe(404); + }); + + test("openapi.json lists /api/v1/entries", async () => { + const res = await call(app, "GET", "openapi.json"); + expect(res.status).toBe(200); + const doc = (await res.json()) as { openapi: string; paths: Record> }; + expect(doc.openapi).toStartWith("3."); + expect(doc.paths["/api/v1/entries"]?.get?.summary).toBeTruthy(); + expect(doc.paths["/api/v1/entries/{id}"]?.patch).toBeTruthy(); + }); + + test("about lists attributions; ingest and unknown routes are 404 JSON", async () => { + expect((await call(app, "GET", "about")).status).toBe(200); + const ing = await call(app, "POST", "http://localhost/ingest/echo/hook"); + expect(ing.status).toBe(404); + expect(((await ing.json()) as { error: { code: string } }).error.code).toBe("not_found"); + expect((await call(app, "GET", "nope")).status).toBe(404); + }); +}); + +describe("internal errors", () => { + test("are 500 with a generic message", async () => { + const broken = { k: null, dialect: "sqlite", close: async () => {} } as never; + const app = createApp({ db: broken, registry, secret: "s", dataDir: "/nonexistent" }); + const orig = console.error; + console.error = () => {}; + try { + const res = await call(app, "POST", "auth/login", { body: { username: "a", password: "b" } }); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: { code: "internal", message: "Something went wrong on the server." } }); + } finally { + console.error = orig; + } + }); +}); From 02b7eed5ec9f06ec268dbea34196dd90f8a70cef Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:16:15 +0100 Subject: [PATCH 3/9] feat(server): boot: migrate, load plugins, worker, scheduler, daily backup, web placeholder Co-Authored-By: Claude Opus 5.5 --- apps/server/src/boot.ts | 18 ++++++++++ apps/server/src/main.ts | 66 +++++++++++++++++++++++++++++++++++ apps/server/test/boot.test.ts | 22 ++++++++++++ apps/web/index.html | 1 + 4 files changed, 107 insertions(+) create mode 100644 apps/server/src/boot.ts create mode 100644 apps/server/src/main.ts create mode 100644 apps/server/test/boot.test.ts create mode 100644 apps/web/index.html diff --git a/apps/server/src/boot.ts b/apps/server/src/boot.ts new file mode 100644 index 0000000..b22bff4 --- /dev/null +++ b/apps/server/src/boot.ts @@ -0,0 +1,18 @@ +import { enqueue } from "@sttracker/core/jobs"; +import type { Db } from "@sttracker/db/db"; + +/** The next 03:00 local time strictly after `now`. */ +export function nextBackupAt(now: Date = new Date()): Date { + const d = new Date(now); + d.setHours(3, 0, 0, 0); + if (d.getTime() <= now.getTime()) d.setDate(d.getDate() + 1); + return d; +} + +/** + * Queue the daily `backup` job for the next 03:00 unless one is already queued or running (lockKey "backup"). + * Returns the job id, or null when one is pending. + */ +export function ensureBackupScheduled(db: Db, now: Date = new Date()): Promise { + return enqueue(db, "backup", {}, { lockKey: "backup", runAt: nextBackupAt(now).toISOString() }); +} diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts new file mode 100644 index 0000000..c4ebf9a --- /dev/null +++ b/apps/server/src/main.ts @@ -0,0 +1,66 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { backupHandler } from "@sttracker/core/backup"; +import { syncHandler } from "@sttracker/core/connections"; +import { startScheduler, startWorker } from "@sttracker/core/jobs"; +import { loadPlugins } from "@sttracker/core/registry"; +import { createDb } from "@sttracker/db/db"; +import { migrateToLatest } from "@sttracker/db/migrate"; +import index from "../../web/index.html"; +import { createApp } from "./app.ts"; +import { ensureBackupScheduled } from "./boot.ts"; +import { loadConfig } from "./config.ts"; + +const BACKUP_CHECK_MS = 60 * 60 * 1000; + +const config = loadConfig(); +await mkdir(join(config.dataDir, "plugins"), { recursive: true }); +const db = await createDb(config.databaseUrl); +await migrateToLatest(db); +const registry = await loadPlugins(config.pluginDirs); + +const ensureBackup = () => ensureBackupScheduled(db).catch((e) => console.error("[backup] scheduling failed", e)); +const runBackup = backupHandler({ db, dataDir: config.dataDir }); +const worker = startWorker(db, { + sync: syncHandler({ db, secret: config.secret, registry }), + backup: async () => { + try { + await runBackup(); + } finally { + // The finishing job still holds lockKey "backup" until the worker marks it done; queue the next one shortly after. + setTimeout(() => void ensureBackup(), 5_000); + } + }, +}); +const scheduler = startScheduler(db); +await ensureBackup(); +// Safety net: re-queue the daily backup if a run's follow-up was missed (e.g. it failed for good). +const backupTimer = setInterval(() => void ensureBackup(), BACKUP_CHECK_MS); + +const app = createApp({ db, registry, secret: config.secret, dataDir: config.dataDir, publicUrl: config.publicUrl }); +const server = Bun.serve({ + port: config.port, + development: process.env.NODE_ENV !== "production", + routes: { + "/api/*": (req, srv) => app.fetch(req, srv), + "/ingest/*": (req, srv) => app.fetch(req, srv), + "/*": index, + }, + fetch: (req, srv) => app.fetch(req, srv), +}); +console.log(`STTracker listening on ${server.url} (${db.dialect}, ${registry.list().length} plugin(s))`); + +let stopping = false; +async function shutdown(signal: string): Promise { + if (stopping) return; + stopping = true; + console.log(`${signal}: shutting down`); + clearInterval(backupTimer); + scheduler.stop(); + await server.stop(); + await worker.stop(); + await db.close(); + process.exit(0); +} +process.on("SIGTERM", () => void shutdown("SIGTERM")); +process.on("SIGINT", () => void shutdown("SIGINT")); diff --git a/apps/server/test/boot.test.ts b/apps/server/test/boot.test.ts new file mode 100644 index 0000000..3632478 --- /dev/null +++ b/apps/server/test/boot.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { forEachDialect } from "@sttracker/db/testing"; +import { ensureBackupScheduled, nextBackupAt } from "../src/boot.ts"; + +test("nextBackupAt is the next 03:00 local time", () => { + const before = new Date(2026, 9, 30, 2, 59); + expect(nextBackupAt(before).getTime()).toBe(new Date(2026, 9, 30, 3, 0).getTime()); + const after = new Date(2026, 9, 30, 3, 0); + expect(nextBackupAt(after).getTime()).toBe(new Date(2026, 9, 31, 3, 0).getTime()); +}); + +forEachDialect("boot", (getDb) => { + test("ensureBackupScheduled queues one backup job under lockKey backup", async () => { + const db = getDb(); + expect(await ensureBackupScheduled(db)).not.toBeNull(); + expect(await ensureBackupScheduled(db)).toBeNull(); + const jobs = await db.k.selectFrom("jobs").selectAll().where("type", "=", "backup").execute(); + expect(jobs).toHaveLength(1); + expect(jobs[0]).toMatchObject({ lock_key: "backup", status: "queued" }); + expect(new Date(jobs[0]!.run_at).getHours()).toBe(3); + }); +}); diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..d0864e8 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1 @@ +STTracker
STTracker is starting…
From 348b04e9286b3cec46ce3ecd2b06a6aa233bff4f Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:17:18 +0100 Subject: [PATCH 4/9] docs: server architecture, API decisions, follow-ups Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 13 +++++++++++-- docs/DECISIONS.md | 27 +++++++++++++++++++++++++++ docs/TODO.md | 7 +++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 249345d..80bab88 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,8 +14,8 @@ user layer or files a Changes-inbox event. ## Map | Path | Responsibility | State | |---|---|---| -| `apps/server` | `Bun.serve`: `/api/v1`, `/ingest/`, job worker, static web | planned | -| `apps/web` | React app (HTML imports) | planned | +| `apps/server` | `main.ts` boots (config → `createDb` → `migrateToLatest` → `loadPlugins` → worker + scheduler + daily backup → `Bun.serve`). `app.ts` `createApp(deps)` = `/api/v1` route table + `/ingest/*` mount (404 in Phase 1), testable via `app.fetch`. `http.ts` = `route()`, auth (session cookie / Bearer app token), CSRF header, body caps, rate limit, error mapping. `routes/.ts` one file per area; `openapi.ts` builds `/api/v1/openapi.json` from the table | exists | +| `apps/web` | React app (HTML imports); `index.html` is a placeholder until Task 17 | placeholder | | `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`, `export` (per-user bundle, CSV, restore), `backup` (scheduled bundles + SQLite `VACUUM INTO`), `sync/` (the change engine: `snapshot` hashing and versions, `apply` per-record diff and apply, `run` = `runSync`, `changes` = Changes inbox `listChanges`/`resolveChange`, `listens` = `ingestListens`), `jobs` (queue, worker, scheduler), `registry` (`loadPlugins`), `connections` (CRUD, plugin context, `syncHandler`) | exists (partial) | | `packages/db` | Kysely over one Bun.SQL dialect (SQLite + Postgres), ids, JSON helpers, migrations, `forEachDialect` test harness | exists (schema pending) | | `packages/plugin-sdk` | plugin contract, `SourceRecord`, http/rate-limit helpers, fixture test harness | planned | @@ -23,6 +23,15 @@ user layer or files a Changes-inbox event. | `docs/design/mockups` | approved UI mockups (reference only) | exists | ## Data flow +HTTP request (`apps/server`): +1. `Bun.serve` routes `/api/*` and `/ingest/*` to `app.fetch`; everything else gets the web app (HTML import). +2. `apiHandler` matches method + path in the route table, applies the per-IP rate limit (login/register), authenticates + (`session` cookie `stt_session`, Bearer app token with a scope, or either), checks `X-Requested-With: sttracker` on + cookie-authenticated writes, reads the body (JSON ≤ 1 MB, multipart ≤ 50 MB) and validates it with zod. +3. The handler calls one core function with the caller's `userId` and actor `"user"`. Core enforces ownership + (`NotFoundError` → 404). Errors become `{ error: { code, message } }`; unknown errors are logged and return 500 `internal`. +4. `POST sources/:id/run|import` only enqueue a `sync` job (lockKey `sync:`, 409 when held); the worker runs it. + Sync lifecycle (`packages/core/src/sync/`; steps 1–2 are `jobs.ts` and `connections.ts`): 1. The scheduler enqueues a `sync` job (lockKey `sync:`) for each non-paused connection that is due. 2. The worker claims the job; `syncHandler` loads the connection and plugin and calls `runSync(deps, plugin, connectionId, mode)`, injecting diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c6ec997..2fb29c6 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4,6 +4,33 @@ Append-only. Never edit or delete an entry. Supersede it with a new one that lin One entry per decision a future maintainer would otherwise have to reverse-engineer. No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number). +## 2026-10-30: HTTP API: auth modes, CSRF header, error mapping, actor +**Status:** accepted +**Context:** Task 16 (`apps/server`). +**Decision:** +- **Auth modes per route:** `none`, `session` (cookie only: tokens, sources create/edit/delete, device auth, bundle import, + admin), `token:`, `any:` (cookie or Bearer). A Bearer token valid for another scope gets 403, an unknown or + revoked one 401. App tokens cannot mint tokens or handle source credentials. +- **CSRF:** cookie-authenticated non-GET requests must send `X-Requested-With: sttracker` (403 otherwise). Browsers cannot + add it cross-site without a CORS preflight, which the server never grants. Bearer requests are exempt. Cookie is + `stt_session`, HttpOnly, SameSite=Lax, Secure when `PUBLIC_URL` is https, 30 days. +- **Error mapping:** zod and `ValidationError` → 400 `validation` (`field: reason`); `AuthError` → + 401 (`invalid_credentials`, `unauthenticated`), 403 (`forbidden`), 400 with its own code (`invite_required`, + `username_taken`, `invalid_username`); `NotFoundError` → 404 `not_found`; `ImportError` → 400 with its code (409 for + `account_not_empty`, `conflict`); unique violations → 409 `conflict`; body over the cap → 413 `validation`; + rate limit → 429 `rate_limited`; anything else → 500 `internal` with a fixed message, detail logged only. +- **Limits:** JSON bodies 1 MB, multipart 50 MB (counted while streaming, not trusted from Content-Length). + Login and register: 10 per minute per client IP, in process (resets on restart; not shared across instances). +- **Actor:** every API write uses actor `"user"`, including token-authenticated ones, because the change engine only + protects fields whose `field_sources` is `"user"`. A `token:` actor would let the next sync overwrite a script's edit. +- **Daily backup:** queued for the next 03:00 local with lockKey `"backup"` at boot, 5 s after each run (the finishing job + still holds the lock while it runs), and hourly as a safety net. All three are the same idempotent `enqueue`. +- **Deleted connections** (`paused` with no credentials) are hidden from `GET sources` and 404 on every `sources/:id` route. +- **File imports** travel in the job payload as base64 (`{ connectionId, file: { name, base64 } }`); `syncHandler` decodes + them into `runSync`'s `{ file }` mode, recorded as run mode `file`. +- **OpenAPI** 3.1 is generated from the route table (`z.toJSONSchema`, input side, unrepresentable → any): every route + with method, path, summary, security and request schema. Responses are not typed yet. + ## 2026-10-30: Job failure semantics and plugin reload limits **Status:** accepted **Context:** Task 9 fix round 1. diff --git a/docs/TODO.md b/docs/TODO.md index b237630..0f8ce4f 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -7,7 +7,14 @@ Small, specific, independently actionable. Remove only when actually done. - [ ] Choose a linter/formatter (e.g. Biome), then add `lint` to `package.json`, `AGENTS.md` and `docs/PERMISSIONS.md`. - [ ] Add CI on Gitea Actions: install, typecheck, and test on SQLite and Postgres. Add a guard that fails if `AGENTS.md` is over 150 lines. +- [ ] Agent permissions for Task 16 commands: add `bun run typecheck`, `bun apps/server/src/main.ts` and `bun --hot apps/server/src/main.ts` (all verified 2026-10-30) to `docs/PERMISSIONS.md`, `.claude/settings.json`, `.gemini/settings.json`, then fill Typecheck/Dev/Start in `AGENTS.md` Commands. Needs a human: agents may not edit their own permission files. + ## Next +- [ ] API: response schemas in the OpenAPI document (requests only today). +- [ ] API: login/register rate limit is per process and keyed on the socket IP; behind a reverse proxy every client shares one key. Add a trusted-proxy setting for `X-Forwarded-For`. +- [ ] API: file imports are base64 in the `jobs.payload` row (up to ~67 MB of text). Move uploads to `DATA_DIR/uploads/` and pass a path. +- [ ] API: `PATCH list-items/:id` only moves items; editing an item's note needs a core function. +- [ ] Connections: a user-paused connection that never had credentials looks deleted (`paused` + no credentials) and disappears from `GET sources`. Add an explicit `deleted_at`. - [ ] Phase 2 and Phase 3 plans (spec §12). - [ ] Generate image thumbnails for attachments (Phase 1 sizes images with CSS). From 0a540208e1ba1bfd91b641310ace6cfbbceb6004 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:19:08 +0100 Subject: [PATCH 5/9] test(server): silence the broken fixture plugin warning Co-Authored-By: Claude Opus 5.5 --- apps/server/test/api.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/test/api.test.ts b/apps/server/test/api.test.ts index 1085897..e18fa97 100644 --- a/apps/server/test/api.test.ts +++ b/apps/server/test/api.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,7 +10,10 @@ import { sessionCookie as buildSessionCookie } from "../src/http.ts"; import { safeFilename } from "../src/routes/attachments.ts"; const ECHO_DIR = join(import.meta.dir, "../../../packages/core/test/fixtures/plugins"); +// The fixture dir also holds a deliberately broken plugin; silence its load warning. +const warn = spyOn(console, "warn").mockImplementation(() => {}); const registry: PluginRegistry = await loadPlugins([ECHO_DIR]); +warn.mockRestore(); const BASE = "http://localhost/api/v1"; type App = ReturnType; From e3a759d768f624a3e28c09ca9f8a427b5216eb87 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:25:59 +0100 Subject: [PATCH 6/9] fix(core): soft-delete source connections with deleted_at Migration 0003 adds source_connections.deleted_at. deleteConnection sets it; listing, lookups, updates, the scheduler and the sync handler ignore deleted connections, so a paused credential-less connection stays visible and can be resumed. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/routes/sources.ts | 3 +- packages/core/src/connections.ts | 34 ++++++++++------ packages/core/src/jobs.ts | 1 + packages/core/test/api-support.test.ts | 39 ++++++++++++++++++- packages/core/test/connections.test.ts | 3 +- .../0003_source_connections_deleted_at.ts | 13 +++++++ packages/db/src/migrations/index.ts | 2 + packages/db/src/schema.ts | 2 + 8 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 packages/db/src/migrations/0003_source_connections_deleted_at.ts diff --git a/apps/server/src/routes/sources.ts b/apps/server/src/routes/sources.ts index b8a27fb..8cb6061 100644 --- a/apps/server/src/routes/sources.ts +++ b/apps/server/src/routes/sources.ts @@ -4,7 +4,6 @@ import { devicePoll, deviceStart, getConnection, - isDeletedConnection, listConnections, listRuns, updateConnection, @@ -48,7 +47,7 @@ export const sourceRoutes = [ path: "sources", summary: "Your source connections (never includes secrets).", auth: "any:read", - handler: async ({ deps, user }) => (await listConnections(deps.db, user.id)).filter((c) => !isDeletedConnection(c)), + handler: async ({ deps, user }) => listConnections(deps.db, user.id), }), route({ method: "POST", diff --git a/packages/core/src/connections.ts b/packages/core/src/connections.ts index bb12cc8..bab549c 100644 --- a/packages/core/src/connections.ts +++ b/packages/core/src/connections.ts @@ -77,6 +77,7 @@ export async function createConnection(db: Db, secret: string, userId: string, i status: "active", last_run_at: null, created_at: nowIso(), + deleted_at: null, }) .execute(); return id; @@ -94,7 +95,7 @@ export interface ConnectionPatch { export async function updateConnection(db: Db, secret: string, userId: string, id: string, patch: ConnectionPatch, manifest: PluginManifest): Promise { checkSchedule(patch.scheduleMinutes); await db.k.transaction().execute(async (tx) => { - let q = tx.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId); + let q = tx.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId).where("deleted_at", "is", null); if (db.dialect === "postgres") q = q.forUpdate(); const row = await q.executeTakeFirst(); if (!row) throw new NotFoundError("connection"); @@ -116,20 +117,27 @@ export async function updateConnection(db: Db, secret: string, userId: string, i }); } -/** Soft delete: the connection is paused and its credentials are wiped. Records and runs stay. */ +/** Soft delete: `deleted_at` is set, the connection is paused and its credentials are wiped. Records and runs stay. */ export async function deleteConnection(db: Db, userId: string, id: string): Promise { const r = await db.k .updateTable("source_connections") - .set({ status: "paused", credentials_enc: null }) + .set({ status: "paused", credentials_enc: null, deleted_at: nowIso() }) .where("id", "=", id) .where("user_id", "=", userId) + .where("deleted_at", "is", null) .executeTakeFirst(); if (r.numUpdatedRows !== 1n) throw new NotFoundError("connection"); } -/** Never returns secrets: plain config plus `hasCredentials`. */ +/** The user's live (not deleted) connections. Never returns secrets: plain config plus `hasCredentials`. */ export async function listConnections(db: Db, userId: string): Promise { - const rows = await db.k.selectFrom("source_connections").selectAll().where("user_id", "=", userId).orderBy("created_at").execute(); + const rows = await db.k + .selectFrom("source_connections") + .selectAll() + .where("user_id", "=", userId) + .where("deleted_at", "is", null) + .orderBy("created_at") + .execute(); return rows.map((r) => ({ id: r.id, pluginId: r.plugin_id, @@ -143,14 +151,15 @@ export async function listConnections(db: Db, userId: string): Promise): boolean { - return c.status === "paused" && !c.hasCredentials; -} - async function ownedRow(db: Db, userId: string, id: string) { - const r = await db.k.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId).executeTakeFirst(); - if (!r || (r.status === "paused" && r.credentials_enc === null)) throw new NotFoundError("connection"); + const r = await db.k + .selectFrom("source_connections") + .selectAll() + .where("id", "=", id) + .where("user_id", "=", userId) + .where("deleted_at", "is", null) + .executeTakeFirst(); + if (!r) throw new NotFoundError("connection"); return r; } @@ -342,6 +351,7 @@ export function syncHandler(deps: SyncHandlerDeps): (payload: SyncPayload, jc?: const { db } = deps; const full = await db.k.selectFrom("source_connections").selectAll().where("id", "=", payload.connectionId).executeTakeFirst(); if (!full) throw new DropJobError("connection not found"); + if (full.deleted_at !== null) throw new DropJobError("connection is deleted"); if (full.status === "paused") throw new DropJobError("connection is paused"); const plugin = deps.registry.get(full.plugin_id); if (!plugin) return failBeforeRun(db, full.id, payload.file ? "file" : (payload.mode ?? "full"), `Plugin '${full.plugin_id}' is not installed or failed to load.`); diff --git a/packages/core/src/jobs.ts b/packages/core/src/jobs.ts index 4620857..155ce2c 100644 --- a/packages/core/src/jobs.ts +++ b/packages/core/src/jobs.ts @@ -198,6 +198,7 @@ export function startScheduler(db: Db, opts: { tickMs?: number } = {}): { stop() .selectFrom("source_connections") .select(["id", "schedule_minutes", "last_run_at"]) .where("status", "!=", "paused") + .where("deleted_at", "is", null) .where("schedule_minutes", "is not", null) .execute(); const now = Date.now(); diff --git a/packages/core/test/api-support.test.ts b/packages/core/test/api-support.test.ts index 552134c..b5a26c0 100644 --- a/packages/core/test/api-support.test.ts +++ b/packages/core/test/api-support.test.ts @@ -7,7 +7,9 @@ import { forEachDialect } from "@sttracker/db/testing"; import { definePlugin, type SourcePlugin } from "@sttracker/plugin-sdk/types"; import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment } from "../src/attachments.ts"; import { createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts"; -import { createConnection, deleteConnection, devicePoll, deviceStart, getConnection, listRuns, syncHandler } from "../src/connections.ts"; +import { createConnection, deleteConnection, devicePoll, deviceStart, getConnection, listConnections, listRuns, syncHandler, updateConnection } from "../src/connections.ts"; +import { startScheduler } from "../src/jobs.ts"; +import { DropJobError } from "../src/jobs.ts"; import { listSuggestions, quickAdd } from "../src/entries.ts"; import { addEngagement } from "../src/engagements.ts"; import { NotFoundError, ValidationError } from "../src/errors.ts"; @@ -114,6 +116,41 @@ forEachDialect("api support", (getDb) => { await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError); }); + test("a paused connection without credentials stays listed and can be unpaused; a deleted one is gone", async () => { + const db = getDb(); + const u = await makeUser(db); + const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest); + await updateConnection(db, SECRET, u, id, { status: "paused" }, echo.manifest); + expect((await listConnections(db, u)).map((c) => c.id)).toEqual([id]); + expect((await getConnection(db, u, id)).status).toBe("paused"); + await updateConnection(db, SECRET, u, id, { status: "active" }, echo.manifest); + expect((await getConnection(db, u, id)).status).toBe("active"); + + await deleteConnection(db, u, id); + const row = await db.k.selectFrom("source_connections").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); + expect(row).toMatchObject({ status: "paused", credentials_enc: null }); + expect(row.deleted_at).not.toBeNull(); + expect(await listConnections(db, u)).toEqual([]); + await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError); + await expect(updateConnection(db, SECRET, u, id, { status: "active" }, echo.manifest)).rejects.toBeInstanceOf(NotFoundError); + await expect(deleteConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError); + }); + + test("deleted connections are never scheduled and their queued syncs are dropped", async () => { + const db = getDb(); + const u = await makeUser(db); + const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {}, scheduleMinutes: 1 }, echo.manifest); + // Simulate a delete that left status "error" (e.g. a row edited by hand): deleted_at alone must exclude it. + await deleteConnection(db, u, id); + await db.k.updateTable("source_connections").set({ status: "error" }).where("id", "=", id).execute(); + const sched = startScheduler(db, { tickMs: 3_600_000 }); + await sched.tickNow(); + sched.stop(); + expect(await db.k.selectFrom("jobs").select("id").execute()).toEqual([]); + const sync = syncHandler({ db, secret: SECRET, registry: reg }); + await expect(sync({ connectionId: id })).rejects.toBeInstanceOf(DropJobError); + }); + test("syncHandler runs file mode from a base64 payload", async () => { const db = getDb(); const u = await makeUser(db); diff --git a/packages/core/test/connections.test.ts b/packages/core/test/connections.test.ts index 1859c9d..cd8dfaf 100644 --- a/packages/core/test/connections.test.ts +++ b/packages/core/test/connections.test.ts @@ -55,7 +55,8 @@ forEachDialect("connections", (getDb) => { r = await raw(id); expect(r.status).toBe("paused"); expect(r.credentials_enc).toBeNull(); - expect((await listConnections(db, u))[0]!.hasCredentials).toBe(false); + expect(r.deleted_at).not.toBeNull(); + expect(await listConnections(db, u)).toEqual([]); }); test("cursor persists on set; http uses manifest rate limit", async () => { diff --git a/packages/db/src/migrations/0003_source_connections_deleted_at.ts b/packages/db/src/migrations/0003_source_connections_deleted_at.ts new file mode 100644 index 0000000..9b4763c --- /dev/null +++ b/packages/db/src/migrations/0003_source_connections_deleted_at.ts @@ -0,0 +1,13 @@ +import type { Kysely } from "kysely"; +import type { Migration } from "kysely/migration"; + +/** Soft delete for source connections: a deleted connection is distinct from one the user merely paused. */ +export const sourceConnectionsDeletedAt: Migration = { + async up(db: Kysely) { + await db.schema.alterTable("source_connections").addColumn("deleted_at", "text").execute(); + }, + + async down(db: Kysely) { + await db.schema.alterTable("source_connections").dropColumn("deleted_at").execute(); + }, +}; diff --git a/packages/db/src/migrations/index.ts b/packages/db/src/migrations/index.ts index 4dc1ca0..fd6adc3 100644 --- a/packages/db/src/migrations/index.ts +++ b/packages/db/src/migrations/index.ts @@ -1,9 +1,11 @@ import type { Migration } from "kysely/migration"; import { initial } from "./0001_initial.ts"; import { worksPositionUnique } from "./0002_works_position_unique.ts"; +import { sourceConnectionsDeletedAt } from "./0003_source_connections_deleted_at.ts"; /** All migrations, keyed by sortable name. Imported statically so bundles need no filesystem. */ export const migrations: Record = { "0001_initial": initial, "0002_works_position_unique": worksPositionUnique, + "0003_source_connections_deleted_at": sourceConnectionsDeletedAt, }; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 81c27bf..0d85c9d 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -269,6 +269,8 @@ export interface SourceConnectionsTable { status: ConnectionStatus; last_run_at: string | null; created_at: string; + /** Set by `deleteConnection`; deleted connections are hidden, never scheduled and never synced. */ + deleted_at: string | null; } export interface SourceRunsTable { From aa8e4db070f5b7adeceaab358995f6395b9e0a35 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:29:38 +0100 Subject: [PATCH 7/9] fix(core): token writes keep their actor in history but are user-owned in field_sources fieldOwner(actor) maps every non-plugin actor to "user" wherever field_sources is written (entries, engagements, list ownership from history), so syncs never overwrite a field an API token edited. authenticateAppToken also returns the token name; revokeAppToken reports whether it revoked; listSuggestions queries the canonical work id. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/auth.ts | 16 ++++-- packages/core/src/engagements.ts | 6 +- packages/core/src/entries.ts | 13 +++-- packages/core/src/history.ts | 9 +++ packages/core/src/sync/apply.ts | 6 +- packages/core/test/api-support.test.ts | 78 +++++++++++++++++++++++++- 6 files changed, 110 insertions(+), 18 deletions(-) diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index 8bf546d..6cefcdc 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -170,11 +170,17 @@ export async function createAppToken( } export async function verifyAppToken(db: Db, token: string, scope: TokenScope): Promise { + return (await authenticateAppToken(db, token, scope))?.user ?? null; +} + +/** Like `verifyAppToken`, also returning the token's name (API writes record actor `token:`). */ +export async function authenticateAppToken(db: Db, token: string, scope: TokenScope): Promise<{ user: User; tokenName: string } | null> { const row = await db.k .selectFrom("app_tokens") .innerJoin("users", "users.id", "app_tokens.user_id") .select([ "app_tokens.id as token_id", + "app_tokens.name as token_name", "app_tokens.scopes", "users.id", "users.username", @@ -186,17 +192,19 @@ export async function verifyAppToken(db: Db, token: string, scope: TokenScope): .executeTakeFirst(); if (!row || !fromJson(row.scopes, []).includes(scope)) return null; await db.k.updateTable("app_tokens").set({ last_used_at: nowIso() }).where("id", "=", row.token_id).execute(); - return toUser(row); + return { user: toUser(row), tokenName: row.token_name }; } -export async function revokeAppToken(db: Db, userId: string, id: string): Promise { - await db.k +/** Revoke one of the user's live tokens. Returns false when no such live token exists (unknown, foreign or revoked). */ +export async function revokeAppToken(db: Db, userId: string, id: string): Promise { + const r = await db.k .updateTable("app_tokens") .set({ revoked_at: nowIso() }) .where("id", "=", id) .where("user_id", "=", userId) .where("revoked_at", "is", null) - .execute(); + .executeTakeFirst(); + return Number(r.numUpdatedRows) === 1; } export interface AppTokenInfo { diff --git a/packages/core/src/engagements.ts b/packages/core/src/engagements.ts index e758308..eea8494 100644 --- a/packages/core/src/engagements.ts +++ b/packages/core/src/engagements.ts @@ -15,7 +15,7 @@ import { toStoredDate, } from "./entries.ts"; import { NotFoundError, ValidationError } from "./errors.ts"; -import { type Actor, type Executor, inTx, recordHistory } from "./history.ts"; +import { type Actor, type Executor, fieldOwner, inTx, recordHistory } from "./history.ts"; /** One time through a work (spec §6.3). Dates are partial; `null` means unknown. */ export interface EngagementPatch { @@ -144,7 +144,7 @@ async function write( ): Promise { const cols = await toColumns(k, userId, mediaType, patch); const fieldSources = { ...current.fieldSources }; - for (const key of Object.keys(patch)) fieldSources[key] = actor; + for (const key of Object.keys(patch)) fieldSources[key] = fieldOwner(actor) as Actor; await k .updateTable("engagements") .set({ ...cols, ...extra, field_sources: toJson(fieldSources), updated_at: nowIso() }) @@ -175,7 +175,7 @@ export async function addEngagement( const { mediaType } = await requireEntry(k, userId, entryId); const cols = await toColumns(k, userId, mediaType, patch); const fieldSources: FieldSources = {}; - for (const key of Object.keys(patch)) fieldSources[key] = actor; + for (const key of Object.keys(patch)) fieldSources[key] = fieldOwner(actor) as Actor; const now = nowIso(); const row: Row = { id: newId(), diff --git a/packages/core/src/entries.ts b/packages/core/src/entries.ts index 142f87c..ac5fb3f 100644 --- a/packages/core/src/entries.ts +++ b/packages/core/src/entries.ts @@ -7,7 +7,7 @@ import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date"; import type { MediaType, PartialDate } from "@sttracker/plugin-sdk/types"; import { customFieldsFor, validateCustomValues } from "./custom-fields.ts"; import { NotFoundError, ValidationError } from "./errors.ts"; -import { type Actor, type Executor, inTx, recordHistory } from "./history.ts"; +import { type Actor, type Executor, fieldOwner, inTx, recordHistory } from "./history.ts"; // --------------------------------------------------------------------------------------------- // Shared user-layer helpers (also used by engagements, lists and copies). @@ -320,7 +320,7 @@ async function writeEntry( ): Promise { const fieldSources: Record = { ...current.fieldSources }; for (const key of Object.keys(patch)) { - fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : actor; + fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : (fieldOwner(actor) as Actor); if (fieldSources[key] === undefined) delete fieldSources[key]; } const cols = { ...patchToColumns(patch), ...opts.extra, field_sources: toJson(fieldSources), updated_at: nowIso() }; @@ -985,14 +985,17 @@ export interface MatchSuggestion { createdAt: string; } -/** The user's pending match suggestions for a work, best first. Throws `NotFoundError` if the work is not visible. */ +/** + * The user's pending match suggestions for a work (after following aliases and merges to the canonical work), best first. + * Throws `NotFoundError` if the work is not visible. + */ export async function listSuggestions(db: Db, userId: string, workId: string): Promise { - await resolveVisibleWork(db.k, userId, workId); + const work = await resolveVisibleWork(db.k, userId, workId); const rows = await db.k .selectFrom("match_suggestions") .selectAll() .where("user_id", "=", userId) - .where("work_id", "=", workId) + .where("work_id", "=", work.id) .where("status", "=", "pending") .orderBy("score", "desc") .orderBy("id") diff --git a/packages/core/src/history.ts b/packages/core/src/history.ts index 34b8da6..b0b30cc 100644 --- a/packages/core/src/history.ts +++ b/packages/core/src/history.ts @@ -12,6 +12,15 @@ import { restoreListFromHistory } from "./lists.ts"; /** Who made a change. Plugin actors are `plugin::`; API tokens are `token:`. */ export type Actor = "user" | `plugin:${string}:${string}` | `token:${string}`; +/** + * Who owns a field in `field_sources` after `actor` writes it. Only plugin actors keep their name; the user, + * through the UI or an API token, is always `"user"`, so syncs never overwrite a field a script edited. + * History rows keep the full actor. + */ +export function fieldOwner(actor: string): string { + return actor.startsWith("plugin:") ? actor : "user"; +} + /** User-layer entities that write `user_history`. */ export type HistoryEntity = "entry" | "engagement" | "copy" | "list"; diff --git a/packages/core/src/sync/apply.ts b/packages/core/src/sync/apply.ts index c9c8e8c..88bcf5a 100644 --- a/packages/core/src/sync/apply.ts +++ b/packages/core/src/sync/apply.ts @@ -6,7 +6,7 @@ import type { ChangeStatus, ChangeType, Database } from "@sttracker/db/schema"; import type { EngagementFields, EntryFields, PartialDate, SourceRecord, WorkHint } from "@sttracker/plugin-sdk/types"; import { addEngagement, type EngagementPatch, updateEngagement } from "../engagements.ts"; import { type EntryPatch, fromStoredDate, resolveVisibleWork, upsertEntry } from "../entries.ts"; -import type { Actor, Executor } from "../history.ts"; +import { type Actor, type Executor, fieldOwner } from "../history.ts"; import { createList, replaceListItems, updateList } from "../lists.ts"; import { matchWork } from "../matching.ts"; import { resolveAliasIn, updateWorkFromHintIn } from "../works.ts"; @@ -146,8 +146,8 @@ export async function listFieldSources(k: Executor, listId: string, sourceActor: const fs: Record = { items: sourceActor }; for (const r of rows) { const after = fromJson(r.after, {}); - for (const f of ["name", "description"]) if (f in after) fs[f] = r.actor; - if (r.actor === "user") fs.items = "user"; + for (const f of ["name", "description"]) if (f in after) fs[f] = fieldOwner(r.actor); + if (fieldOwner(r.actor) === "user") fs.items = "user"; } return fs; } diff --git a/packages/core/test/api-support.test.ts b/packages/core/test/api-support.test.ts index b5a26c0..5bc2c13 100644 --- a/packages/core/test/api-support.test.ts +++ b/packages/core/test/api-support.test.ts @@ -6,7 +6,11 @@ import { newId, nowIso } from "@sttracker/db/ids"; import { forEachDialect } from "@sttracker/db/testing"; import { definePlugin, type SourcePlugin } from "@sttracker/plugin-sdk/types"; import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment } from "../src/attachments.ts"; -import { createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts"; +import { authenticateAppToken, createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts"; +import { getHistory } from "../src/history.ts"; +import { fieldOwner } from "../src/history.ts"; +import { getEntry, upsertEntry } from "../src/entries.ts"; +import { listEngagements } from "../src/engagements.ts"; import { createConnection, deleteConnection, devicePoll, deviceStart, getConnection, listConnections, listRuns, syncHandler, updateConnection } from "../src/connections.ts"; import { startScheduler } from "../src/jobs.ts"; import { DropJobError } from "../src/jobs.ts"; @@ -39,7 +43,21 @@ const fileish: SourcePlugin = definePlugin({ poll: async (_ctx, deviceCode) => (deviceCode === "dc" ? { access_token: "tok" } : "pending"), }, }); -const reg2: PluginRegistry = { get: (id) => (id === "fileish" ? fileish : reg.get(id)), list: () => [...reg.list(), fileish.manifest], reload: async () => {} }; +let flipStatus: "planned" | "completed" = "planned"; +const flip: SourcePlugin = definePlugin({ + manifest: { id: "flip", name: "Flip", version: "1", sdkVersion: 1, mediaTypes: ["book"], capabilities: ["pull"], config: [] }, + async *pull() { + yield { + kind: "entry", + key: "flip-1", + raw: flipStatus, + work: { title: "Flip", mediaType: "book", externalIds: { isbn13: "9780000000010" } }, + entry: { status: flipStatus, rating: flipStatus === "planned" ? 40 : 90 }, + }; + }, +}); +const extra: Record = { fileish, flip }; +const reg2: PluginRegistry = { get: (id) => extra[id] ?? reg.get(id), list: () => [...reg.list(), fileish.manifest, flip.manifest], reload: async () => {} }; forEachDialect("api support", (getDb) => { test("listAppTokens returns metadata only, revoked flagged, scoped to user", async () => { @@ -48,7 +66,9 @@ forEachDialect("api support", (getDb) => { const other = await makeUser(db, "bob"); const t = await createAppToken(db, u, "script", ["read"]); await createAppToken(db, other, "x", ["write"]); - await revokeAppToken(db, u, t.id); + expect(await revokeAppToken(db, other, t.id)).toBe(false); + expect(await revokeAppToken(db, u, t.id)).toBe(true); + expect(await revokeAppToken(db, u, t.id)).toBe(false); const list = await listAppTokens(db, u); expect(list).toHaveLength(1); expect(list[0]).toMatchObject({ id: t.id, name: "script", scopes: ["read"], lastUsedAt: null }); @@ -56,6 +76,53 @@ forEachDialect("api support", (getDb) => { expect(JSON.stringify(list)).not.toContain("hash"); }); + test("fieldOwner: only plugin actors keep their name in field_sources", () => { + expect(fieldOwner("user")).toBe("user"); + expect(fieldOwner("token:script")).toBe("user"); + expect(fieldOwner("plugin:echo:c1")).toBe("plugin:echo:c1"); + }); + + test("authenticateAppToken returns the user and the token name", async () => { + const db = getDb(); + const u = await makeUser(db); + const t = await createAppToken(db, u, "script", ["write"]); + expect(await authenticateAppToken(db, t.token, "write")).toMatchObject({ user: { id: u }, tokenName: "script" }); + expect(await authenticateAppToken(db, t.token, "read")).toBeNull(); + }); + + test("a token write keeps its actor in history, is owned by the user, and a later sync files a pending change", async () => { + const db = getDb(); + const u = await makeUser(db); + flipStatus = "planned"; + const id = await createConnection(db, SECRET, u, { pluginId: "flip", name: "F", config: {} }, flip.manifest); + const sync = syncHandler({ db, secret: SECRET, registry: reg2 }); + await sync({ connectionId: id, mode: "full" }); + const entry = await db.k.selectFrom("entries").selectAll().where("user_id", "=", u).executeTakeFirstOrThrow(); + await upsertEntry(db, u, entry.work_id, { status: "dropped" }, "token:script"); + const view = await getEntry(db, u, entry.id); + expect(view.fieldSources.status).toBe("user"); + expect(view.fieldSources.rating).toBe(`plugin:flip:${id}`); + const hist = await getHistory(db, u, "entry", entry.id); + expect(hist[0]!.actor).toBe("token:script"); + + flipStatus = "completed"; + await sync({ connectionId: id, mode: "full" }); + const after = await getEntry(db, u, entry.id); + expect(after.status).toBe("dropped"); + expect(after.rating).toBe(90); + const pending = await db.k.selectFrom("change_events").selectAll().where("user_id", "=", u).where("status", "=", "pending").execute(); + expect(pending.map((p) => [p.type, JSON.parse(p.payload).field])).toEqual([["changed", "status"]]); + }); + + test("token-authored engagement fields are owned by the user", async () => { + const db = getDb(); + const u = await makeUser(db); + const e = await quickAdd(db, u, { title: "Dune" }); + await addEngagement(db, u, e.id, { platform: "kindle" }, "token:script"); + const [g] = await listEngagements(db, u, e.id); + expect(g!.fieldSources).toEqual({ platform: "user" }); + }); + test("listSuggestions lists pending suggestions for a visible work", async () => { const db = getDb(); const u = await makeUser(db); @@ -70,6 +137,11 @@ forEachDialect("api support", (getDb) => { expect(s[0]).toMatchObject({ workId: draft.workId, candidateWorkId: good, score: 0.75, status: "pending" }); const other = await makeUser(db, "bob"); await expect(listSuggestions(db, other, draft.workId)).rejects.toBeInstanceOf(NotFoundError); + + // Asking by an id the user aliased away lists the canonical work's suggestions. + const old = await makeWork(db, { title: "Old Dune", ownerUserId: u }); + await db.k.insertInto("work_aliases").values({ id: newId(), user_id: u, from_work_id: old, to_work_id: draft.workId }).execute(); + expect((await listSuggestions(db, u, old)).map((x) => x.workId)).toEqual([draft.workId]); }); test("requireLinkTarget checks ownership per entity; links and filename are readable", async () => { From 5f7d54b805c65035cc132fbd7b4e90a62f14ce20 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:30:43 +0100 Subject: [PATCH 8/9] fix(server): token actor, login CSRF, 415, cookie decoding, admin invites, production default - API writes pass token: for Bearer requests and "user" for sessions. - X-Requested-With: sttracker on every non-Bearer write, login and register included. - JSON-body routes reject non-JSON content types with 415. - A malformed stt_session cookie counts as no cookie (401), not 500. - DELETE me/tokens/:id is 404 for unknown or foreign ids; admin/invites is admin-only. - Bun.serve development mode only with NODE_ENV=development or --hot. - OpenAPI documents multipart fields. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/http.ts | 44 ++++++++--- apps/server/src/main.ts | 4 +- apps/server/src/openapi.ts | 10 ++- apps/server/src/routes/attachments.ts | 1 + apps/server/src/routes/auth.ts | 4 +- apps/server/src/routes/copies.ts | 8 +- apps/server/src/routes/engagements.ts | 12 +-- apps/server/src/routes/entries.ts | 12 +-- apps/server/src/routes/lists.ts | 18 ++--- apps/server/test/api.test.ts | 107 ++++++++++++++++++++++++-- 10 files changed, 174 insertions(+), 46 deletions(-) diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 77ec8ba..27b20e4 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -1,5 +1,6 @@ -import { getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth"; +import { authenticateAppToken, getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth"; import { AuthError, ImportError, NotFoundError, ValidationError } from "@sttracker/core/errors"; +import type { Actor } from "@sttracker/core/history"; import { isUniqueViolation } from "@sttracker/core/matching"; import type { PluginRegistry } from "@sttracker/core/registry"; import type { Db } from "@sttracker/db/db"; @@ -33,6 +34,8 @@ export interface Ctx { query: Q; /** The authenticated user. Throws on `auth: "none"` routes, which have none. */ user: User; + /** Who writes: `token:` for Bearer requests, `"user"` for sessions. Pass it to every core write. */ + actor: Actor; /** The session token, when the request was authenticated by cookie. */ sessionToken: string | null; /** Parsed multipart body, for `multipart: true` routes. */ @@ -51,6 +54,8 @@ export interface RouteDef { query?: z.ZodType; /** Parse the body as multipart form data (upload limit) instead of JSON. */ multipart?: boolean; + /** Non-file multipart fields, for the OpenAPI document (handlers validate them). Every multipart route also takes `file`. */ + formFields?: z.ZodObject; /** Rate limit per client IP (login, register). */ rateLimited?: boolean; /** Only admins may call it (403 otherwise). */ @@ -132,7 +137,13 @@ function readCookie(req: Request, name: string): string | null { if (!header) return null; for (const part of header.split(";")) { const i = part.indexOf("="); - if (i > 0 && part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim()) || null; + if (i > 0 && part.slice(0, i).trim() === name) { + try { + return decodeURIComponent(part.slice(i + 1).trim()) || null; + } catch { + return null; // malformed percent-encoding: treat as no cookie + } + } } return null; } @@ -185,16 +196,20 @@ export function rateLimiter(limit: number, windowMs: number): (key: string) => b const SCOPES: TokenScope[] = ["read", "write", "ingest"]; /** Resolve the caller for `mode`. Returns the user and the session token used, if any. */ -async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ user: User | null; sessionToken: string | null }> { - if (mode === "none") return { user: null, sessionToken: null }; +async function authenticate( + req: Request, + mode: AuthMode, + db: Db, +): Promise<{ user: User | null; sessionToken: string | null; actor: Actor }> { + if (mode === "none") return { user: null, sessionToken: null, actor: "user" }; const authz = req.headers.get("authorization"); const bearer = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : null; const [kind, scopeName] = mode.split(":") as ["session" | "token" | "any", TokenScope | undefined]; if (kind !== "session" && bearer) { const scope = scopeName!; - const user = await verifyAppToken(db, bearer, scope); - if (user) return { user, sessionToken: null }; + const auth = await authenticateAppToken(db, bearer, scope); + if (auth) return { user: auth.user, sessionToken: null, actor: `token:${auth.tokenName}` }; for (const other of SCOPES) { if (other !== scope && (await verifyAppToken(db, bearer, other))) { throw new HttpError(403, "forbidden", `This app token lacks the '${scope}' scope.`); @@ -207,11 +222,7 @@ async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ use const token = readCookie(req, SESSION_COOKIE); const user = token ? await getSessionUser(db, token) : null; if (!user) throw new HttpError(401, "unauthenticated", "Sign in first."); - // CSRF: browsers cannot set this header cross-site without a CORS preflight, which we never grant. - if (req.method !== "GET" && req.method !== "HEAD" && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) { - throw new HttpError(403, "forbidden", `Cookie-authenticated requests must send the header X-Requested-With: ${CSRF_VALUE}.`); - } - return { user, sessionToken: token }; + return { user, sessionToken: token, actor: "user" }; } interface Compiled { @@ -261,7 +272,13 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/ if (def.rateLimited && !limit(`${def.path}|${ip}`)) { return errorResponse(429, "rate_limited", "Too many attempts. Wait a minute and try again.", { "retry-after": "60" }); } - const { user, sessionToken } = await authenticate(req, def.auth, deps.db); + // CSRF: every non-Bearer write (login and register included) must carry this header. Browsers cannot add it + // cross-site without a CORS preflight, which the server never grants. Bearer requests are not ambient, so exempt. + const bearer = req.headers.get("authorization")?.startsWith("Bearer ") ?? false; + if (req.method !== "GET" && req.method !== "HEAD" && !bearer && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) { + throw new HttpError(403, "forbidden", `Browser requests that change data must send the header X-Requested-With: ${CSRF_VALUE}.`); + } + const { user, sessionToken, actor } = await authenticate(req, def.auth, deps.db); if (def.admin && user?.role !== "admin") throw new HttpError(403, "forbidden", "Only an admin can do that."); let body: unknown; @@ -279,6 +296,8 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/ const bytes = await readLimited(req, MAX_JSON_BYTES); let raw: unknown; if (bytes.byteLength > 0) { + const type = (req.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase(); + if (type !== "application/json") throw new HttpError(415, "unsupported_media_type", "Send the body as Content-Type: application/json."); try { raw = JSON.parse(new TextDecoder().decode(bytes)); } catch { @@ -299,6 +318,7 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/ if (!user) throw new Error(`route ${def.method} ${def.path} has no authenticated user`); return user; }, + actor, sessionToken, get form(): FormData { if (!form) throw new Error(`route ${def.method} ${def.path} is not multipart`); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index c4ebf9a..b4c0420 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -40,7 +40,9 @@ const backupTimer = setInterval(() => void ensureBackup(), BACKUP_CHECK_MS); const app = createApp({ db, registry, secret: config.secret, dataDir: config.dataDir, publicUrl: config.publicUrl }); const server = Bun.serve({ port: config.port, - development: process.env.NODE_ENV !== "production", + // Production unless asked: dev mode adds HMR and verbose error pages. + // `bun --hot` puts the flag in execArgv, not Bun.argv. + development: process.env.NODE_ENV === "development" || process.execArgv.includes("--hot"), routes: { "/api/*": (req, srv) => app.fetch(req, srv), "/ingest/*": (req, srv) => app.fetch(req, srv), diff --git a/apps/server/src/openapi.ts b/apps/server/src/openapi.ts index 3012d2f..291b71a 100644 --- a/apps/server/src/openapi.ts +++ b/apps/server/src/openapi.ts @@ -46,7 +46,15 @@ export function buildOpenApi(routes: RouteDef[], prefix = "/api/v1"): JsonSchema }; if (parameters.length > 0) op.parameters = parameters; if (r.body) op.requestBody = { required: !r.body.safeParse(undefined).success, content: { "application/json": { schema: toSchema(r.body) } } }; - if (r.multipart) op.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object" } } } }; + if (r.multipart) { + const fields = r.formFields ? (toSchema(r.formFields) as { properties?: JsonSchema; required?: string[] }) : {}; + const schema = { + type: "object", + properties: { file: { type: "string", format: "binary" }, ...fields.properties }, + required: ["file", ...(fields.required ?? [])], + }; + op.requestBody = { required: true, content: { "multipart/form-data": { schema } } }; + } (paths[path] ??= {})[r.method.toLowerCase()] = op; } return { diff --git a/apps/server/src/routes/attachments.ts b/apps/server/src/routes/attachments.ts index f759980..cf6ad43 100644 --- a/apps/server/src/routes/attachments.ts +++ b/apps/server/src/routes/attachments.ts @@ -24,6 +24,7 @@ export const attachmentRoutes = [ summary: "Upload a file (multipart: file, entity, entityId, caption) and link it to one of your records.", auth: "any:write", multipart: true, + formFields: fields, handler: async ({ deps, user, form }) => { const file = formFile(form); const f = fields.parse({ diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index f691def..3032b22 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -1,4 +1,5 @@ import { createAppToken, createInvite, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth"; +import { NotFoundError } from "@sttracker/core/errors"; import { z } from "zod"; import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts"; @@ -61,7 +62,7 @@ export const authRoutes = [ summary: "Revoke an app token.", auth: "session", handler: async ({ deps, user, params }) => { - await revokeAppToken(deps.db, user.id, params.id!); + if (!(await revokeAppToken(deps.db, user.id, params.id!))) throw new NotFoundError("app token"); return noContent(); }, }), @@ -70,6 +71,7 @@ export const authRoutes = [ path: "admin/invites", summary: "Create an invite code (admin). The code is returned once.", auth: "session", + admin: true, body: z.object({ ttlDays: z.number().int().min(1).max(365).optional() }).strict().optional(), handler: async ({ deps, user, body }) => json({ code: await createInvite(deps.db, user.id, body?.ttlDays) }, 201), }), diff --git a/apps/server/src/routes/copies.ts b/apps/server/src/routes/copies.ts index 6de9f3b..ee62ddf 100644 --- a/apps/server/src/routes/copies.ts +++ b/apps/server/src/routes/copies.ts @@ -35,7 +35,7 @@ export const copyRoutes = [ summary: "Record an owned copy.", auth: "any:write", body: copyPatch, - handler: async ({ deps, user, body }) => json(await createCopy(deps.db, user.id, body as CopyArg), 201), + handler: async ({ deps, user, body, actor }) => json(await createCopy(deps.db, user.id, body as CopyArg, actor), 201), }), route({ method: "PATCH", @@ -43,15 +43,15 @@ export const copyRoutes = [ summary: "Edit an owned copy.", auth: "any:write", body: copyPatch, - handler: async ({ deps, user, params, body }) => updateCopy(deps.db, user.id, params.id!, body as CopyArg), + handler: async ({ deps, user, params, body, actor }) => updateCopy(deps.db, user.id, params.id!, body as CopyArg, actor), }), route({ method: "DELETE", path: "copies/:id", summary: "Soft-delete an owned copy.", auth: "any:write", - handler: async ({ deps, user, params }) => { - await softDeleteCopy(deps.db, user.id, params.id!); + handler: async ({ deps, user, params, actor }) => { + await softDeleteCopy(deps.db, user.id, params.id!, actor); return noContent(); }, }), diff --git a/apps/server/src/routes/engagements.ts b/apps/server/src/routes/engagements.ts index a239947..5ff5ca3 100644 --- a/apps/server/src/routes/engagements.ts +++ b/apps/server/src/routes/engagements.ts @@ -30,8 +30,8 @@ export const engagementRoutes = [ summary: "Log a read, watch or play.", auth: "any:write", body: engagementPatch, - handler: async ({ deps, user, params, body }) => - json(await addEngagement(deps.db, user.id, params.id!, body as Parameters[3], "user"), 201), + handler: async ({ deps, user, params, body, actor }) => + json(await addEngagement(deps.db, user.id, params.id!, body as Parameters[3], actor), 201), }), route({ method: "PATCH", @@ -39,16 +39,16 @@ export const engagementRoutes = [ summary: "Edit an engagement.", auth: "any:write", body: engagementPatch, - handler: async ({ deps, user, params, body }) => - updateEngagement(deps.db, user.id, params.id!, body as Parameters[3], "user"), + handler: async ({ deps, user, params, body, actor }) => + updateEngagement(deps.db, user.id, params.id!, body as Parameters[3], actor), }), route({ method: "DELETE", path: "engagements/:id", summary: "Soft-delete an engagement.", auth: "any:write", - handler: async ({ deps, user, params }) => { - await softDeleteEngagement(deps.db, user.id, params.id!, "user"); + handler: async ({ deps, user, params, actor }) => { + await softDeleteEngagement(deps.db, user.id, params.id!, actor); return noContent(); }, }), diff --git a/apps/server/src/routes/entries.ts b/apps/server/src/routes/entries.ts index c8c5657..6b81c21 100644 --- a/apps/server/src/routes/entries.ts +++ b/apps/server/src/routes/entries.ts @@ -60,9 +60,9 @@ export const entryRoutes = [ summary: "Edit an entry. Edited fields become user-owned (syncs stop overwriting them).", auth: "any:write", body: entryPatch, - handler: async ({ deps, user, params, body }) => { + handler: async ({ deps, user, params, body, actor }) => { const cur = await getEntry(deps.db, user.id, params.id!); - await upsertEntry(deps.db, user.id, cur.workId, body as Parameters[3], "user"); + await upsertEntry(deps.db, user.id, cur.workId, body as Parameters[3], actor); return getEntry(deps.db, user.id, params.id!); }, }), @@ -71,8 +71,8 @@ export const entryRoutes = [ path: "entries/:id", summary: "Soft-delete an entry.", auth: "any:write", - handler: async ({ deps, user, params }) => { - await softDeleteEntry(deps.db, user.id, params.id!, "user"); + handler: async ({ deps, user, params, actor }) => { + await softDeleteEntry(deps.db, user.id, params.id!, actor); return noContent(); }, }), @@ -99,8 +99,8 @@ export const entryRoutes = [ summary: "Move an entry to another work (for you only).", auth: "any:write", body: z.object({ workId: z.string().min(1) }).strict(), - handler: async ({ deps, user, params, body }) => { - const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, "user"); + handler: async ({ deps, user, params, body, actor }) => { + const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, actor); return getEntry(deps.db, user.id, moved.id); }, }), diff --git a/apps/server/src/routes/lists.ts b/apps/server/src/routes/lists.ts index b260d38..98d6f0d 100644 --- a/apps/server/src/routes/lists.ts +++ b/apps/server/src/routes/lists.ts @@ -25,7 +25,7 @@ export const listRoutes = [ summary: "Create a list.", auth: "any:write", body: listPatch.required({ name: true }), - handler: async ({ deps, user, body }) => json(await createList(deps.db, user.id, body as ListPatchArg & { name: string }), 201), + handler: async ({ deps, user, body, actor }) => json(await createList(deps.db, user.id, body as ListPatchArg & { name: string }, actor), 201), }), route({ method: "GET", @@ -40,15 +40,15 @@ export const listRoutes = [ summary: "Edit a list.", auth: "any:write", body: listPatch, - handler: async ({ deps, user, params, body }) => updateList(deps.db, user.id, params.id!, body as ListPatchArg), + handler: async ({ deps, user, params, body, actor }) => updateList(deps.db, user.id, params.id!, body as ListPatchArg, actor), }), route({ method: "DELETE", path: "lists/:id", summary: "Soft-delete a list.", auth: "any:write", - handler: async ({ deps, user, params }) => { - await softDeleteList(deps.db, user.id, params.id!); + handler: async ({ deps, user, params, actor }) => { + await softDeleteList(deps.db, user.id, params.id!, actor); return noContent(); }, }), @@ -58,7 +58,7 @@ export const listRoutes = [ summary: "Add a work to a list.", auth: "any:write", body: z.object({ workId: z.string().min(1), note: text(10_000).nullable().optional(), position: z.number().int().min(0).optional() }).strict(), - handler: async ({ deps, user, params, body }) => json(await addListItem(deps.db, user.id, params.id!, body), 201), + handler: async ({ deps, user, params, body, actor }) => json(await addListItem(deps.db, user.id, params.id!, body, actor), 201), }), route({ method: "PATCH", @@ -66,8 +66,8 @@ export const listRoutes = [ summary: "Move a list item to a new 0-based position.", auth: "any:write", body: z.object({ position: z.number().int().min(0) }).strict(), - handler: async ({ deps, user, params, body }) => { - await moveListItem(deps.db, user.id, params.id!, body.position); + handler: async ({ deps, user, params, body, actor }) => { + await moveListItem(deps.db, user.id, params.id!, body.position, actor); return noContent(); }, }), @@ -76,8 +76,8 @@ export const listRoutes = [ path: "list-items/:id", summary: "Remove an item from a list.", auth: "any:write", - handler: async ({ deps, user, params }) => { - await removeListItem(deps.db, user.id, params.id!); + handler: async ({ deps, user, params, actor }) => { + await removeListItem(deps.db, user.id, params.id!, actor); return noContent(); }, }), diff --git a/apps/server/test/api.test.ts b/apps/server/test/api.test.ts index e18fa97..e32299d 100644 --- a/apps/server/test/api.test.ts +++ b/apps/server/test/api.test.ts @@ -23,15 +23,15 @@ interface CallOpts { token?: string; headers?: Record; form?: FormData; + /** Omit the X-Requested-With header that browsers (the web app) send on writes. */ + noCsrf?: boolean; } async function call(app: App, method: string, path: string, o: CallOpts = {}): Promise { const headers: Record = { ...o.headers }; - if (o.cookie) { - headers.cookie = o.cookie; - headers["x-requested-with"] ??= "sttracker"; - } + if (o.cookie) headers.cookie = o.cookie; if (o.token) headers.authorization = `Bearer ${o.token}`; + else if (!o.noCsrf) headers["x-requested-with"] ??= "sttracker"; let body: BodyInit | undefined; if (o.form) body = o.form; else if (o.body !== undefined) { @@ -184,8 +184,16 @@ forEachDialect("api", (getDb) => { test("cookie-authenticated writes need the CSRF header", async () => { const cookie = await signUp(app, "ana"); - const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, headers: { "x-requested-with": "" } }); + const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, noCsrf: true }); expect(res.status).toBe(403); + // Login and register too (login CSRF). + const login = await call(app, "POST", "auth/login", { body: { username: "ana", password: "correct horse" }, noCsrf: true }); + expect(login.status).toBe(403); + expect(login.headers.get("set-cookie")).toBeNull(); + expect((await call(app, "POST", "auth/register", { body: { username: "zed", password: "correct horse" }, noCsrf: true })).status).toBe(403); + // Bearer requests are exempt. + const { token } = (await (await call(app, "POST", "me/tokens", { cookie, body: { name: "w", scopes: ["write"] } })).json()) as { token: string }; + expect((await call(app, "POST", "entries/quick", { token, body: { title: "Dune" } })).status).toBe(201); }); test("login is rate limited to 10 per minute", async () => { @@ -264,16 +272,97 @@ forEachDialect("api", (getDb) => { expect(got.headers.get("x-content-type-options")).toBe("nosniff"); expect(got.headers.get("content-disposition")).toBe('inline; filename="we_ird_na_me_.png"'); + const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string }; + const bob = await signUp(app, "bob", code); + const bobs = (await (await call(app, "POST", "entries/quick", { cookie: bob, body: { title: "Bob's" } })).json()) as { id: string }; const form2 = new FormData(); form2.set("file", new File([new Uint8Array([1])], "a.png", { type: "image/png" })); form2.set("entity", "entry"); - form2.set("entityId", "0190f000-0000-7000-8000-000000000000"); + form2.set("entityId", bobs.id); expect((await call(app, "POST", "attachments", { cookie: ana, form: form2 })).status).toBe(404); + expect((await call(app, "GET", `attachments/${att.id}`, { cookie: bob })).status).toBe(404); expect((await call(app, "DELETE", `attachments/${att.id}/links/${att.links[0]!.id}`, { cookie: ana })).status).toBe(204); expect((await call(app, "GET", `attachments/${att.id}`, { cookie: ana })).status).toBe(404); }); + test("JSON bodies need Content-Type application/json (415)", async () => { + const res = await app.fetch( + new Request(`${BASE}/auth/register`, { + method: "POST", + headers: { "content-type": "text/plain", "x-requested-with": "sttracker" }, + body: JSON.stringify({ username: "ana", password: "correct horse" }), + }), + ); + expect(res.status).toBe(415); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe("unsupported_media_type"); + }); + + test("a malformed session cookie is 401, not 500", async () => { + expect((await call(app, "GET", "me", { headers: { cookie: "stt_session=%" } })).status).toBe(401); + }); + + test("cookie plus a bogus Bearer on a write is 401", async () => { + const cookie = await signUp(app, "ana"); + const res = await call(app, "POST", "entries/quick", { cookie, token: "stt_bogus", body: { title: "Dune" } }); + expect(res.status).toBe(401); + }); + + test("a write-scope token can write; history records the token, field_sources the user", async () => { + const cookie = await signUp(app, "ana"); + const add = (await (await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" } })).json()) as { id: string }; + const { token } = (await (await call(app, "POST", "me/tokens", { cookie, body: { name: "script", scopes: ["write", "read"] } })).json()) as { token: string }; + const res = await call(app, "PATCH", `entries/${add.id}`, { token, body: { rating: 70 } }); + expect(res.status).toBe(200); + expect(((await res.json()) as { fieldSources: Record }).fieldSources.rating).toBe("user"); + const hist = (await (await call(app, "GET", `entries/${add.id}/history`, { token })).json()) as { actor: string }[]; + expect(hist[0]!.actor).toBe("token:script"); + }); + + test("DELETE me/tokens/:id is 404 for unknown or foreign tokens", async () => { + const ana = await signUp(app, "ana"); + const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string }; + const bob = await signUp(app, "bob", code); + const { id } = (await (await call(app, "POST", "me/tokens", { cookie: ana, body: { name: "t", scopes: ["read"] } })).json()) as { id: string }; + expect((await call(app, "DELETE", `me/tokens/${id}`, { cookie: bob })).status).toBe(404); + expect((await call(app, "DELETE", "me/tokens/nope", { cookie: ana })).status).toBe(404); + expect((await call(app, "DELETE", `me/tokens/${id}`, { cookie: ana })).status).toBe(204); + }); + + test("admin-only routes are 403 for members", async () => { + const ana = await signUp(app, "ana"); + const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string }; + const bob = await signUp(app, "bob", code); + expect((await call(app, "POST", "admin/plugins/reload", { cookie: bob })).status).toBe(403); + expect((await call(app, "POST", "admin/invites", { cookie: bob })).status).toBe(403); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + expect((await call(app, "POST", "admin/plugins/reload", { cookie: ana })).status).toBe(200); + } finally { + warn.mockRestore(); + } + }); + + test("a paused source without credentials stays listed and can be resumed", async () => { + const cookie = await signUp(app, "ana"); + const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string }; + expect((await call(app, "PATCH", `sources/${id}`, { cookie, body: { status: "paused" } })).status).toBe(200); + const listed = (await (await call(app, "GET", "sources", { cookie })).json()) as { id: string; status: string }[]; + expect(listed).toMatchObject([{ id, status: "paused" }]); + const resumed = await call(app, "PATCH", `sources/${id}`, { cookie, body: { status: "active" } }); + expect(((await resumed.json()) as { status: string }).status).toBe("active"); + }); + + test("a multipart upload over 50 MB is 413", async () => { + const cookie = await signUp(app, "ana"); + const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string }; + const form = new FormData(); + form.set("file", new File([new Uint8Array(50 * 1024 * 1024 + 1)], "big.csv", { type: "text/csv" })); + const res = await call(app, "POST", `sources/${id}/import`, { cookie, form }); + expect(res.status).toBe(413); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe("validation"); + }); + test("openapi.json lists /api/v1/entries", async () => { const res = await call(app, "GET", "openapi.json"); expect(res.status).toBe(200); @@ -281,6 +370,12 @@ forEachDialect("api", (getDb) => { expect(doc.openapi).toStartWith("3."); expect(doc.paths["/api/v1/entries"]?.get?.summary).toBeTruthy(); expect(doc.paths["/api/v1/entries/{id}"]?.patch).toBeTruthy(); + const up = doc.paths["/api/v1/attachments"]?.post as unknown as { + requestBody: { content: Record; required: string[] } }> }; + }; + const mp = up.requestBody.content["multipart/form-data"]!.schema; + expect(Object.keys(mp.properties).sort()).toEqual(["caption", "entity", "entityId", "file"]); + expect(mp.required).toEqual(["file", "entity", "entityId"]); }); test("about lists attributions; ingest and unknown routes are 404 JSON", async () => { From 0b34ede62e35b6ae65d2414d49998f19f7622689 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:30:59 +0100 Subject: [PATCH 9/9] docs: token actor, connection deleted_at and CSRF decisions; follow-ups Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 8 +++++--- docs/DECISIONS.md | 18 ++++++++++++++++++ docs/TODO.md | 3 ++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 80bab88..fd20afa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -26,9 +26,11 @@ user layer or files a Changes-inbox event. HTTP request (`apps/server`): 1. `Bun.serve` routes `/api/*` and `/ingest/*` to `app.fetch`; everything else gets the web app (HTML import). 2. `apiHandler` matches method + path in the route table, applies the per-IP rate limit (login/register), authenticates - (`session` cookie `stt_session`, Bearer app token with a scope, or either), checks `X-Requested-With: sttracker` on - cookie-authenticated writes, reads the body (JSON ≤ 1 MB, multipart ≤ 50 MB) and validates it with zod. -3. The handler calls one core function with the caller's `userId` and actor `"user"`. Core enforces ownership + (`session` cookie `stt_session`, Bearer app token with a scope, or either), requires `X-Requested-With: sttracker` on + every non-Bearer write (login included), reads the body (JSON ≤ 1 MB with `Content-Type: application/json`, + multipart ≤ 50 MB) and validates it with zod. +3. The handler calls one core function with the caller's `userId` and actor (`token:` for Bearer, else `"user"`; + `field_sources` stores both as `"user"`). Core enforces ownership (`NotFoundError` → 404). Errors become `{ error: { code, message } }`; unknown errors are logged and return 500 `internal`. 4. `POST sources/:id/run|import` only enqueue a `sync` job (lockKey `sync:`, 409 when held); the worker runs it. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 2fb29c6..869d0dd 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -4,6 +4,24 @@ Append-only. Never edit or delete an entry. Supersede it with a new one that lin One entry per decision a future maintainer would otherwise have to reverse-engineer. No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number). +## 2026-10-30: Token actor in history, connection deleted_at, CSRF on every browser write +**Status:** accepted. Supersedes the **Actor**, **CSRF** and **Deleted connections** bullets of +"HTTP API: auth modes, CSRF header, error mapping, actor" below. +**Context:** Task 16 fix round 1 (controller rulings). +**Decision:** +- **Actor vs. owner.** API writes pass actor `token:` for Bearer requests and `"user"` for sessions, so history + shows which script changed what (spec §6.9). `field_sources` stores `fieldOwner(actor)`: every actor that does not start + with `plugin:` is stored as `"user"`. A token edit is the user's edit, and syncs file a pending change instead of + overwriting it. List ownership, which is read from history, applies the same mapping. +- **CSRF.** `X-Requested-With: sttracker` is required on every non-GET request that has no Bearer token, including the + `auth: "none"` routes login and register (login CSRF). JSON-body routes also require `Content-Type: application/json` + (415 `unsupported_media_type` otherwise), so a cross-site simple form post cannot pass as JSON. +- **Deleted connections** carry `source_connections.deleted_at` (migration 0003). `deleteConnection` sets it and still pauses + the connection and wipes its credentials. Listing, lookups, updates, the scheduler and the sync handler ignore deleted rows. + A user-paused connection without credentials is a normal, visible, resumable connection. +- **Dev mode** (`Bun.serve({ development })`) is on only with `NODE_ENV=development` or `bun --hot` (detected through + `process.execArgv`; `Bun.argv` does not carry runtime flags). Everything else runs in production mode. + ## 2026-10-30: HTTP API: auth modes, CSRF header, error mapping, actor **Status:** accepted **Context:** Task 16 (`apps/server`). diff --git a/docs/TODO.md b/docs/TODO.md index 0f8ce4f..0ef72bc 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -11,10 +11,11 @@ Small, specific, independently actionable. Remove only when actually done. ## Next - [ ] API: response schemas in the OpenAPI document (requests only today). +- [ ] Deployment docs (Task 19): the server runs `Bun.serve` in production mode unless `NODE_ENV=development` or `bun --hot`, so Docker needs no `NODE_ENV`. Document `NODE_ENV=development` for local debugging. +- [ ] API: `restoreVersion`, `acceptSuggestion` and `resolveChange` take no actor, so history records `"user"` for them even when an app token calls them. Thread the actor through if script provenance matters there. - [ ] API: login/register rate limit is per process and keyed on the socket IP; behind a reverse proxy every client shares one key. Add a trusted-proxy setting for `X-Forwarded-For`. - [ ] API: file imports are base64 in the `jobs.payload` row (up to ~67 MB of text). Move uploads to `DATA_DIR/uploads/` and pass a path. - [ ] API: `PATCH list-items/:id` only moves items; editing an item's note needs a core function. -- [ ] Connections: a user-paused connection that never had credentials looks deleted (`paused` + no credentials) and disappears from `GET sources`. Add an explicit `deleted_at`. - [ ] Phase 2 and Phase 3 plans (spec §12). - [ ] Generate image thumbnails for attachments (Phase 1 sizes images with CSS).