fix(server): token actor, login CSRF, 415, cookie decoding, admin invites, production default
- API writes pass token:<name> for Bearer requests and "user" for sessions. - X-Requested-With: sttracker on every non-Bearer write, login and register included. - JSON-body routes reject non-JSON content types with 415. - A malformed stt_session cookie counts as no cookie (401), not 500. - DELETE me/tokens/:id is 404 for unknown or foreign ids; admin/invites is admin-only. - Bun.serve development mode only with NODE_ENV=development or --hot. - OpenAPI documents multipart fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa8e4db070
commit
5f7d54b805
10 files changed
+174
-46
No files matched your search
+32
-12
@@ -1,5 +1,6 @@
|
|||||||
import { getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth";
|
import { authenticateAppToken, getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth";
|
||||||
import { AuthError, ImportError, NotFoundError, ValidationError } from "@sttracker/core/errors";
|
import { AuthError, ImportError, NotFoundError, ValidationError } from "@sttracker/core/errors";
|
||||||
|
import type { Actor } from "@sttracker/core/history";
|
||||||
import { isUniqueViolation } from "@sttracker/core/matching";
|
import { isUniqueViolation } from "@sttracker/core/matching";
|
||||||
import type { PluginRegistry } from "@sttracker/core/registry";
|
import type { PluginRegistry } from "@sttracker/core/registry";
|
||||||
import type { Db } from "@sttracker/db/db";
|
import type { Db } from "@sttracker/db/db";
|
||||||
@@ -33,6 +34,8 @@ export interface Ctx<B, Q> {
|
|||||||
query: Q;
|
query: Q;
|
||||||
/** The authenticated user. Throws on `auth: "none"` routes, which have none. */
|
/** The authenticated user. Throws on `auth: "none"` routes, which have none. */
|
||||||
user: User;
|
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. */
|
/** The session token, when the request was authenticated by cookie. */
|
||||||
sessionToken: string | null;
|
sessionToken: string | null;
|
||||||
/** Parsed multipart body, for `multipart: true` routes. */
|
/** Parsed multipart body, for `multipart: true` routes. */
|
||||||
@@ -51,6 +54,8 @@ export interface RouteDef<B = any, Q = any> {
|
|||||||
query?: z.ZodType<Q>;
|
query?: z.ZodType<Q>;
|
||||||
/** Parse the body as multipart form data (upload limit) instead of JSON. */
|
/** Parse the body as multipart form data (upload limit) instead of JSON. */
|
||||||
multipart?: boolean;
|
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). */
|
/** Rate limit per client IP (login, register). */
|
||||||
rateLimited?: boolean;
|
rateLimited?: boolean;
|
||||||
/** Only admins may call it (403 otherwise). */
|
/** Only admins may call it (403 otherwise). */
|
||||||
@@ -132,7 +137,13 @@ function readCookie(req: Request, name: string): string | null {
|
|||||||
if (!header) return null;
|
if (!header) return null;
|
||||||
for (const part of header.split(";")) {
|
for (const part of header.split(";")) {
|
||||||
const i = part.indexOf("=");
|
const i = part.indexOf("=");
|
||||||
if (i > 0 && part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim()) || null;
|
if (i > 0 && part.slice(0, i).trim() === name) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(part.slice(i + 1).trim()) || null;
|
||||||
|
} catch {
|
||||||
|
return null; // malformed percent-encoding: treat as no cookie
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -185,16 +196,20 @@ export function rateLimiter(limit: number, windowMs: number): (key: string) => b
|
|||||||
const SCOPES: TokenScope[] = ["read", "write", "ingest"];
|
const SCOPES: TokenScope[] = ["read", "write", "ingest"];
|
||||||
|
|
||||||
/** Resolve the caller for `mode`. Returns the user and the session token used, if any. */
|
/** 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 }> {
|
async function authenticate(
|
||||||
if (mode === "none") return { user: null, sessionToken: null };
|
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 authz = req.headers.get("authorization");
|
||||||
const bearer = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : null;
|
const bearer = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : null;
|
||||||
const [kind, scopeName] = mode.split(":") as ["session" | "token" | "any", TokenScope | undefined];
|
const [kind, scopeName] = mode.split(":") as ["session" | "token" | "any", TokenScope | undefined];
|
||||||
|
|
||||||
if (kind !== "session" && bearer) {
|
if (kind !== "session" && bearer) {
|
||||||
const scope = scopeName!;
|
const scope = scopeName!;
|
||||||
const user = await verifyAppToken(db, bearer, scope);
|
const auth = await authenticateAppToken(db, bearer, scope);
|
||||||
if (user) return { user, sessionToken: null };
|
if (auth) return { user: auth.user, sessionToken: null, actor: `token:${auth.tokenName}` };
|
||||||
for (const other of SCOPES) {
|
for (const other of SCOPES) {
|
||||||
if (other !== scope && (await verifyAppToken(db, bearer, other))) {
|
if (other !== scope && (await verifyAppToken(db, bearer, other))) {
|
||||||
throw new HttpError(403, "forbidden", `This app token lacks the '${scope}' scope.`);
|
throw new HttpError(403, "forbidden", `This app token lacks the '${scope}' scope.`);
|
||||||
@@ -207,11 +222,7 @@ async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ use
|
|||||||
const token = readCookie(req, SESSION_COOKIE);
|
const token = readCookie(req, SESSION_COOKIE);
|
||||||
const user = token ? await getSessionUser(db, token) : null;
|
const user = token ? await getSessionUser(db, token) : null;
|
||||||
if (!user) throw new HttpError(401, "unauthenticated", "Sign in first.");
|
if (!user) throw new HttpError(401, "unauthenticated", "Sign in first.");
|
||||||
// CSRF: browsers cannot set this header cross-site without a CORS preflight, which we never grant.
|
return { user, sessionToken: token, actor: "user" };
|
||||||
if (req.method !== "GET" && req.method !== "HEAD" && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
|
|
||||||
throw new HttpError(403, "forbidden", `Cookie-authenticated requests must send the header X-Requested-With: ${CSRF_VALUE}.`);
|
|
||||||
}
|
|
||||||
return { user, sessionToken: token };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Compiled {
|
interface Compiled {
|
||||||
@@ -261,7 +272,13 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/
|
|||||||
if (def.rateLimited && !limit(`${def.path}|${ip}`)) {
|
if (def.rateLimited && !limit(`${def.path}|${ip}`)) {
|
||||||
return errorResponse(429, "rate_limited", "Too many attempts. Wait a minute and try again.", { "retry-after": "60" });
|
return errorResponse(429, "rate_limited", "Too many attempts. Wait a minute and try again.", { "retry-after": "60" });
|
||||||
}
|
}
|
||||||
const { user, sessionToken } = await authenticate(req, def.auth, deps.db);
|
// CSRF: every non-Bearer write (login and register included) must carry this header. Browsers cannot add it
|
||||||
|
// cross-site without a CORS preflight, which the server never grants. Bearer requests are not ambient, so exempt.
|
||||||
|
const bearer = req.headers.get("authorization")?.startsWith("Bearer ") ?? false;
|
||||||
|
if (req.method !== "GET" && req.method !== "HEAD" && !bearer && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
|
||||||
|
throw new HttpError(403, "forbidden", `Browser requests that change data must send the header X-Requested-With: ${CSRF_VALUE}.`);
|
||||||
|
}
|
||||||
|
const { user, sessionToken, actor } = await authenticate(req, def.auth, deps.db);
|
||||||
if (def.admin && user?.role !== "admin") throw new HttpError(403, "forbidden", "Only an admin can do that.");
|
if (def.admin && user?.role !== "admin") throw new HttpError(403, "forbidden", "Only an admin can do that.");
|
||||||
|
|
||||||
let body: unknown;
|
let body: unknown;
|
||||||
@@ -279,6 +296,8 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/
|
|||||||
const bytes = await readLimited(req, MAX_JSON_BYTES);
|
const bytes = await readLimited(req, MAX_JSON_BYTES);
|
||||||
let raw: unknown;
|
let raw: unknown;
|
||||||
if (bytes.byteLength > 0) {
|
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 {
|
try {
|
||||||
raw = JSON.parse(new TextDecoder().decode(bytes));
|
raw = JSON.parse(new TextDecoder().decode(bytes));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -299,6 +318,7 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/
|
|||||||
if (!user) throw new Error(`route ${def.method} ${def.path} has no authenticated user`);
|
if (!user) throw new Error(`route ${def.method} ${def.path} has no authenticated user`);
|
||||||
return user;
|
return user;
|
||||||
},
|
},
|
||||||
|
actor,
|
||||||
sessionToken,
|
sessionToken,
|
||||||
get form(): FormData {
|
get form(): FormData {
|
||||||
if (!form) throw new Error(`route ${def.method} ${def.path} is not multipart`);
|
if (!form) throw new Error(`route ${def.method} ${def.path} is not multipart`);
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ const backupTimer = setInterval(() => void ensureBackup(), BACKUP_CHECK_MS);
|
|||||||
const app = createApp({ db, registry, secret: config.secret, dataDir: config.dataDir, publicUrl: config.publicUrl });
|
const app = createApp({ db, registry, secret: config.secret, dataDir: config.dataDir, publicUrl: config.publicUrl });
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port: config.port,
|
port: config.port,
|
||||||
development: process.env.NODE_ENV !== "production",
|
// Production unless asked: dev mode adds HMR and verbose error pages.
|
||||||
|
// `bun --hot` puts the flag in execArgv, not Bun.argv.
|
||||||
|
development: process.env.NODE_ENV === "development" || process.execArgv.includes("--hot"),
|
||||||
routes: {
|
routes: {
|
||||||
"/api/*": (req, srv) => app.fetch(req, srv),
|
"/api/*": (req, srv) => app.fetch(req, srv),
|
||||||
"/ingest/*": (req, srv) => app.fetch(req, srv),
|
"/ingest/*": (req, srv) => app.fetch(req, srv),
|
||||||
|
|||||||
@@ -46,7 +46,15 @@ export function buildOpenApi(routes: RouteDef[], prefix = "/api/v1"): JsonSchema
|
|||||||
};
|
};
|
||||||
if (parameters.length > 0) op.parameters = parameters;
|
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.body) op.requestBody = { required: !r.body.safeParse(undefined).success, content: { "application/json": { schema: toSchema(r.body) } } };
|
||||||
if (r.multipart) op.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object" } } } };
|
if (r.multipart) {
|
||||||
|
const fields = r.formFields ? (toSchema(r.formFields) as { properties?: JsonSchema; required?: string[] }) : {};
|
||||||
|
const schema = {
|
||||||
|
type: "object",
|
||||||
|
properties: { file: { type: "string", format: "binary" }, ...fields.properties },
|
||||||
|
required: ["file", ...(fields.required ?? [])],
|
||||||
|
};
|
||||||
|
op.requestBody = { required: true, content: { "multipart/form-data": { schema } } };
|
||||||
|
}
|
||||||
(paths[path] ??= {})[r.method.toLowerCase()] = op;
|
(paths[path] ??= {})[r.method.toLowerCase()] = op;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const attachmentRoutes = [
|
|||||||
summary: "Upload a file (multipart: file, entity, entityId, caption) and link it to one of your records.",
|
summary: "Upload a file (multipart: file, entity, entityId, caption) and link it to one of your records.",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
multipart: true,
|
multipart: true,
|
||||||
|
formFields: fields,
|
||||||
handler: async ({ deps, user, form }) => {
|
handler: async ({ deps, user, form }) => {
|
||||||
const file = formFile(form);
|
const file = formFile(form);
|
||||||
const f = fields.parse({
|
const f = fields.parse({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createAppToken, createInvite, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth";
|
import { createAppToken, createInvite, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth";
|
||||||
|
import { NotFoundError } from "@sttracker/core/errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts";
|
import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts";
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ export const authRoutes = [
|
|||||||
summary: "Revoke an app token.",
|
summary: "Revoke an app token.",
|
||||||
auth: "session",
|
auth: "session",
|
||||||
handler: async ({ deps, user, params }) => {
|
handler: async ({ deps, user, params }) => {
|
||||||
await revokeAppToken(deps.db, user.id, params.id!);
|
if (!(await revokeAppToken(deps.db, user.id, params.id!))) throw new NotFoundError("app token");
|
||||||
return noContent();
|
return noContent();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -70,6 +71,7 @@ export const authRoutes = [
|
|||||||
path: "admin/invites",
|
path: "admin/invites",
|
||||||
summary: "Create an invite code (admin). The code is returned once.",
|
summary: "Create an invite code (admin). The code is returned once.",
|
||||||
auth: "session",
|
auth: "session",
|
||||||
|
admin: true,
|
||||||
body: z.object({ ttlDays: z.number().int().min(1).max(365).optional() }).strict().optional(),
|
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),
|
handler: async ({ deps, user, body }) => json({ code: await createInvite(deps.db, user.id, body?.ttlDays) }, 201),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const copyRoutes = [
|
|||||||
summary: "Record an owned copy.",
|
summary: "Record an owned copy.",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
body: copyPatch,
|
body: copyPatch,
|
||||||
handler: async ({ deps, user, body }) => json(await createCopy(deps.db, user.id, body as CopyArg), 201),
|
handler: async ({ deps, user, body, actor }) => json(await createCopy(deps.db, user.id, body as CopyArg, actor), 201),
|
||||||
}),
|
}),
|
||||||
route({
|
route({
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -43,15 +43,15 @@ export const copyRoutes = [
|
|||||||
summary: "Edit an owned copy.",
|
summary: "Edit an owned copy.",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
body: copyPatch,
|
body: copyPatch,
|
||||||
handler: async ({ deps, user, params, body }) => updateCopy(deps.db, user.id, params.id!, body as CopyArg),
|
handler: async ({ deps, user, params, body, actor }) => updateCopy(deps.db, user.id, params.id!, body as CopyArg, actor),
|
||||||
}),
|
}),
|
||||||
route({
|
route({
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
path: "copies/:id",
|
path: "copies/:id",
|
||||||
summary: "Soft-delete an owned copy.",
|
summary: "Soft-delete an owned copy.",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
handler: async ({ deps, user, params }) => {
|
handler: async ({ deps, user, params, actor }) => {
|
||||||
await softDeleteCopy(deps.db, user.id, params.id!);
|
await softDeleteCopy(deps.db, user.id, params.id!, actor);
|
||||||
return noContent();
|
return noContent();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export const engagementRoutes = [
|
|||||||
summary: "Log a read, watch or play.",
|
summary: "Log a read, watch or play.",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
body: engagementPatch,
|
body: engagementPatch,
|
||||||
handler: async ({ deps, user, params, body }) =>
|
handler: async ({ deps, user, params, body, actor }) =>
|
||||||
json(await addEngagement(deps.db, user.id, params.id!, body as Parameters<typeof addEngagement>[3], "user"), 201),
|
json(await addEngagement(deps.db, user.id, params.id!, body as Parameters<typeof addEngagement>[3], actor), 201),
|
||||||
}),
|
}),
|
||||||
route({
|
route({
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -39,16 +39,16 @@ export const engagementRoutes = [
|
|||||||
summary: "Edit an engagement.",
|
summary: "Edit an engagement.",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
body: engagementPatch,
|
body: engagementPatch,
|
||||||
handler: async ({ deps, user, params, body }) =>
|
handler: async ({ deps, user, params, body, actor }) =>
|
||||||
updateEngagement(deps.db, user.id, params.id!, body as Parameters<typeof updateEngagement>[3], "user"),
|
updateEngagement(deps.db, user.id, params.id!, body as Parameters<typeof updateEngagement>[3], actor),
|
||||||
}),
|
}),
|
||||||
route({
|
route({
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
path: "engagements/:id",
|
path: "engagements/:id",
|
||||||
summary: "Soft-delete an engagement.",
|
summary: "Soft-delete an engagement.",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
handler: async ({ deps, user, params }) => {
|
handler: async ({ deps, user, params, actor }) => {
|
||||||
await softDeleteEngagement(deps.db, user.id, params.id!, "user");
|
await softDeleteEngagement(deps.db, user.id, params.id!, actor);
|
||||||
return noContent();
|
return noContent();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ export const entryRoutes = [
|
|||||||
summary: "Edit an entry. Edited fields become user-owned (syncs stop overwriting them).",
|
summary: "Edit an entry. Edited fields become user-owned (syncs stop overwriting them).",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
body: entryPatch,
|
body: entryPatch,
|
||||||
handler: async ({ deps, user, params, body }) => {
|
handler: async ({ deps, user, params, body, actor }) => {
|
||||||
const cur = await getEntry(deps.db, user.id, params.id!);
|
const cur = await getEntry(deps.db, user.id, params.id!);
|
||||||
await upsertEntry(deps.db, user.id, cur.workId, body as Parameters<typeof upsertEntry>[3], "user");
|
await upsertEntry(deps.db, user.id, cur.workId, body as Parameters<typeof upsertEntry>[3], actor);
|
||||||
return getEntry(deps.db, user.id, params.id!);
|
return getEntry(deps.db, user.id, params.id!);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -71,8 +71,8 @@ export const entryRoutes = [
|
|||||||
path: "entries/:id",
|
path: "entries/:id",
|
||||||
summary: "Soft-delete an entry.",
|
summary: "Soft-delete an entry.",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
handler: async ({ deps, user, params }) => {
|
handler: async ({ deps, user, params, actor }) => {
|
||||||
await softDeleteEntry(deps.db, user.id, params.id!, "user");
|
await softDeleteEntry(deps.db, user.id, params.id!, actor);
|
||||||
return noContent();
|
return noContent();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -99,8 +99,8 @@ export const entryRoutes = [
|
|||||||
summary: "Move an entry to another work (for you only).",
|
summary: "Move an entry to another work (for you only).",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
body: z.object({ workId: z.string().min(1) }).strict(),
|
body: z.object({ workId: z.string().min(1) }).strict(),
|
||||||
handler: async ({ deps, user, params, body }) => {
|
handler: async ({ deps, user, params, body, actor }) => {
|
||||||
const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, "user");
|
const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, actor);
|
||||||
return getEntry(deps.db, user.id, moved.id);
|
return getEntry(deps.db, user.id, moved.id);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const listRoutes = [
|
|||||||
summary: "Create a list.",
|
summary: "Create a list.",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
body: listPatch.required({ name: true }),
|
body: listPatch.required({ name: true }),
|
||||||
handler: async ({ deps, user, body }) => json(await createList(deps.db, user.id, body as ListPatchArg & { name: string }), 201),
|
handler: async ({ deps, user, body, actor }) => json(await createList(deps.db, user.id, body as ListPatchArg & { name: string }, actor), 201),
|
||||||
}),
|
}),
|
||||||
route({
|
route({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -40,15 +40,15 @@ export const listRoutes = [
|
|||||||
summary: "Edit a list.",
|
summary: "Edit a list.",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
body: listPatch,
|
body: listPatch,
|
||||||
handler: async ({ deps, user, params, body }) => updateList(deps.db, user.id, params.id!, body as ListPatchArg),
|
handler: async ({ deps, user, params, body, actor }) => updateList(deps.db, user.id, params.id!, body as ListPatchArg, actor),
|
||||||
}),
|
}),
|
||||||
route({
|
route({
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
path: "lists/:id",
|
path: "lists/:id",
|
||||||
summary: "Soft-delete a list.",
|
summary: "Soft-delete a list.",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
handler: async ({ deps, user, params }) => {
|
handler: async ({ deps, user, params, actor }) => {
|
||||||
await softDeleteList(deps.db, user.id, params.id!);
|
await softDeleteList(deps.db, user.id, params.id!, actor);
|
||||||
return noContent();
|
return noContent();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -58,7 +58,7 @@ export const listRoutes = [
|
|||||||
summary: "Add a work to a list.",
|
summary: "Add a work to a list.",
|
||||||
auth: "any:write",
|
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(),
|
body: z.object({ workId: z.string().min(1), note: text(10_000).nullable().optional(), position: z.number().int().min(0).optional() }).strict(),
|
||||||
handler: async ({ deps, user, params, body }) => json(await addListItem(deps.db, user.id, params.id!, body), 201),
|
handler: async ({ deps, user, params, body, actor }) => json(await addListItem(deps.db, user.id, params.id!, body, actor), 201),
|
||||||
}),
|
}),
|
||||||
route({
|
route({
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -66,8 +66,8 @@ export const listRoutes = [
|
|||||||
summary: "Move a list item to a new 0-based position.",
|
summary: "Move a list item to a new 0-based position.",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
body: z.object({ position: z.number().int().min(0) }).strict(),
|
body: z.object({ position: z.number().int().min(0) }).strict(),
|
||||||
handler: async ({ deps, user, params, body }) => {
|
handler: async ({ deps, user, params, body, actor }) => {
|
||||||
await moveListItem(deps.db, user.id, params.id!, body.position);
|
await moveListItem(deps.db, user.id, params.id!, body.position, actor);
|
||||||
return noContent();
|
return noContent();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -76,8 +76,8 @@ export const listRoutes = [
|
|||||||
path: "list-items/:id",
|
path: "list-items/:id",
|
||||||
summary: "Remove an item from a list.",
|
summary: "Remove an item from a list.",
|
||||||
auth: "any:write",
|
auth: "any:write",
|
||||||
handler: async ({ deps, user, params }) => {
|
handler: async ({ deps, user, params, actor }) => {
|
||||||
await removeListItem(deps.db, user.id, params.id!);
|
await removeListItem(deps.db, user.id, params.id!, actor);
|
||||||
return noContent();
|
return noContent();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ interface CallOpts {
|
|||||||
token?: string;
|
token?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
form?: FormData;
|
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> {
|
async function call(app: App, method: string, path: string, o: CallOpts = {}): Promise<Response> {
|
||||||
const headers: Record<string, string> = { ...o.headers };
|
const headers: Record<string, string> = { ...o.headers };
|
||||||
if (o.cookie) {
|
if (o.cookie) headers.cookie = o.cookie;
|
||||||
headers.cookie = o.cookie;
|
|
||||||
headers["x-requested-with"] ??= "sttracker";
|
|
||||||
}
|
|
||||||
if (o.token) headers.authorization = `Bearer ${o.token}`;
|
if (o.token) headers.authorization = `Bearer ${o.token}`;
|
||||||
|
else if (!o.noCsrf) headers["x-requested-with"] ??= "sttracker";
|
||||||
let body: BodyInit | undefined;
|
let body: BodyInit | undefined;
|
||||||
if (o.form) body = o.form;
|
if (o.form) body = o.form;
|
||||||
else if (o.body !== undefined) {
|
else if (o.body !== undefined) {
|
||||||
@@ -184,8 +184,16 @@ forEachDialect("api", (getDb) => {
|
|||||||
|
|
||||||
test("cookie-authenticated writes need the CSRF header", async () => {
|
test("cookie-authenticated writes need the CSRF header", async () => {
|
||||||
const cookie = await signUp(app, "ana");
|
const cookie = await signUp(app, "ana");
|
||||||
const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, headers: { "x-requested-with": "" } });
|
const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, noCsrf: true });
|
||||||
expect(res.status).toBe(403);
|
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 () => {
|
test("login is rate limited to 10 per minute", async () => {
|
||||||
@@ -264,16 +272,97 @@ forEachDialect("api", (getDb) => {
|
|||||||
expect(got.headers.get("x-content-type-options")).toBe("nosniff");
|
expect(got.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
expect(got.headers.get("content-disposition")).toBe('inline; filename="we_ird_na_me_.png"');
|
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();
|
const form2 = new FormData();
|
||||||
form2.set("file", new File([new Uint8Array([1])], "a.png", { type: "image/png" }));
|
form2.set("file", new File([new Uint8Array([1])], "a.png", { type: "image/png" }));
|
||||||
form2.set("entity", "entry");
|
form2.set("entity", "entry");
|
||||||
form2.set("entityId", "0190f000-0000-7000-8000-000000000000");
|
form2.set("entityId", bobs.id);
|
||||||
expect((await call(app, "POST", "attachments", { cookie: ana, form: form2 })).status).toBe(404);
|
expect((await call(app, "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, "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);
|
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 () => {
|
test("openapi.json lists /api/v1/entries", async () => {
|
||||||
const res = await call(app, "GET", "openapi.json");
|
const res = await call(app, "GET", "openapi.json");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -281,6 +370,12 @@ forEachDialect("api", (getDb) => {
|
|||||||
expect(doc.openapi).toStartWith("3.");
|
expect(doc.openapi).toStartWith("3.");
|
||||||
expect(doc.paths["/api/v1/entries"]?.get?.summary).toBeTruthy();
|
expect(doc.paths["/api/v1/entries"]?.get?.summary).toBeTruthy();
|
||||||
expect(doc.paths["/api/v1/entries/{id}"]?.patch).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 () => {
|
test("about lists attributions; ingest and unknown routes are 404 JSON", async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user