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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 21:15:01 +01:00
co-authored by Claude Opus 5.5
parent 7b0a6738dd
commit 6963e297b0
19 changed files with 1599 additions and 0 deletions
+49
View File
@@ -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<Response>;
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.");
},
};
}
+30
View File
@@ -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<string, string | undefined> = 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 !== ""),
};
}
+316
View File
@@ -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<B, Q> {
req: Request;
url: URL;
params: Record<string, string>;
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<B = any, Q = any> {
method: Method;
/** Relative to `/api/v1`, with `:param` segments. */
path: string;
summary: string;
auth: AuthMode;
body?: z.ZodType<B>;
query?: z.ZodType<Q>;
/** 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<B, Q>): Promise<unknown>;
}
/** Declare a route; returns it unchanged with the handler's body and query types inferred from the schemas. */
export function route<BS extends z.ZodType = z.ZodUnknown, QS extends z.ZodType = z.ZodUnknown>(
def: Omit<RouteDef<z.output<BS>, z.output<QS>>, "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<string, string> = {}): 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<string, string> = {}): Response {
return json({ error: { code, message } }, status, headers);
}
export const noContent = (): Response => new Response(null, { status: 204 });
const AUTH_STATUS: Record<string, [number, string, string]> = {
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<Uint8Array<ArrayBuffer>> {
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<string, number[]>();
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 <token>.");
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<string, string> | null {
if (pattern.length !== parts.length) return null;
const params: Record<string, string> = {};
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<Response> {
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<string, string> } | 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<unknown, unknown> = {
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);
}
};
}
+64
View File
@@ -0,0 +1,64 @@
import { z } from "zod";
import type { RouteDef } from "./http.ts";
type JsonSchema = Record<string, unknown>;
const toSchema = (s: z.ZodType): JsonSchema => {
const { $schema: _, ...rest } = z.toJSONSchema(s, { io: "input", unrepresentable: "any" }) as JsonSchema;
return rest;
};
const SECURITY: Record<RouteDef["auth"], { [k: string]: string[] }[]> = {
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<string, Record<string, JsonSchema>> = {};
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<string, JsonSchema>; 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,
};
}
+76
View File
@@ -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();
},
}),
];
+76
View File
@@ -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),
}),
];
+31
View File
@@ -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();
},
}),
];
+58
View File
@@ -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<typeof updateCopy>[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();
},
}),
];
+32
View File
@@ -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),
}),
];
+55
View File
@@ -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<typeof addEngagement>[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<typeof updateEngagement>[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();
},
}),
];
+107
View File
@@ -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<typeof listEntries>[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<typeof upsertEntry>[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);
},
}),
];
+49
View File
@@ -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<ArrayBuffer>, {
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()));
},
}),
];
+8
View File
@@ -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;
}
+84
View File
@@ -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<typeof updateList>[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();
},
}),
];
+40
View File
@@ -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();
},
}),
];
}
+19
View File
@@ -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);
+143
View File
@@ -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<typeof enqueue>[0], connectionId: string, payload: Record<string, unknown>): Promise<Response> {
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),
}),
];
+56
View File
@@ -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();
},
}),
];
+306
View File
@@ -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<typeof createApp>;
interface CallOpts {
body?: unknown;
cookie?: string;
token?: string;
headers?: Record<string, string>;
form?: FormData;
}
async function call(app: App, method: string, path: string, o: CallOpts = {}): Promise<Response> {
const headers: Record<string, string> = { ...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<string> {
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<string, unknown>;
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<string, unknown>[];
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])], 'we<ird>na;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<string, Record<string, { summary: string }>> };
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;
}
});
});