Files
saltbo__agent-kanban/server/http/resource-server/request.ts
2026-09-05 03:19:27 +00:00

148 lines
5.9 KiB
TypeScript

import {
completeIdempotency,
type ResourceIdempotency,
ResourceIdempotencyReplay,
resolveIdempotentResponse,
} from "@server/adapters/d1/resourceIdempotency";
import type { Env } from "@server/env";
import { v2Problem } from "@server/http/middleware/v2Contract";
import type { TaskActionWriteActorType } from "@shared";
import type { Context } from "hono";
import { HTTPException } from "hono/http-exception";
type ApiContext = Context<{ Bindings: Env }>;
export function resolveActor(c: ApiContext): { actorType: TaskActionWriteActorType; actorId: string; sessionId: null } {
const principal = c.get("principal");
const actorType: TaskActionWriteActorType = principal.type === "agent" ? "realmroot:agent" : principal.type === "human" ? "user" : principal.type;
return {
actorType,
actorId: principal.actorId ?? principal.subjectId,
sessionId: null,
};
}
export function assertResourceWriteFields(
body: unknown,
allowed: ReadonlySet<string>,
resourceName: string,
): asserts body is Record<string, unknown> {
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw new HTTPException(422, { message: `${resourceName} must be a JSON object` });
}
const unknown = Object.keys(body).filter((key) => !allowed.has(key));
if (unknown.length > 0) {
throw new HTTPException(422, { message: `${resourceName} contains unsupported properties: ${unknown.join(", ")}` });
}
}
export function assertRequiredResourceString(body: Record<string, unknown>, field: string, resourceName: string): void {
if (typeof body[field] !== "string" || body[field].length === 0) {
throw new HTTPException(422, { message: `${resourceName}.${field} must be a non-empty string` });
}
}
export function assertOptionalResourceString(body: Record<string, unknown>, field: string, resourceName: string): void {
if (body[field] !== undefined && typeof body[field] !== "string") {
throw new HTTPException(422, { message: `${resourceName}.${field} must be a string` });
}
}
export function assertOptionalResourceStringArray(body: Record<string, unknown>, field: string, resourceName: string): void {
const value = body[field];
if (value !== undefined && (!Array.isArray(value) || value.some((item) => typeof item !== "string"))) {
throw new HTTPException(422, { message: `${resourceName}.${field} must be an array of strings` });
}
}
export function assertOptionalResourceObject(body: Record<string, unknown>, field: string, resourceName: string): void {
const value = body[field];
if (value !== undefined && value !== null && (typeof value !== "object" || Array.isArray(value))) {
throw new HTTPException(422, { message: `${resourceName}.${field} must be a JSON object or null` });
}
}
export async function rejectRequestBody(c: ApiContext, resourceName: string): Promise<Response | null> {
if ((await c.req.raw.clone().arrayBuffer()).byteLength === 0) return null;
return v2Problem(c, 400, "request-body-not-allowed", "Request body not allowed", `${resourceName} has no client-writable representation`);
}
export function setCreatedResourceHeaders(c: ApiContext, collection: string, id: string, version: string): void {
c.header("Location", new URL(`/api/${collection}/${encodeURIComponent(id)}`, c.req.url).toString());
c.header("ETag", `"${version}"`);
}
export function externalCreationIdempotencyKey(c: ApiContext): string {
const idempotency = c.get("resourceIdempotency");
if (!idempotency) throw new Error("External creation requires idempotency middleware");
return idempotency.upstreamKey;
}
export function resourceIdempotencyFor<T extends { id: string }>(
c: ApiContext,
collection: string,
versionOf: (resource: T) => string,
represent: (resource: T) => unknown,
options: { includeResourceHeaders?: boolean } = {},
): ResourceIdempotency<T> | undefined {
const idempotency = c.get("resourceIdempotency");
if (!idempotency) return undefined;
return {
...idempotency,
responseFor: (resource) => {
const id = resource.id;
return {
status: 201,
body: JSON.stringify(represent(resource)),
contentType: "application/json; charset=UTF-8",
location: options.includeResourceHeaders === false ? "" : new URL(`/api/${collection}/${encodeURIComponent(id)}`, c.req.url).toString(),
etag: options.includeResourceHeaders === false ? "" : `"${versionOf(resource)}"`,
};
},
};
}
export async function readJsonBody<T>(c: ApiContext, mediaTypes = ["application/json"]): Promise<T | Response> {
if (!mediaTypes.includes(c.req.header("content-type")?.split(";", 1)[0]?.trim().toLowerCase() ?? "")) {
return v2Problem(c, 415, "unsupported-media-type", "Unsupported media type", `Content-Type must be ${mediaTypes.join(" or ")}`);
}
try {
return await c.req.json<T>();
} catch {
return v2Problem(c, 400, "invalid-json", "Invalid JSON", "The request body must be valid JSON");
}
}
export async function completeExternalCreation(
c: ApiContext,
collection: string,
resourceId: string,
version: string,
responseBody: unknown,
status: 200 | 201 = 201,
options: { includeResourceHeaders?: boolean } = {},
): Promise<void> {
const idempotency = c.get("resourceIdempotency");
if (!idempotency) return;
const location =
options.includeResourceHeaders === false ? "" : new URL(`/api/${collection}/${encodeURIComponent(resourceId)}`, c.req.url).toString();
const etag = options.includeResourceHeaders === false ? "" : `"${version}"`;
const completed = {
...idempotency,
responseFor: () => ({
status,
body: JSON.stringify(responseBody),
contentType: "application/json; charset=UTF-8",
location,
etag,
}),
};
try {
await c.env.DB.batch([completeIdempotency(c.env.DB, completed, resourceId, responseBody)]);
} catch (error) {
const winner = await resolveIdempotentResponse(c.env.DB, idempotency);
if (winner) throw new ResourceIdempotencyReplay(winner);
throw error;
}
}