merge: task 16 server API

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 21:40:38 +01:00
co-authored by Claude Opus 5.5
39 changed files with 2455 additions and 31 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.");
},
};
}
+18
View File
@@ -0,0 +1,18 @@
import { enqueue } from "@sttracker/core/jobs";
import type { Db } from "@sttracker/db/db";
/** The next 03:00 local time strictly after `now`. */
export function nextBackupAt(now: Date = new Date()): Date {
const d = new Date(now);
d.setHours(3, 0, 0, 0);
if (d.getTime() <= now.getTime()) d.setDate(d.getDate() + 1);
return d;
}
/**
* Queue the daily `backup` job for the next 03:00 unless one is already queued or running (lockKey "backup").
* Returns the job id, or null when one is pending.
*/
export function ensureBackupScheduled(db: Db, now: Date = new Date()): Promise<string | null> {
return enqueue(db, "backup", {}, { lockKey: "backup", runAt: nextBackupAt(now).toISOString() });
}
+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 !== ""),
};
}
+336
View File
@@ -0,0 +1,336 @@
import { authenticateAppToken, getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth";
import { AuthError, ImportError, NotFoundError, ValidationError } from "@sttracker/core/errors";
import type { Actor } from "@sttracker/core/history";
import { isUniqueViolation } from "@sttracker/core/matching";
import type { PluginRegistry } from "@sttracker/core/registry";
import type { Db } from "@sttracker/db/db";
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;
/** Who writes: `token:<name>` for Bearer requests, `"user"` for sessions. Pass it to every core write. */
actor: Actor;
/** The session token, when the request was authenticated by cookie. */
sessionToken: string | null;
/** Parsed multipart body, for `multipart: true` routes. */
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;
/** Non-file multipart fields, for the OpenAPI document (handlers validate them). Every multipart route also takes `file`. */
formFields?: z.ZodObject;
/** Rate limit per client IP (login, register). */
rateLimited?: boolean;
/** Only admins may call it (403 otherwise). */
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) {
try {
return decodeURIComponent(part.slice(i + 1).trim()) || null;
} catch {
return null; // malformed percent-encoding: treat as no cookie
}
}
}
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; actor: Actor }> {
if (mode === "none") return { user: null, sessionToken: null, actor: "user" };
const authz = req.headers.get("authorization");
const bearer = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : null;
const [kind, scopeName] = mode.split(":") as ["session" | "token" | "any", TokenScope | undefined];
if (kind !== "session" && bearer) {
const scope = scopeName!;
const auth = await authenticateAppToken(db, bearer, scope);
if (auth) return { user: auth.user, sessionToken: null, actor: `token:${auth.tokenName}` };
for (const other of SCOPES) {
if (other !== scope && (await verifyAppToken(db, bearer, other))) {
throw new HttpError(403, "forbidden", `This app token lacks the '${scope}' scope.`);
}
}
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.");
return { user, sessionToken: token, actor: "user" };
}
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" });
}
// CSRF: every non-Bearer write (login and register included) must carry this header. Browsers cannot add it
// cross-site without a CORS preflight, which the server never grants. Bearer requests are not ambient, so exempt.
const bearer = req.headers.get("authorization")?.startsWith("Bearer ") ?? false;
if (req.method !== "GET" && req.method !== "HEAD" && !bearer && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
throw new HttpError(403, "forbidden", `Browser requests that change data must send the header X-Requested-With: ${CSRF_VALUE}.`);
}
const { user, sessionToken, actor } = await authenticate(req, def.auth, deps.db);
if (def.admin && user?.role !== "admin") throw new HttpError(403, "forbidden", "Only an admin can do that.");
let body: unknown;
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) {
const type = (req.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase();
if (type !== "application/json") throw new HttpError(415, "unsupported_media_type", "Send the body as Content-Type: application/json.");
try {
raw = JSON.parse(new TextDecoder().decode(bytes));
} catch {
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;
},
actor,
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);
}
};
}
+68
View File
@@ -0,0 +1,68 @@
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { backupHandler } from "@sttracker/core/backup";
import { syncHandler } from "@sttracker/core/connections";
import { startScheduler, startWorker } from "@sttracker/core/jobs";
import { loadPlugins } from "@sttracker/core/registry";
import { createDb } from "@sttracker/db/db";
import { migrateToLatest } from "@sttracker/db/migrate";
import index from "../../web/index.html";
import { createApp } from "./app.ts";
import { ensureBackupScheduled } from "./boot.ts";
import { loadConfig } from "./config.ts";
const BACKUP_CHECK_MS = 60 * 60 * 1000;
const config = loadConfig();
await mkdir(join(config.dataDir, "plugins"), { recursive: true });
const db = await createDb(config.databaseUrl);
await migrateToLatest(db);
const registry = await loadPlugins(config.pluginDirs);
const ensureBackup = () => ensureBackupScheduled(db).catch((e) => console.error("[backup] scheduling failed", e));
const runBackup = backupHandler({ db, dataDir: config.dataDir });
const worker = startWorker(db, {
sync: syncHandler({ db, secret: config.secret, registry }),
backup: async () => {
try {
await runBackup();
} finally {
// The finishing job still holds lockKey "backup" until the worker marks it done; queue the next one shortly after.
setTimeout(() => void ensureBackup(), 5_000);
}
},
});
const scheduler = startScheduler(db);
await ensureBackup();
// Safety net: re-queue the daily backup if a run's follow-up was missed (e.g. it failed for good).
const backupTimer = setInterval(() => void ensureBackup(), BACKUP_CHECK_MS);
const app = createApp({ db, registry, secret: config.secret, dataDir: config.dataDir, publicUrl: config.publicUrl });
const server = Bun.serve({
port: config.port,
// Production unless asked: dev mode adds HMR and verbose error pages.
// `bun --hot` puts the flag in execArgv, not Bun.argv.
development: process.env.NODE_ENV === "development" || process.execArgv.includes("--hot"),
routes: {
"/api/*": (req, srv) => app.fetch(req, srv),
"/ingest/*": (req, srv) => app.fetch(req, srv),
"/*": index,
},
fetch: (req, srv) => app.fetch(req, srv),
});
console.log(`STTracker listening on ${server.url} (${db.dialect}, ${registry.list().length} plugin(s))`);
let stopping = false;
async function shutdown(signal: string): Promise<void> {
if (stopping) return;
stopping = true;
console.log(`${signal}: shutting down`);
clearInterval(backupTimer);
scheduler.stop();
await server.stop();
await worker.stop();
await db.close();
process.exit(0);
}
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
+72
View File
@@ -0,0 +1,72 @@
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) {
const fields = r.formFields ? (toSchema(r.formFields) as { properties?: JsonSchema; required?: string[] }) : {};
const schema = {
type: "object",
properties: { file: { type: "string", format: "binary" }, ...fields.properties },
required: ["file", ...(fields.required ?? [])],
};
op.requestBody = { required: true, content: { "multipart/form-data": { schema } } };
}
(paths[path] ??= {})[r.method.toLowerCase()] = op;
}
return {
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,
};
}
+77
View File
@@ -0,0 +1,77 @@
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,
formFields: fields,
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();
},
}),
];
+78
View File
@@ -0,0 +1,78 @@
import { createAppToken, createInvite, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth";
import { NotFoundError } from "@sttracker/core/errors";
import { z } from "zod";
import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts";
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 }) => {
if (!(await revokeAppToken(deps.db, user.id, params.id!))) throw new NotFoundError("app token");
return noContent();
},
}),
route({
method: "POST",
path: "admin/invites",
summary: "Create an invite code (admin). The code is returned once.",
auth: "session",
admin: true,
body: z.object({ ttlDays: z.number().int().min(1).max(365).optional() }).strict().optional(),
handler: async ({ deps, user, body }) => json({ code: await createInvite(deps.db, user.id, body?.ttlDays) }, 201),
}),
];
+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, actor }) => json(await createCopy(deps.db, user.id, body as CopyArg, actor), 201),
}),
route({
method: "PATCH",
path: "copies/:id",
summary: "Edit an owned copy.",
auth: "any:write",
body: copyPatch,
handler: async ({ deps, user, params, body, actor }) => updateCopy(deps.db, user.id, params.id!, body as CopyArg, actor),
}),
route({
method: "DELETE",
path: "copies/:id",
summary: "Soft-delete an owned copy.",
auth: "any:write",
handler: async ({ deps, user, params, actor }) => {
await softDeleteCopy(deps.db, user.id, params.id!, actor);
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, actor }) =>
json(await addEngagement(deps.db, user.id, params.id!, body as Parameters<typeof addEngagement>[3], actor), 201),
}),
route({
method: "PATCH",
path: "engagements/:id",
summary: "Edit an engagement.",
auth: "any:write",
body: engagementPatch,
handler: async ({ deps, user, params, body, actor }) =>
updateEngagement(deps.db, user.id, params.id!, body as Parameters<typeof updateEngagement>[3], actor),
}),
route({
method: "DELETE",
path: "engagements/:id",
summary: "Soft-delete an engagement.",
auth: "any:write",
handler: async ({ deps, user, params, actor }) => {
await softDeleteEngagement(deps.db, user.id, params.id!, actor);
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, actor }) => {
const cur = await getEntry(deps.db, user.id, params.id!);
await upsertEntry(deps.db, user.id, cur.workId, body as Parameters<typeof upsertEntry>[3], actor);
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, actor }) => {
await softDeleteEntry(deps.db, user.id, params.id!, actor);
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, actor }) => {
const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, actor);
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, actor }) => json(await createList(deps.db, user.id, body as ListPatchArg & { name: string }, actor), 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, actor }) => updateList(deps.db, user.id, params.id!, body as ListPatchArg, actor),
}),
route({
method: "DELETE",
path: "lists/:id",
summary: "Soft-delete a list.",
auth: "any:write",
handler: async ({ deps, user, params, actor }) => {
await softDeleteList(deps.db, user.id, params.id!, actor);
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, actor }) => json(await addListItem(deps.db, user.id, params.id!, body, actor), 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, actor }) => {
await moveListItem(deps.db, user.id, params.id!, body.position, actor);
return noContent();
},
}),
route({
method: "DELETE",
path: "list-items/:id",
summary: "Remove an item from a list.",
auth: "any:write",
handler: async ({ deps, user, params, actor }) => {
await removeListItem(deps.db, user.id, params.id!, actor);
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);
+142
View File
@@ -0,0 +1,142 @@
import {
createConnection,
deleteConnection,
devicePoll,
deviceStart,
getConnection,
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 }) => listConnections(deps.db, user.id),
}),
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();
},
}),
];
+404
View File
@@ -0,0 +1,404 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
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");
// The fixture dir also holds a deliberately broken plugin; silence its load warning.
const warn = spyOn(console, "warn").mockImplementation(() => {});
const registry: PluginRegistry = await loadPlugins([ECHO_DIR]);
warn.mockRestore();
const BASE = "http://localhost/api/v1";
type App = ReturnType<typeof createApp>;
interface CallOpts {
body?: unknown;
cookie?: string;
token?: string;
headers?: Record<string, string>;
form?: FormData;
/** Omit the X-Requested-With header that browsers (the web app) send on writes. */
noCsrf?: boolean;
}
async function call(app: App, method: string, path: string, o: CallOpts = {}): Promise<Response> {
const headers: Record<string, string> = { ...o.headers };
if (o.cookie) headers.cookie = o.cookie;
if (o.token) headers.authorization = `Bearer ${o.token}`;
else if (!o.noCsrf) headers["x-requested-with"] ??= "sttracker";
let body: BodyInit | undefined;
if (o.form) body = o.form;
else if (o.body !== undefined) {
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" }, noCsrf: true });
expect(res.status).toBe(403);
// Login and register too (login CSRF).
const login = await call(app, "POST", "auth/login", { body: { username: "ana", password: "correct horse" }, noCsrf: true });
expect(login.status).toBe(403);
expect(login.headers.get("set-cookie")).toBeNull();
expect((await call(app, "POST", "auth/register", { body: { username: "zed", password: "correct horse" }, noCsrf: true })).status).toBe(403);
// Bearer requests are exempt.
const { token } = (await (await call(app, "POST", "me/tokens", { cookie, body: { name: "w", scopes: ["write"] } })).json()) as { token: string };
expect((await call(app, "POST", "entries/quick", { token, body: { title: "Dune" } })).status).toBe(201);
});
test("login is rate limited to 10 per minute", async () => {
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 { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
const bob = await signUp(app, "bob", code);
const bobs = (await (await call(app, "POST", "entries/quick", { cookie: bob, body: { title: "Bob's" } })).json()) as { id: string };
const form2 = new FormData();
form2.set("file", new File([new Uint8Array([1])], "a.png", { type: "image/png" }));
form2.set("entity", "entry");
form2.set("entityId", bobs.id);
expect((await call(app, "POST", "attachments", { cookie: ana, form: form2 })).status).toBe(404);
expect((await call(app, "GET", `attachments/${att.id}`, { cookie: bob })).status).toBe(404);
expect((await call(app, "DELETE", `attachments/${att.id}/links/${att.links[0]!.id}`, { cookie: ana })).status).toBe(204);
expect((await call(app, "GET", `attachments/${att.id}`, { cookie: ana })).status).toBe(404);
});
test("JSON bodies need Content-Type application/json (415)", async () => {
const res = await app.fetch(
new Request(`${BASE}/auth/register`, {
method: "POST",
headers: { "content-type": "text/plain", "x-requested-with": "sttracker" },
body: JSON.stringify({ username: "ana", password: "correct horse" }),
}),
);
expect(res.status).toBe(415);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("unsupported_media_type");
});
test("a malformed session cookie is 401, not 500", async () => {
expect((await call(app, "GET", "me", { headers: { cookie: "stt_session=%" } })).status).toBe(401);
});
test("cookie plus a bogus Bearer on a write is 401", async () => {
const cookie = await signUp(app, "ana");
const res = await call(app, "POST", "entries/quick", { cookie, token: "stt_bogus", body: { title: "Dune" } });
expect(res.status).toBe(401);
});
test("a write-scope token can write; history records the token, field_sources the user", async () => {
const cookie = await signUp(app, "ana");
const add = (await (await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" } })).json()) as { id: string };
const { token } = (await (await call(app, "POST", "me/tokens", { cookie, body: { name: "script", scopes: ["write", "read"] } })).json()) as { token: string };
const res = await call(app, "PATCH", `entries/${add.id}`, { token, body: { rating: 70 } });
expect(res.status).toBe(200);
expect(((await res.json()) as { fieldSources: Record<string, string> }).fieldSources.rating).toBe("user");
const hist = (await (await call(app, "GET", `entries/${add.id}/history`, { token })).json()) as { actor: string }[];
expect(hist[0]!.actor).toBe("token:script");
});
test("DELETE me/tokens/:id is 404 for unknown or foreign tokens", async () => {
const ana = await signUp(app, "ana");
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
const bob = await signUp(app, "bob", code);
const { id } = (await (await call(app, "POST", "me/tokens", { cookie: ana, body: { name: "t", scopes: ["read"] } })).json()) as { id: string };
expect((await call(app, "DELETE", `me/tokens/${id}`, { cookie: bob })).status).toBe(404);
expect((await call(app, "DELETE", "me/tokens/nope", { cookie: ana })).status).toBe(404);
expect((await call(app, "DELETE", `me/tokens/${id}`, { cookie: ana })).status).toBe(204);
});
test("admin-only routes are 403 for members", async () => {
const ana = await signUp(app, "ana");
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
const bob = await signUp(app, "bob", code);
expect((await call(app, "POST", "admin/plugins/reload", { cookie: bob })).status).toBe(403);
expect((await call(app, "POST", "admin/invites", { cookie: bob })).status).toBe(403);
const warn = spyOn(console, "warn").mockImplementation(() => {});
try {
expect((await call(app, "POST", "admin/plugins/reload", { cookie: ana })).status).toBe(200);
} finally {
warn.mockRestore();
}
});
test("a paused source without credentials stays listed and can be resumed", async () => {
const cookie = await signUp(app, "ana");
const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string };
expect((await call(app, "PATCH", `sources/${id}`, { cookie, body: { status: "paused" } })).status).toBe(200);
const listed = (await (await call(app, "GET", "sources", { cookie })).json()) as { id: string; status: string }[];
expect(listed).toMatchObject([{ id, status: "paused" }]);
const resumed = await call(app, "PATCH", `sources/${id}`, { cookie, body: { status: "active" } });
expect(((await resumed.json()) as { status: string }).status).toBe("active");
});
test("a multipart upload over 50 MB is 413", async () => {
const cookie = await signUp(app, "ana");
const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string };
const form = new FormData();
form.set("file", new File([new Uint8Array(50 * 1024 * 1024 + 1)], "big.csv", { type: "text/csv" }));
const res = await call(app, "POST", `sources/${id}/import`, { cookie, form });
expect(res.status).toBe(413);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("validation");
});
test("openapi.json lists /api/v1/entries", async () => {
const res = await call(app, "GET", "openapi.json");
expect(res.status).toBe(200);
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();
const up = doc.paths["/api/v1/attachments"]?.post as unknown as {
requestBody: { content: Record<string, { schema: { properties: Record<string, unknown>; required: string[] } }> };
};
const mp = up.requestBody.content["multipart/form-data"]!.schema;
expect(Object.keys(mp.properties).sort()).toEqual(["caption", "entity", "entityId", "file"]);
expect(mp.required).toEqual(["file", "entity", "entityId"]);
});
test("about lists attributions; ingest and unknown routes are 404 JSON", async () => {
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;
}
});
});
+22
View File
@@ -0,0 +1,22 @@
import { expect, test } from "bun:test";
import { forEachDialect } from "@sttracker/db/testing";
import { ensureBackupScheduled, nextBackupAt } from "../src/boot.ts";
test("nextBackupAt is the next 03:00 local time", () => {
const before = new Date(2026, 9, 30, 2, 59);
expect(nextBackupAt(before).getTime()).toBe(new Date(2026, 9, 30, 3, 0).getTime());
const after = new Date(2026, 9, 30, 3, 0);
expect(nextBackupAt(after).getTime()).toBe(new Date(2026, 9, 31, 3, 0).getTime());
});
forEachDialect("boot", (getDb) => {
test("ensureBackupScheduled queues one backup job under lockKey backup", async () => {
const db = getDb();
expect(await ensureBackupScheduled(db)).not.toBeNull();
expect(await ensureBackupScheduled(db)).toBeNull();
const jobs = await db.k.selectFrom("jobs").selectAll().where("type", "=", "backup").execute();
expect(jobs).toHaveLength(1);
expect(jobs[0]).toMatchObject({ lock_key: "backup", status: "queued" });
expect(new Date(jobs[0]!.run_at).getHours()).toBe(3);
});
});
+1
View File
@@ -0,0 +1 @@
<!doctype html><title>STTracker</title><div id="root">STTracker is starting…</div>
+13 -2
View File
@@ -14,8 +14,8 @@ user layer or files a Changes-inbox event.
## Map
| Path | Responsibility | State |
|---|---|---|
| `apps/server` | `Bun.serve`: `/api/v1`, `/ingest/<plugin>`, job worker, static web | planned |
| `apps/web` | React app (HTML imports) | planned |
| `apps/server` | `main.ts` boots (config → `createDb` → `migrateToLatest` → `loadPlugins` → worker + scheduler + daily backup → `Bun.serve`). `app.ts` `createApp(deps)` = `/api/v1` route table + `/ingest/*` mount (404 in Phase 1), testable via `app.fetch`. `http.ts` = `route()`, auth (session cookie / Bearer app token), CSRF header, body caps, rate limit, error mapping. `routes/<area>.ts` one file per area; `openapi.ts` builds `/api/v1/openapi.json` from the table | exists |
| `apps/web` | React app (HTML imports); `index.html` is a placeholder until Task 17 | placeholder |
| `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`, `export` (per-user bundle, CSV, restore), `backup` (scheduled bundles + SQLite `VACUUM INTO`), `sync/` (the change engine: `snapshot` hashing and versions, `apply` per-record diff and apply, `run` = `runSync`, `changes` = Changes inbox `listChanges`/`resolveChange`, `listens` = `ingestListens`), `jobs` (queue, worker, scheduler), `registry` (`loadPlugins`), `connections` (CRUD, plugin context, `syncHandler`) | exists (partial) |
| `packages/db` | Kysely over one Bun.SQL dialect (SQLite + Postgres), ids, JSON helpers, migrations, `forEachDialect` test harness | exists (schema pending) |
| `packages/plugin-sdk` | plugin contract, `SourceRecord`, http/rate-limit helpers, fixture test harness | planned |
@@ -23,6 +23,17 @@ user layer or files a Changes-inbox event.
| `docs/design/mockups` | approved UI mockups (reference only) | exists |
## Data flow
HTTP request (`apps/server`):
1. `Bun.serve` routes `/api/*` and `/ingest/*` to `app.fetch`; everything else gets the web app (HTML import).
2. `apiHandler` matches method + path in the route table, applies the per-IP rate limit (login/register), authenticates
(`session` cookie `stt_session`, Bearer app token with a scope, or either), requires `X-Requested-With: sttracker` on
every non-Bearer write (login included), reads the body (JSON ≤ 1 MB with `Content-Type: application/json`,
multipart ≤ 50 MB) and validates it with zod.
3. The handler calls one core function with the caller's `userId` and actor (`token:<name>` for Bearer, else `"user"`;
`field_sources` stores both as `"user"`). Core enforces ownership
(`NotFoundError` → 404). Errors become `{ error: { code, message } }`; unknown errors are logged and return 500 `internal`.
4. `POST sources/:id/run|import` only enqueue a `sync` job (lockKey `sync:<id>`, 409 when held); the worker runs it.
Sync lifecycle (`packages/core/src/sync/`; steps 1–2 are `jobs.ts` and `connections.ts`):
1. The scheduler enqueues a `sync` job (lockKey `sync:<connectionId>`) for each non-paused connection that is due.
2. The worker claims the job; `syncHandler` loads the connection and plugin and calls `runSync(deps, plugin, connectionId, mode)`, injecting
+45
View File
@@ -4,6 +4,51 @@ Append-only. Never edit or delete an entry. Supersede it with a new one that lin
One entry per decision a future maintainer would otherwise have to reverse-engineer.
No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number).
## 2026-10-30: Token actor in history, connection deleted_at, CSRF on every browser write
**Status:** accepted. Supersedes the **Actor**, **CSRF** and **Deleted connections** bullets of
"HTTP API: auth modes, CSRF header, error mapping, actor" below.
**Context:** Task 16 fix round 1 (controller rulings).
**Decision:**
- **Actor vs. owner.** API writes pass actor `token:<name>` for Bearer requests and `"user"` for sessions, so history
shows which script changed what (spec §6.9). `field_sources` stores `fieldOwner(actor)`: every actor that does not start
with `plugin:` is stored as `"user"`. A token edit is the user's edit, and syncs file a pending change instead of
overwriting it. List ownership, which is read from history, applies the same mapping.
- **CSRF.** `X-Requested-With: sttracker` is required on every non-GET request that has no Bearer token, including the
`auth: "none"` routes login and register (login CSRF). JSON-body routes also require `Content-Type: application/json`
(415 `unsupported_media_type` otherwise), so a cross-site simple form post cannot pass as JSON.
- **Deleted connections** carry `source_connections.deleted_at` (migration 0003). `deleteConnection` sets it and still pauses
the connection and wipes its credentials. Listing, lookups, updates, the scheduler and the sync handler ignore deleted rows.
A user-paused connection without credentials is a normal, visible, resumable connection.
- **Dev mode** (`Bun.serve({ development })`) is on only with `NODE_ENV=development` or `bun --hot` (detected through
`process.execArgv`; `Bun.argv` does not carry runtime flags). Everything else runs in production mode.
## 2026-10-30: HTTP API: auth modes, CSRF header, error mapping, actor
**Status:** accepted
**Context:** Task 16 (`apps/server`).
**Decision:**
- **Auth modes per route:** `none`, `session` (cookie only: tokens, sources create/edit/delete, device auth, bundle import,
admin), `token:<scope>`, `any:<scope>` (cookie or Bearer). A Bearer token valid for another scope gets 403, an unknown or
revoked one 401. App tokens cannot mint tokens or handle source credentials.
- **CSRF:** cookie-authenticated non-GET requests must send `X-Requested-With: sttracker` (403 otherwise). Browsers cannot
add it cross-site without a CORS preflight, which the server never grants. Bearer requests are exempt. Cookie is
`stt_session`, HttpOnly, SameSite=Lax, Secure when `PUBLIC_URL` is https, 30 days.
- **Error mapping:** zod and `ValidationError` → 400 `validation` (`field: reason`); `AuthError` →
401 (`invalid_credentials`, `unauthenticated`), 403 (`forbidden`), 400 with its own code (`invite_required`,
`username_taken`, `invalid_username`); `NotFoundError` → 404 `not_found`; `ImportError` → 400 with its code (409 for
`account_not_empty`, `conflict`); unique violations → 409 `conflict`; body over the cap → 413 `validation`;
rate limit → 429 `rate_limited`; anything else → 500 `internal` with a fixed message, detail logged only.
- **Limits:** JSON bodies 1 MB, multipart 50 MB (counted while streaming, not trusted from Content-Length).
Login and register: 10 per minute per client IP, in process (resets on restart; not shared across instances).
- **Actor:** every API write uses actor `"user"`, including token-authenticated ones, because the change engine only
protects fields whose `field_sources` is `"user"`. A `token:<name>` actor would let the next sync overwrite a script's edit.
- **Daily backup:** queued for the next 03:00 local with lockKey `"backup"` at boot, 5 s after each run (the finishing job
still holds the lock while it runs), and hourly as a safety net. All three are the same idempotent `enqueue`.
- **Deleted connections** (`paused` with no credentials) are hidden from `GET sources` and 404 on every `sources/:id` route.
- **File imports** travel in the job payload as base64 (`{ connectionId, file: { name, base64 } }`); `syncHandler` decodes
them into `runSync`'s `{ file }` mode, recorded as run mode `file`.
- **OpenAPI** 3.1 is generated from the route table (`z.toJSONSchema`, input side, unrepresentable → any): every route
with method, path, summary, security and request schema. Responses are not typed yet.
## 2026-10-30: Job failure semantics and plugin reload limits
**Status:** accepted
**Context:** Task 9 fix round 1.
+8
View File
@@ -7,7 +7,15 @@ Small, specific, independently actionable. Remove only when actually done.
- [ ] Choose a linter/formatter (e.g. Biome), then add `lint` to `package.json`, `AGENTS.md` and `docs/PERMISSIONS.md`.
- [ ] Add CI on Gitea Actions: install, typecheck, and test on SQLite and Postgres. Add a guard that fails if `AGENTS.md` is over 150 lines.
- [ ] Agent permissions for Task 16 commands: add `bun run typecheck`, `bun apps/server/src/main.ts` and `bun --hot apps/server/src/main.ts` (all verified 2026-10-30) to `docs/PERMISSIONS.md`, `.claude/settings.json`, `.gemini/settings.json`, then fill Typecheck/Dev/Start in `AGENTS.md` Commands. Needs a human: agents may not edit their own permission files.
## Next
- [ ] API: response schemas in the OpenAPI document (requests only today).
- [ ] Deployment docs (Task 19): the server runs `Bun.serve` in production mode unless `NODE_ENV=development` or `bun --hot`, so Docker needs no `NODE_ENV`. Document `NODE_ENV=development` for local debugging.
- [ ] API: `restoreVersion`, `acceptSuggestion` and `resolveChange` take no actor, so history records `"user"` for them even when an app token calls them. Thread the actor through if script provenance matters there.
- [ ] API: login/register rate limit is per process and keyed on the socket IP; behind a reverse proxy every client shares one key. Add a trusted-proxy setting for `X-Forwarded-For`.
- [ ] API: file imports are base64 in the `jobs.payload` row (up to ~67 MB of text). Move uploads to `DATA_DIR/uploads/` and pass a path.
- [ ] API: `PATCH list-items/:id` only moves items; editing an item's note needs a core function.
- [ ] Phase 2 and Phase 3 plans (spec §12).
- [ ] Generate image thumbnails for attachments (Phase 1 sizes images with CSS).
+64 -4
View File
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
import type { Db } from "@sttracker/db/db";
import { newId, nowIso } from "@sttracker/db/ids";
import type { AttachmentEntity, AttachmentsTable } from "@sttracker/db/schema";
import { ValidationError } from "./errors.ts";
import { NotFoundError, ValidationError } from "./errors.ts";
export type Attachment = AttachmentsTable;
@@ -127,15 +127,15 @@ export async function readAttachment(
dataDir: string,
userId: string,
id: string,
): Promise<{ file: ReturnType<typeof Bun.file>; mime: string } | null> {
): Promise<{ file: ReturnType<typeof Bun.file>; mime: string; filename: string } | null> {
const row = await db.k
.selectFrom("attachments")
.select(["sha256", "mime"])
.select(["sha256", "mime", "filename"])
.where("id", "=", id)
.where("user_id", "=", userId)
.executeTakeFirst();
if (!row) return null;
return { file: Bun.file(pathFor(dataDir, row.sha256)), mime: row.mime };
return { file: Bun.file(pathFor(dataDir, row.sha256)), mime: row.mime, filename: row.filename };
}
/** Attachments of `userId` linked to the given entity, oldest first. */
@@ -201,3 +201,63 @@ export async function unlinkAttachment(
if (orphan) await rm(pathFor(dataDir, att.sha256), { force: true });
});
}
const LINK_ENTITIES: readonly AttachmentEntity[] = ["entry", "engagement", "copy", "list", "work"];
/**
* Throw `NotFoundError` unless `entityId` is a live `entity` the user may attach to: their own entry,
* engagement, copy or list, or a work that is shared or theirs. Call before `storeAttachment`.
*/
export async function requireLinkTarget(db: Db, userId: string, entity: AttachmentEntity, entityId: string): Promise<void> {
if (!LINK_ENTITIES.includes(entity)) throw new ValidationError("entity", "invalid");
const k = db.k;
let found: unknown;
switch (entity) {
case "entry":
found = await k.selectFrom("entries").select("id").where("id", "=", entityId).where("user_id", "=", userId).where("deleted_at", "is", null).executeTakeFirst();
break;
case "engagement":
found = await k
.selectFrom("engagements as g")
.innerJoin("entries as e", "e.id", "g.entry_id")
.select("g.id")
.where("g.id", "=", entityId)
.where("e.user_id", "=", userId)
.where("g.deleted_at", "is", null)
.where("e.deleted_at", "is", null)
.executeTakeFirst();
break;
case "copy":
found = await k.selectFrom("copies").select("id").where("id", "=", entityId).where("user_id", "=", userId).where("deleted_at", "is", null).executeTakeFirst();
break;
case "list":
found = await k.selectFrom("lists").select("id").where("id", "=", entityId).where("user_id", "=", userId).where("deleted_at", "is", null).executeTakeFirst();
break;
case "work":
found = await k
.selectFrom("works")
.select("id")
.where("id", "=", entityId)
.where((eb) => eb.or([eb("owner_user_id", "is", null), eb("owner_user_id", "=", userId)]))
.executeTakeFirst();
break;
}
if (!found) throw new NotFoundError(entity);
}
/** The links of one of the user's attachments (empty for another user's or a missing attachment). */
export async function listAttachmentLinks(
db: Db,
userId: string,
attachmentId: string,
): Promise<{ id: string; entity: AttachmentEntity; entityId: string }[]> {
const rows = await db.k
.selectFrom("attachment_links as l")
.innerJoin("attachments as a", "a.id", "l.attachment_id")
.select(["l.id", "l.entity", "l.entity_id"])
.where("a.id", "=", attachmentId)
.where("a.user_id", "=", userId)
.orderBy("l.id")
.execute();
return rows.map((r) => ({ id: r.id, entity: r.entity, entityId: r.entity_id }));
}
+39 -3
View File
@@ -170,11 +170,17 @@ export async function createAppToken(
}
export async function verifyAppToken(db: Db, token: string, scope: TokenScope): Promise<User | null> {
return (await authenticateAppToken(db, token, scope))?.user ?? null;
}
/** Like `verifyAppToken`, also returning the token's name (API writes record actor `token:<name>`). */
export async function authenticateAppToken(db: Db, token: string, scope: TokenScope): Promise<{ user: User; tokenName: string } | null> {
const row = await db.k
.selectFrom("app_tokens")
.innerJoin("users", "users.id", "app_tokens.user_id")
.select([
"app_tokens.id as token_id",
"app_tokens.name as token_name",
"app_tokens.scopes",
"users.id",
"users.username",
@@ -186,15 +192,45 @@ export async function verifyAppToken(db: Db, token: string, scope: TokenScope):
.executeTakeFirst();
if (!row || !fromJson<string[]>(row.scopes, []).includes(scope)) return null;
await db.k.updateTable("app_tokens").set({ last_used_at: nowIso() }).where("id", "=", row.token_id).execute();
return toUser(row);
return { user: toUser(row), tokenName: row.token_name };
}
export async function revokeAppToken(db: Db, userId: string, id: string): Promise<void> {
await db.k
/** Revoke one of the user's live tokens. Returns false when no such live token exists (unknown, foreign or revoked). */
export async function revokeAppToken(db: Db, userId: string, id: string): Promise<boolean> {
const r = await db.k
.updateTable("app_tokens")
.set({ revoked_at: nowIso() })
.where("id", "=", id)
.where("user_id", "=", userId)
.where("revoked_at", "is", null)
.executeTakeFirst();
return Number(r.numUpdatedRows) === 1;
}
export interface AppTokenInfo {
id: string;
name: string;
scopes: TokenScope[];
lastUsedAt: string | null;
revokedAt: string | null;
createdAt: string;
}
/** The user's app tokens, newest first. Metadata only: the token and its hash are never returned. */
export async function listAppTokens(db: Db, userId: string): Promise<AppTokenInfo[]> {
const rows = await db.k
.selectFrom("app_tokens")
.select(["id", "name", "scopes", "last_used_at", "revoked_at", "created_at"])
.where("user_id", "=", userId)
.orderBy("created_at", "desc")
.orderBy("id", "desc")
.execute();
return rows.map((r) => ({
id: r.id,
name: r.name,
scopes: fromJson<TokenScope[]>(r.scopes, []),
lastUsedAt: r.last_used_at,
revokedAt: r.revoked_at,
createdAt: r.created_at,
}));
}
+130 -13
View File
@@ -1,14 +1,14 @@
import type { Db } from "@sttracker/db/db";
import { newId, nowIso } from "@sttracker/db/ids";
import { fromJson, toJson } from "@sttracker/db/json";
import type { ConnectionStatus } from "@sttracker/db/schema";
import type { ConnectionStatus, RunMode, RunStatus } from "@sttracker/db/schema";
import { createHttpClient, type HttpClientOptions } from "@sttracker/plugin-sdk/http";
import type { HttpClient, PluginContext, PluginManifest } from "@sttracker/plugin-sdk/types";
import { decryptJson, encryptJson } from "./crypto.ts";
import { CryptoError, NotFoundError, ValidationError } from "./errors.ts";
import { DropJobError, NonRetryableError, type JobContext } from "./jobs.ts";
import type { PluginRegistry } from "./registry.ts";
import { runSync, type Connection, type RunSummary } from "./sync/run.ts";
import { runSync, type Connection, type RunSummary, type SyncMode } from "./sync/run.ts";
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -77,6 +77,7 @@ export async function createConnection(db: Db, secret: string, userId: string, i
status: "active",
last_run_at: null,
created_at: nowIso(),
deleted_at: null,
})
.execute();
return id;
@@ -94,7 +95,7 @@ export interface ConnectionPatch {
export async function updateConnection(db: Db, secret: string, userId: string, id: string, patch: ConnectionPatch, manifest: PluginManifest): Promise<void> {
checkSchedule(patch.scheduleMinutes);
await db.k.transaction().execute(async (tx) => {
let q = tx.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId);
let q = tx.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId).where("deleted_at", "is", null);
if (db.dialect === "postgres") q = q.forUpdate();
const row = await q.executeTakeFirst();
if (!row) throw new NotFoundError("connection");
@@ -116,20 +117,27 @@ export async function updateConnection(db: Db, secret: string, userId: string, i
});
}
/** Soft delete: the connection is paused and its credentials are wiped. Records and runs stay. */
/** Soft delete: `deleted_at` is set, the connection is paused and its credentials are wiped. Records and runs stay. */
export async function deleteConnection(db: Db, userId: string, id: string): Promise<void> {
const r = await db.k
.updateTable("source_connections")
.set({ status: "paused", credentials_enc: null })
.set({ status: "paused", credentials_enc: null, deleted_at: nowIso() })
.where("id", "=", id)
.where("user_id", "=", userId)
.where("deleted_at", "is", null)
.executeTakeFirst();
if (r.numUpdatedRows !== 1n) throw new NotFoundError("connection");
}
/** Never returns secrets: plain config plus `hasCredentials`. */
/** The user's live (not deleted) connections. Never returns secrets: plain config plus `hasCredentials`. */
export async function listConnections(db: Db, userId: string): Promise<ConnectionView[]> {
const rows = await db.k.selectFrom("source_connections").selectAll().where("user_id", "=", userId).orderBy("created_at").execute();
const rows = await db.k
.selectFrom("source_connections")
.selectAll()
.where("user_id", "=", userId)
.where("deleted_at", "is", null)
.orderBy("created_at")
.execute();
return rows.map((r) => ({
id: r.id,
pluginId: r.plugin_id,
@@ -143,6 +151,105 @@ export async function listConnections(db: Db, userId: string): Promise<Connectio
}));
}
async function ownedRow(db: Db, userId: string, id: string) {
const r = await db.k
.selectFrom("source_connections")
.selectAll()
.where("id", "=", id)
.where("user_id", "=", userId)
.where("deleted_at", "is", null)
.executeTakeFirst();
if (!r) throw new NotFoundError("connection");
return r;
}
/** One of the user's live connections, without secrets. Deleted or foreign connections throw `NotFoundError`. */
export async function getConnection(db: Db, userId: string, id: string): Promise<ConnectionView> {
const r = await ownedRow(db, userId, id);
return {
id: r.id,
pluginId: r.plugin_id,
name: r.name,
config: fromJson<Record<string, unknown>>(r.config, {}),
scheduleMinutes: r.schedule_minutes,
status: r.status,
lastRunAt: r.last_run_at,
createdAt: r.created_at,
hasCredentials: r.credentials_enc !== null,
};
}
export interface RunView {
id: string;
mode: RunMode;
status: RunStatus;
startedAt: string;
finishedAt: string | null;
counts: Record<string, number>;
log: string;
error: string | null;
}
/** Runs of one of the user's connections, newest first (at most `limit`, default 50). */
export async function listRuns(db: Db, userId: string, connectionId: string, limit = 50): Promise<RunView[]> {
await ownedRow(db, userId, connectionId);
const rows = await db.k
.selectFrom("source_runs")
.selectAll()
.where("connection_id", "=", connectionId)
.orderBy("started_at", "desc")
.orderBy("id", "desc")
.limit(Math.min(Math.max(limit, 1), 200))
.execute();
return rows.map((r) => ({
id: r.id,
mode: r.mode,
status: r.status,
startedAt: r.started_at,
finishedAt: r.finished_at,
counts: fromJson<Record<string, number>>(r.counts, {}),
log: r.log,
error: r.error,
}));
}
async function deviceContext(db: Db, secret: string, registry: PluginRegistry, userId: string, id: string) {
const r = await ownedRow(db, userId, id);
const plugin = registry.get(r.plugin_id);
if (!plugin) throw new ValidationError("pluginId", "not_installed");
if (!plugin.deviceAuth) throw new ValidationError("pluginId", "no_device_auth");
const conn: Connection = {
id: r.id,
userId: r.user_id,
pluginId: r.plugin_id,
name: r.name,
config: fromJson<Record<string, unknown>>(r.config, {}),
credentialsEnc: r.credentials_enc,
scheduleMinutes: r.schedule_minutes,
cursor: r.cursor,
status: r.status,
lastRunAt: r.last_run_at,
createdAt: r.created_at,
};
const ctx = await makePluginContext(db, secret, conn, "device", plugin.manifest);
return { deviceAuth: plugin.deviceAuth, ctx };
}
/** Start a device-code sign-in for one of the user's connections. */
export async function deviceStart(db: Db, secret: string, registry: PluginRegistry, userId: string, id: string) {
const { deviceAuth, ctx } = await deviceContext(db, secret, registry, userId, id);
return deviceAuth.start(ctx);
}
/** Poll a device-code sign-in. On success the returned credentials are merged into the encrypted blob. */
export async function devicePoll(db: Db, secret: string, registry: PluginRegistry, userId: string, id: string, deviceCode: string): Promise<"pending" | "connected"> {
const { deviceAuth, ctx } = await deviceContext(db, secret, registry, userId, id);
const res = await deviceAuth.poll(ctx, deviceCode);
if (res === "pending") return "pending";
await ctx.credentials.save(res);
return "connected";
}
export const USER_AGENT = "STTracker/0.1 (+self-hosted)";
export interface ContextOpts {
@@ -215,7 +322,7 @@ export interface SyncHandlerDeps {
const MSG_CRYPTO = "Cannot decrypt this source's credentials. Was STTRACKER_SECRET changed? Reconnect the source to fix it.";
/** Record a run that failed before `runSync` could start, flag the connection, and stop the job without retry. */
async function failBeforeRun(db: Db, connectionId: string, mode: "full" | "changes", message: string): Promise<never> {
async function failBeforeRun(db: Db, connectionId: string, mode: RunMode, message: string): Promise<never> {
const now = nowIso();
await db.k
.insertInto("source_runs")
@@ -232,16 +339,26 @@ async function failBeforeRun(db: Db, connectionId: string, mode: "full" | "chang
* run, mark the connection `error` and fail the job without retry. A failed run is rethrown so the worker retries.
* `ctx.log` output is appended to the run's `source_runs.log`.
*/
export function syncHandler(deps: SyncHandlerDeps): (payload: { connectionId: string; mode?: "full" | "changes" }, jc?: JobContext) => Promise<RunSummary | void> {
export interface SyncPayload {
connectionId: string;
mode?: "full" | "changes";
/** File mode: run the plugin's `importFile` over these bytes instead of pulling. */
file?: { name: string; base64: string };
}
export function syncHandler(deps: SyncHandlerDeps): (payload: SyncPayload, jc?: JobContext) => Promise<RunSummary | void> {
return async (payload, jc) => {
const { db } = deps;
const full = await db.k.selectFrom("source_connections").selectAll().where("id", "=", payload.connectionId).executeTakeFirst();
if (!full) throw new DropJobError("connection not found");
if (full.deleted_at !== null) throw new DropJobError("connection is deleted");
if (full.status === "paused") throw new DropJobError("connection is paused");
const plugin = deps.registry.get(full.plugin_id);
if (!plugin) return failBeforeRun(db, full.id, payload.mode ?? "full", `Plugin '${full.plugin_id}' is not installed or failed to load.`);
let mode: "full" | "changes" = "full";
if (plugin.pullChanges && payload.mode !== "full") {
if (!plugin) return failBeforeRun(db, full.id, payload.file ? "file" : (payload.mode ?? "full"), `Plugin '${full.plugin_id}' is not installed or failed to load.`);
let mode: SyncMode = "full";
if (payload.file) {
mode = { file: { name: payload.file.name, bytes: new Uint8Array(Buffer.from(payload.file.base64, "base64")) } };
} else if (plugin.pullChanges && payload.mode !== "full") {
const cutoff = new Date(Date.now() - WEEK_MS).toISOString();
const recentFull = await db.k
.selectFrom("source_runs")
@@ -270,7 +387,7 @@ export function syncHandler(deps: SyncHandlerDeps): (payload: { connectionId: st
try {
creds = await readCreds(deps.secret, conn.credentialsEnc);
} catch (e) {
if (e instanceof CryptoError) return failBeforeRun(db, full.id, mode, MSG_CRYPTO);
if (e instanceof CryptoError) return failBeforeRun(db, full.id, typeof mode === "string" ? mode : "file", MSG_CRYPTO);
throw e;
}
const logs: string[] = [];
+3 -3
View File
@@ -15,7 +15,7 @@ import {
toStoredDate,
} from "./entries.ts";
import { NotFoundError, ValidationError } from "./errors.ts";
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
import { type Actor, type Executor, fieldOwner, inTx, recordHistory } from "./history.ts";
/** One time through a work (spec §6.3). Dates are partial; `null` means unknown. */
export interface EngagementPatch {
@@ -144,7 +144,7 @@ async function write(
): Promise<Engagement> {
const cols = await toColumns(k, userId, mediaType, patch);
const fieldSources = { ...current.fieldSources };
for (const key of Object.keys(patch)) fieldSources[key] = actor;
for (const key of Object.keys(patch)) fieldSources[key] = fieldOwner(actor) as Actor;
await k
.updateTable("engagements")
.set({ ...cols, ...extra, field_sources: toJson(fieldSources), updated_at: nowIso() })
@@ -175,7 +175,7 @@ export async function addEngagement(
const { mediaType } = await requireEntry(k, userId, entryId);
const cols = await toColumns(k, userId, mediaType, patch);
const fieldSources: FieldSources = {};
for (const key of Object.keys(patch)) fieldSources[key] = actor;
for (const key of Object.keys(patch)) fieldSources[key] = fieldOwner(actor) as Actor;
const now = nowIso();
const row: Row = {
id: newId(),
+36 -2
View File
@@ -7,7 +7,7 @@ import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date";
import type { MediaType, PartialDate } from "@sttracker/plugin-sdk/types";
import { customFieldsFor, validateCustomValues } from "./custom-fields.ts";
import { NotFoundError, ValidationError } from "./errors.ts";
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
import { type Actor, type Executor, fieldOwner, inTx, recordHistory } from "./history.ts";
// ---------------------------------------------------------------------------------------------
// Shared user-layer helpers (also used by engagements, lists and copies).
@@ -320,7 +320,7 @@ async function writeEntry(
): Promise<Entry> {
const fieldSources: Record<string, Actor | undefined> = { ...current.fieldSources };
for (const key of Object.keys(patch)) {
fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : actor;
fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : (fieldOwner(actor) as Actor);
if (fieldSources[key] === undefined) delete fieldSources[key];
}
const cols = { ...patchToColumns(patch), ...opts.extra, field_sources: toJson(fieldSources), updated_at: nowIso() };
@@ -975,3 +975,37 @@ export async function rejectSuggestion(db: Db, userId: string, suggestionId: str
if (s.status !== "pending") throw new ValidationError("status", "not_pending");
await db.k.updateTable("match_suggestions").set({ status: "rejected" }).where("id", "=", s.id).execute();
}
export interface MatchSuggestion {
id: string;
workId: string;
candidateWorkId: string;
score: number;
status: "pending" | "accepted" | "rejected";
createdAt: string;
}
/**
* The user's pending match suggestions for a work (after following aliases and merges to the canonical work), best first.
* Throws `NotFoundError` if the work is not visible.
*/
export async function listSuggestions(db: Db, userId: string, workId: string): Promise<MatchSuggestion[]> {
const work = await resolveVisibleWork(db.k, userId, workId);
const rows = await db.k
.selectFrom("match_suggestions")
.selectAll()
.where("user_id", "=", userId)
.where("work_id", "=", work.id)
.where("status", "=", "pending")
.orderBy("score", "desc")
.orderBy("id")
.execute();
return rows.map((r) => ({
id: r.id,
workId: r.work_id,
candidateWorkId: r.candidate_work_id,
score: r.score,
status: r.status,
createdAt: r.created_at,
}));
}
+9
View File
@@ -12,6 +12,15 @@ import { restoreListFromHistory } from "./lists.ts";
/** Who made a change. Plugin actors are `plugin:<pluginId>:<connectionId>`; API tokens are `token:<name>`. */
export type Actor = "user" | `plugin:${string}:${string}` | `token:${string}`;
/**
* Who owns a field in `field_sources` after `actor` writes it. Only plugin actors keep their name; the user,
* through the UI or an API token, is always `"user"`, so syncs never overwrite a field a script edited.
* History rows keep the full actor.
*/
export function fieldOwner(actor: string): string {
return actor.startsWith("plugin:") ? actor : "user";
}
/** User-layer entities that write `user_history`. */
export type HistoryEntity = "entry" | "engagement" | "copy" | "list";
+1
View File
@@ -198,6 +198,7 @@ export function startScheduler(db: Db, opts: { tickMs?: number } = {}): { stop()
.selectFrom("source_connections")
.select(["id", "schedule_minutes", "last_run_at"])
.where("status", "!=", "paused")
.where("deleted_at", "is", null)
.where("schedule_minutes", "is not", null)
.execute();
const now = Date.now();
+3 -3
View File
@@ -6,7 +6,7 @@ import type { ChangeStatus, ChangeType, Database } from "@sttracker/db/schema";
import type { EngagementFields, EntryFields, PartialDate, SourceRecord, WorkHint } from "@sttracker/plugin-sdk/types";
import { addEngagement, type EngagementPatch, updateEngagement } from "../engagements.ts";
import { type EntryPatch, fromStoredDate, resolveVisibleWork, upsertEntry } from "../entries.ts";
import type { Actor, Executor } from "../history.ts";
import { type Actor, type Executor, fieldOwner } from "../history.ts";
import { createList, replaceListItems, updateList } from "../lists.ts";
import { matchWork } from "../matching.ts";
import { resolveAliasIn, updateWorkFromHintIn } from "../works.ts";
@@ -146,8 +146,8 @@ export async function listFieldSources(k: Executor, listId: string, sourceActor:
const fs: Record<string, string> = { items: sourceActor };
for (const r of rows) {
const after = fromJson<Fields>(r.after, {});
for (const f of ["name", "description"]) if (f in after) fs[f] = r.actor;
if (r.actor === "user") fs.items = "user";
for (const f of ["name", "description"]) if (f in after) fs[f] = fieldOwner(r.actor);
if (fieldOwner(r.actor) === "user") fs.items = "user";
}
return fs;
}
+249
View File
@@ -0,0 +1,249 @@
import { expect, spyOn, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { newId, nowIso } from "@sttracker/db/ids";
import { forEachDialect } from "@sttracker/db/testing";
import { definePlugin, type SourcePlugin } from "@sttracker/plugin-sdk/types";
import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment } from "../src/attachments.ts";
import { authenticateAppToken, createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts";
import { getHistory } from "../src/history.ts";
import { fieldOwner } from "../src/history.ts";
import { getEntry, upsertEntry } from "../src/entries.ts";
import { listEngagements } from "../src/engagements.ts";
import { createConnection, deleteConnection, devicePoll, deviceStart, getConnection, listConnections, listRuns, syncHandler, updateConnection } from "../src/connections.ts";
import { startScheduler } from "../src/jobs.ts";
import { DropJobError } from "../src/jobs.ts";
import { listSuggestions, quickAdd } from "../src/entries.ts";
import { addEngagement } from "../src/engagements.ts";
import { NotFoundError, ValidationError } from "../src/errors.ts";
import type { PluginRegistry } from "../src/registry.ts";
import { loadPlugins } from "../src/registry.ts";
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
const SECRET = "test-secret";
const warn = spyOn(console, "warn").mockImplementation(() => {});
const reg = await loadPlugins([join(import.meta.dir, "fixtures/plugins")]);
warn.mockRestore();
const echo = reg.get("echo")!;
const fileish: SourcePlugin = definePlugin({
manifest: { id: "fileish", name: "Fileish", version: "1", sdkVersion: 1, mediaTypes: ["book"], capabilities: ["importFile"], config: [] },
async *importFile(_ctx, file) {
yield {
kind: "entry",
key: file.name,
raw: new TextDecoder().decode(file.bytes),
work: { title: new TextDecoder().decode(file.bytes), mediaType: "book", externalIds: { isbn13: "9780000000009" } },
entry: { status: "planned" },
};
},
deviceAuth: {
start: async () => ({ userCode: "ABCD", verificationUrl: "https://x.test/device", deviceCode: "dc", intervalSec: 5, expiresInSec: 600 }),
poll: async (_ctx, deviceCode) => (deviceCode === "dc" ? { access_token: "tok" } : "pending"),
},
});
let flipStatus: "planned" | "completed" = "planned";
const flip: SourcePlugin = definePlugin({
manifest: { id: "flip", name: "Flip", version: "1", sdkVersion: 1, mediaTypes: ["book"], capabilities: ["pull"], config: [] },
async *pull() {
yield {
kind: "entry",
key: "flip-1",
raw: flipStatus,
work: { title: "Flip", mediaType: "book", externalIds: { isbn13: "9780000000010" } },
entry: { status: flipStatus, rating: flipStatus === "planned" ? 40 : 90 },
};
},
});
const extra: Record<string, SourcePlugin> = { fileish, flip };
const reg2: PluginRegistry = { get: (id) => extra[id] ?? reg.get(id), list: () => [...reg.list(), fileish.manifest, flip.manifest], reload: async () => {} };
forEachDialect("api support", (getDb) => {
test("listAppTokens returns metadata only, revoked flagged, scoped to user", async () => {
const db = getDb();
const u = await makeUser(db);
const other = await makeUser(db, "bob");
const t = await createAppToken(db, u, "script", ["read"]);
await createAppToken(db, other, "x", ["write"]);
expect(await revokeAppToken(db, other, t.id)).toBe(false);
expect(await revokeAppToken(db, u, t.id)).toBe(true);
expect(await revokeAppToken(db, u, t.id)).toBe(false);
const list = await listAppTokens(db, u);
expect(list).toHaveLength(1);
expect(list[0]).toMatchObject({ id: t.id, name: "script", scopes: ["read"], lastUsedAt: null });
expect(list[0]!.revokedAt).not.toBeNull();
expect(JSON.stringify(list)).not.toContain("hash");
});
test("fieldOwner: only plugin actors keep their name in field_sources", () => {
expect(fieldOwner("user")).toBe("user");
expect(fieldOwner("token:script")).toBe("user");
expect(fieldOwner("plugin:echo:c1")).toBe("plugin:echo:c1");
});
test("authenticateAppToken returns the user and the token name", async () => {
const db = getDb();
const u = await makeUser(db);
const t = await createAppToken(db, u, "script", ["write"]);
expect(await authenticateAppToken(db, t.token, "write")).toMatchObject({ user: { id: u }, tokenName: "script" });
expect(await authenticateAppToken(db, t.token, "read")).toBeNull();
});
test("a token write keeps its actor in history, is owned by the user, and a later sync files a pending change", async () => {
const db = getDb();
const u = await makeUser(db);
flipStatus = "planned";
const id = await createConnection(db, SECRET, u, { pluginId: "flip", name: "F", config: {} }, flip.manifest);
const sync = syncHandler({ db, secret: SECRET, registry: reg2 });
await sync({ connectionId: id, mode: "full" });
const entry = await db.k.selectFrom("entries").selectAll().where("user_id", "=", u).executeTakeFirstOrThrow();
await upsertEntry(db, u, entry.work_id, { status: "dropped" }, "token:script");
const view = await getEntry(db, u, entry.id);
expect(view.fieldSources.status).toBe("user");
expect(view.fieldSources.rating).toBe(`plugin:flip:${id}`);
const hist = await getHistory(db, u, "entry", entry.id);
expect(hist[0]!.actor).toBe("token:script");
flipStatus = "completed";
await sync({ connectionId: id, mode: "full" });
const after = await getEntry(db, u, entry.id);
expect(after.status).toBe("dropped");
expect(after.rating).toBe(90);
const pending = await db.k.selectFrom("change_events").selectAll().where("user_id", "=", u).where("status", "=", "pending").execute();
expect(pending.map((p) => [p.type, JSON.parse(p.payload).field])).toEqual([["changed", "status"]]);
});
test("token-authored engagement fields are owned by the user", async () => {
const db = getDb();
const u = await makeUser(db);
const e = await quickAdd(db, u, { title: "Dune" });
await addEngagement(db, u, e.id, { platform: "kindle" }, "token:script");
const [g] = await listEngagements(db, u, e.id);
expect(g!.fieldSources).toEqual({ platform: "user" });
});
test("listSuggestions lists pending suggestions for a visible work", async () => {
const db = getDb();
const u = await makeUser(db);
const draft = await quickAdd(db, u, { title: "Dune??", mediaType: "book" });
const good = await makeWork(db, { title: "Dune", externalIds: { isbn13: "1" } });
await db.k
.insertInto("match_suggestions")
.values({ id: newId(), user_id: u, work_id: draft.workId, candidate_work_id: good, score: 0.75, status: "pending", created_at: nowIso() })
.execute();
const s = await listSuggestions(db, u, draft.workId);
expect(s).toHaveLength(1);
expect(s[0]).toMatchObject({ workId: draft.workId, candidateWorkId: good, score: 0.75, status: "pending" });
const other = await makeUser(db, "bob");
await expect(listSuggestions(db, other, draft.workId)).rejects.toBeInstanceOf(NotFoundError);
// Asking by an id the user aliased away lists the canonical work's suggestions.
const old = await makeWork(db, { title: "Old Dune", ownerUserId: u });
await db.k.insertInto("work_aliases").values({ id: newId(), user_id: u, from_work_id: old, to_work_id: draft.workId }).execute();
expect((await listSuggestions(db, u, old)).map((x) => x.workId)).toEqual([draft.workId]);
});
test("requireLinkTarget checks ownership per entity; links and filename are readable", async () => {
const db = getDb();
const dir = await mkdtemp(join(tmpdir(), "stt-att-"));
try {
const u = await makeUser(db);
const bob = await makeUser(db, "bob");
const e = await quickAdd(db, u, { title: "Dune" });
const g = await addEngagement(db, u, e.id, {}, "user");
await requireLinkTarget(db, u, "entry", e.id);
await requireLinkTarget(db, u, "engagement", g.id);
await requireLinkTarget(db, u, "work", e.workId);
await expect(requireLinkTarget(db, bob, "entry", e.id)).rejects.toBeInstanceOf(NotFoundError);
await expect(requireLinkTarget(db, bob, "engagement", g.id)).rejects.toBeInstanceOf(NotFoundError);
await expect(requireLinkTarget(db, bob, "work", e.workId)).rejects.toBeInstanceOf(NotFoundError);
await expect(requireLinkTarget(db, u, "nope" as never, e.id)).rejects.toBeInstanceOf(ValidationError);
const a = await storeAttachment(db, dir, u, { bytes: new Uint8Array([1]), mime: "image/png", filename: "a.png" }, { entity: "entry", entityId: e.id });
const links = await listAttachmentLinks(db, u, a.id);
expect(links).toEqual([{ id: expect.any(String), entity: "entry", entityId: e.id }]);
expect(await listAttachmentLinks(db, bob, a.id)).toEqual([]);
expect((await readAttachment(db, dir, u, a.id))?.filename).toBe("a.png");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("getConnection hides other users and deleted connections; listRuns is newest first", async () => {
const db = getDb();
const u = await makeUser(db);
const bob = await makeUser(db, "bob");
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest);
expect((await getConnection(db, u, id)).pluginId).toBe("echo");
await expect(getConnection(db, bob, id)).rejects.toBeInstanceOf(NotFoundError);
const sync = syncHandler({ db, secret: SECRET, registry: reg });
await sync({ connectionId: id, mode: "full" });
await sync({ connectionId: id, mode: "full" });
const runs = await listRuns(db, u, id);
expect(runs).toHaveLength(2);
expect(runs[0]!.startedAt >= runs[1]!.startedAt).toBe(true);
expect(runs[0]).toMatchObject({ mode: "full", status: "ok" });
await expect(listRuns(db, bob, id)).rejects.toBeInstanceOf(NotFoundError);
await deleteConnection(db, u, id);
await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError);
});
test("a paused connection without credentials stays listed and can be unpaused; a deleted one is gone", async () => {
const db = getDb();
const u = await makeUser(db);
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest);
await updateConnection(db, SECRET, u, id, { status: "paused" }, echo.manifest);
expect((await listConnections(db, u)).map((c) => c.id)).toEqual([id]);
expect((await getConnection(db, u, id)).status).toBe("paused");
await updateConnection(db, SECRET, u, id, { status: "active" }, echo.manifest);
expect((await getConnection(db, u, id)).status).toBe("active");
await deleteConnection(db, u, id);
const row = await db.k.selectFrom("source_connections").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
expect(row).toMatchObject({ status: "paused", credentials_enc: null });
expect(row.deleted_at).not.toBeNull();
expect(await listConnections(db, u)).toEqual([]);
await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError);
await expect(updateConnection(db, SECRET, u, id, { status: "active" }, echo.manifest)).rejects.toBeInstanceOf(NotFoundError);
await expect(deleteConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError);
});
test("deleted connections are never scheduled and their queued syncs are dropped", async () => {
const db = getDb();
const u = await makeUser(db);
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {}, scheduleMinutes: 1 }, echo.manifest);
// Simulate a delete that left status "error" (e.g. a row edited by hand): deleted_at alone must exclude it.
await deleteConnection(db, u, id);
await db.k.updateTable("source_connections").set({ status: "error" }).where("id", "=", id).execute();
const sched = startScheduler(db, { tickMs: 3_600_000 });
await sched.tickNow();
sched.stop();
expect(await db.k.selectFrom("jobs").select("id").execute()).toEqual([]);
const sync = syncHandler({ db, secret: SECRET, registry: reg });
await expect(sync({ connectionId: id })).rejects.toBeInstanceOf(DropJobError);
});
test("syncHandler runs file mode from a base64 payload", async () => {
const db = getDb();
const u = await makeUser(db);
const id = await createConnection(db, SECRET, u, { pluginId: "fileish", name: "F", config: {} }, fileish.manifest);
const sync = syncHandler({ db, secret: SECRET, registry: reg2 });
const s = await sync({ connectionId: id, file: { name: "x.txt", base64: Buffer.from("Dune").toString("base64") } });
expect(s).toMatchObject({ status: "ok" });
expect((await listRuns(db, u, id))[0]!.mode).toBe("file");
const entries = await db.k.selectFrom("entries").select("id").where("user_id", "=", u).execute();
expect(entries).toHaveLength(1);
});
test("deviceStart and devicePoll store credentials on success", async () => {
const db = getDb();
const u = await makeUser(db);
const id = await createConnection(db, SECRET, u, { pluginId: "fileish", name: "F", config: {} }, fileish.manifest);
expect(await deviceStart(db, SECRET, reg2, u, id)).toMatchObject({ userCode: "ABCD", deviceCode: "dc" });
expect(await devicePoll(db, SECRET, reg2, u, id, "other")).toBe("pending");
expect(await devicePoll(db, SECRET, reg2, u, id, "dc")).toBe("connected");
expect(await getConnection(db, u, id)).toMatchObject({ hasCredentials: true });
const e = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest);
await expect(deviceStart(db, SECRET, reg2, u, e)).rejects.toBeInstanceOf(ValidationError);
});
});
+2 -1
View File
@@ -55,7 +55,8 @@ forEachDialect("connections", (getDb) => {
r = await raw(id);
expect(r.status).toBe("paused");
expect(r.credentials_enc).toBeNull();
expect((await listConnections(db, u))[0]!.hasCredentials).toBe(false);
expect(r.deleted_at).not.toBeNull();
expect(await listConnections(db, u)).toEqual([]);
});
test("cursor persists on set; http uses manifest rate limit", async () => {
@@ -0,0 +1,13 @@
import type { Kysely } from "kysely";
import type { Migration } from "kysely/migration";
/** Soft delete for source connections: a deleted connection is distinct from one the user merely paused. */
export const sourceConnectionsDeletedAt: Migration = {
async up(db: Kysely<any>) {
await db.schema.alterTable("source_connections").addColumn("deleted_at", "text").execute();
},
async down(db: Kysely<any>) {
await db.schema.alterTable("source_connections").dropColumn("deleted_at").execute();
},
};
+2
View File
@@ -1,9 +1,11 @@
import type { Migration } from "kysely/migration";
import { initial } from "./0001_initial.ts";
import { worksPositionUnique } from "./0002_works_position_unique.ts";
import { sourceConnectionsDeletedAt } from "./0003_source_connections_deleted_at.ts";
/** All migrations, keyed by sortable name. Imported statically so bundles need no filesystem. */
export const migrations: Record<string, Migration> = {
"0001_initial": initial,
"0002_works_position_unique": worksPositionUnique,
"0003_source_connections_deleted_at": sourceConnectionsDeletedAt,
};
+2
View File
@@ -269,6 +269,8 @@ export interface SourceConnectionsTable {
status: ConnectionStatus;
last_run_at: string | null;
created_at: string;
/** Set by `deleteConnection`; deleted connections are hidden, never scheduled and never synced. */
deleted_at: string | null;
}
export interface SourceRunsTable {