From 6963e297b07d8547b7bdefd05fee534e8a807bb9 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 21:15:01 +0100 Subject: [PATCH] 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; + } + }); +});