import type { Context } from "@wrnexus/core"; import type { Db } from "@wrnexus/db"; import { can } from "@wrnexus/authz"; import { HttpError, defineApiRoute, json, requireJson, requireParam, requireUser } from "./http.ts"; const SAFE = /^[A-Za-z_][A-Za-z0-9_]*$/; const ident = (value: string) => { if (!SAFE.test(value)) throw new TypeError(`Unsafe resource identifier: ${value}`); return value; }; export interface ResourceDefinition> { name: string; db: () => Db; table: string; id?: string; owner?: string; fields: readonly (keyof T & string)[]; permissions?: { read?: string; readAny?: string; write?: string; writeAny?: string }; validate?: (input: unknown, mode: "create" | "update") => T | Promise; serialize?: (row: T) => unknown; } export interface ResourceHandlers { list(ctx: Context): Response | Promise; create(ctx: Context): Response | Promise; get(ctx: Context): Response | Promise; update(ctx: Context): Response | Promise; remove(ctx: Context): Response | Promise; } /** Build conventional owned CRUD handlers from one checked resource declaration. */ export function defineResource>( definition: ResourceDefinition, ): ResourceHandlers { const table = ident(definition.table); const id = ident(definition.id ?? "id"); const owner = definition.owner ? ident(definition.owner) : undefined; const fields = definition.fields.map(ident); const shape = (row: T) => definition.serialize?.(row) ?? row; const authorize = async (ctx: Context, action: "read" | "write", row?: T) => { const subject = requireUser(ctx).id; const permission = definition.permissions?.[action]; const any = definition.permissions?.[`${action}Any`]; if (any && (await can(ctx, any))) return subject; if ( permission && !(await can(ctx, permission, owner ? { ownerId: row?.[owner] ?? subject } : row)) ) { throw new HttpError(403, "Forbidden"); } if (owner && row && String(row[owner]) !== subject) throw new HttpError(403, "Forbidden"); return subject; }; const find = async (ctx: Context) => { const value = requireParam(ctx, id, { integer: true, positive: true }); const row = await definition.db().one(`SELECT * FROM ${table} WHERE ${id} = ?`, [value]); if (!row) throw new HttpError(404, `${definition.name} not found`); return row; }; return { list: defineApiRoute(async (ctx) => { const subject = await authorize(ctx, "read"); const seeAll = definition.permissions?.readAny && (await can(ctx, definition.permissions.readAny)); const rows = await definition .db() .all( `SELECT * FROM ${table}${owner && !seeAll ? ` WHERE ${owner} = ?` : ""} ORDER BY ${id} DESC`, owner && !seeAll ? [subject] : [], ); return json.ok({ [definition.name]: rows.map(shape) }); }), create: defineApiRoute(async (ctx) => { const subject = await authorize(ctx, "write"); const input = definition.validate ? await definition.validate(await requireJson(ctx), "create") : await requireJson(ctx); const values = Object.fromEntries( fields.filter((key) => key in input).map((key) => [key, input[key]]), ); if (owner) values[owner] = subject; const columns = Object.keys(values).map(ident); const result = await definition.db().exec( `INSERT INTO ${table} (${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`, columns.map((key) => values[key]), ); return json.created({ ok: true, id: result.lastInsertId }); }), get: defineApiRoute(async (ctx) => { const row = await find(ctx); await authorize(ctx, "read", row); return json.ok({ [definition.name.replace(/s$/, "")]: shape(row) }); }), update: defineApiRoute(async (ctx) => { const row = await find(ctx); await authorize(ctx, "write", row); const input = definition.validate ? await definition.validate(await requireJson(ctx), "update") : await requireJson(ctx); const keys = fields.filter((key) => key in input); if (!keys.length) throw new HttpError(400, "No writable fields supplied."); await definition .db() .exec(`UPDATE ${table} SET ${keys.map((key) => `${key} = ?`).join(", ")} WHERE ${id} = ?`, [ ...keys.map((key) => input[key]), row[id], ]); return json.ok({ ok: true }); }), remove: defineApiRoute(async (ctx) => { const row = await find(ctx); await authorize(ctx, "write", row); await definition.db().exec(`DELETE FROM ${table} WHERE ${id} = ?`, [row[id]]); return json.ok({ ok: true }); }), }; }