merge: task 16 server API
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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.");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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 !== ""),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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),
|
||||
}),
|
||||
];
|
||||
@@ -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();
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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();
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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),
|
||||
}),
|
||||
];
|
||||
@@ -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();
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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);
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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()));
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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();
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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),
|
||||
}),
|
||||
];
|
||||
@@ -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();
|
||||
},
|
||||
}),
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><title>STTracker</title><div id="root">STTracker is starting…</div>
|
||||
+13
-2
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user