Files
suaveplan/packages/api-stack/api/api-plugin-vault/src/routes/build-routes.ts
2026-05-27 23:29:53 +01:00

371 lines
14 KiB
TypeScript

import {
DELETE,
GET,
PATCH,
type PluginRouteEntry,
POST,
successResponse,
} from "@suaveplan/api-openapi";
import { requireAuth } from "@suaveplan/api-plugin-auth";
import type { Context } from "hono";
import {
VAULT_PLUGIN_CODES,
VaultPluginError,
} from "../error-codes/error-codes.js";
import { getVaultPluginInstruments } from "../otel/instruments.js";
import { extractUnlock } from "../plugin/unlock.js";
import {
CreateVaultItemBodySchema,
DeleteVaultItemResponseSchema,
UpdateVaultItemBodySchema,
VaultItemListResponseSchema,
VaultItemParamsSchema,
VaultItemResponseSchema,
} from "../schemas/vault-item.js";
import type { VaultService } from "../services/vault-service.js";
import type {
VaultHandle,
VaultItem,
VaultPluginConfig,
VaultUnlockExtractor,
} from "../types/types.js";
/**
* Build the 5 vault routes. Every route uses `requireAuth()` upstream
* so the principal is guaranteed non-null. Foreign items are surfaced
* as `VAULT_PLUGIN_ITEM_NOT_FOUND` (404) to prevent enumeration —
* never 403.
*/
export function buildVaultRoutes(
vaultService: VaultService,
config: VaultPluginConfig
): PluginRouteEntry[] {
const list = GET("/vault/items")
.tags("vault")
.summary("List the principal's vault items")
.response(successResponse(VaultItemListResponseSchema.shape.data))
.build();
const create = POST("/vault/items")
.tags("vault")
.summary("Create a new vault item")
.body(CreateVaultItemBodySchema)
.response(successResponse(VaultItemResponseSchema.shape.data))
.build();
const get = GET("/vault/items/{id}")
.tags("vault")
.summary("Fetch a single vault item")
.params(VaultItemParamsSchema)
.response(successResponse(VaultItemResponseSchema.shape.data))
.build();
const update = PATCH("/vault/items/{id}")
.tags("vault")
.summary("Update fields on an existing vault item")
.params(VaultItemParamsSchema)
.body(UpdateVaultItemBodySchema)
.response(successResponse(VaultItemResponseSchema.shape.data))
.build();
const remove = DELETE("/vault/items/{id}")
.tags("vault")
.summary("Delete a vault item")
.params(VaultItemParamsSchema)
.response(successResponse(DeleteVaultItemResponseSchema.shape.data))
.build();
const handlers = createHandlers(vaultService, config);
return [
{ route: list, handler: requireAuthThen(handlers.list) },
{ route: create, handler: requireAuthThen(handlers.create) },
{ route: get, handler: requireAuthThen(handlers.get) },
{ route: update, handler: requireAuthThen(handlers.update) },
{ route: remove, handler: requireAuthThen(handlers.remove) },
];
}
/**
* Wrap a handler with `requireAuth()` upstream — the middleware throws
* `AuthError(AUTH_UNAUTHENTICATED)` for anonymous principals, so every
* handler downstream may safely assume `c.get("user")` is non-null.
*
* `requireAuth` invokes `next` whenever the principal is non-null and
* throws otherwise — the wrapped handler always sets `response` before
* `await guard(...)` resolves, so the post-await read is safe.
*/
function requireAuthThen(
handler: (c: Context) => Promise<Response>
): (c: Context) => Promise<Response> {
const guard = requireAuth();
return async (c) => {
let response: Response | undefined;
await guard(c, async () => {
response = await handler(c);
});
return response as Response;
};
}
interface Handlers {
readonly list: (c: Context) => Promise<Response>;
readonly create: (c: Context) => Promise<Response>;
readonly get: (c: Context) => Promise<Response>;
readonly update: (c: Context) => Promise<Response>;
readonly remove: (c: Context) => Promise<Response>;
}
function createHandlers(
vaultService: VaultService,
config: VaultPluginConfig
): Handlers {
const extractor: VaultUnlockExtractor = config.unlockExtractor;
const maxBytes = config.maxItemSizeBytes;
return {
list: async (c) =>
withOpenVault(
c,
vaultService,
extractor,
async (handle) => {
const items = await vaultService.listItems(handle);
const summaries = items.map(toSummary);
getVaultPluginInstruments().itemsReadTotal.add(1, {
outcome: "success",
});
return c.json({
success: true as const,
data: summaries,
meta: { total: summaries.length },
});
},
"list"
),
create: async (c) =>
withOpenVault(
c,
vaultService,
extractor,
async (handle) => {
const raw = await c.req.json<unknown>();
const parsed = CreateVaultItemBodySchema.safeParse(raw);
if (!parsed.success) {
incrementOutcome("create", "failure");
throw new VaultPluginError(
VAULT_PLUGIN_CODES.INVALID_INPUT,
"POST /vault/items received an invalid body",
{
cause: parsed.error,
context: { statusCode: 400 },
}
);
}
enforceBodySize(parsed.data.body, maxBytes, "create");
const createInput: {
title: string;
kind: string;
body: string;
metadata?: Record<string, unknown>;
} = {
title: parsed.data.title,
kind: parsed.data.kind,
body: parsed.data.body,
...(parsed.data.metadata !== undefined
? { metadata: parsed.data.metadata }
: {}),
};
const item = await vaultService.createItem(
handle,
createInput
);
const bodyBytes = Buffer.byteLength(item.body, "utf8");
const instruments = getVaultPluginInstruments();
instruments.itemsCreatedTotal.add(1, {
outcome: "success",
});
instruments.itemSizeBytes.record(bodyBytes);
return c.json({ success: true as const, data: item }, 201);
},
"create"
),
get: async (c) =>
withOpenVault(
c,
vaultService,
extractor,
async (handle) => {
const params = VaultItemParamsSchema.parse(c.req.param());
const item = await vaultService.readItem(handle, params.id);
if (item === null) {
incrementOutcome("read", "failure");
throw new VaultPluginError(
VAULT_PLUGIN_CODES.ITEM_NOT_FOUND,
"vault item not found",
{ context: { id: params.id, statusCode: 404 } }
);
}
incrementOutcome("read", "success");
return c.json({ success: true as const, data: item });
},
"read"
),
update: async (c) =>
withOpenVault(
c,
vaultService,
extractor,
async (handle) => {
const params = VaultItemParamsSchema.parse(c.req.param());
const raw = await c.req.json<unknown>();
const parsed = UpdateVaultItemBodySchema.safeParse(raw);
if (!parsed.success) {
incrementOutcome("update", "failure");
throw new VaultPluginError(
VAULT_PLUGIN_CODES.INVALID_INPUT,
"PATCH /vault/items/:id received an invalid body",
{
cause: parsed.error,
context: { statusCode: 400 },
}
);
}
if (parsed.data.body !== undefined) {
enforceBodySize(parsed.data.body, maxBytes, "update");
}
const updatePatch: {
title?: string;
kind?: string;
body?: string;
metadata?: Record<string, unknown>;
} = {
...(parsed.data.title !== undefined
? { title: parsed.data.title }
: {}),
...(parsed.data.kind !== undefined
? { kind: parsed.data.kind }
: {}),
...(parsed.data.body !== undefined
? { body: parsed.data.body }
: {}),
...(parsed.data.metadata !== undefined
? { metadata: parsed.data.metadata }
: {}),
};
const item = await vaultService.updateItem(
handle,
params.id,
updatePatch
);
if (item === null) {
incrementOutcome("update", "failure");
throw new VaultPluginError(
VAULT_PLUGIN_CODES.ITEM_NOT_FOUND,
"vault item not found",
{ context: { id: params.id, statusCode: 404 } }
);
}
const instruments = getVaultPluginInstruments();
instruments.itemsUpdatedTotal.add(1, {
outcome: "success",
});
if (parsed.data.body !== undefined) {
instruments.itemSizeBytes.record(
Buffer.byteLength(parsed.data.body, "utf8")
);
}
return c.json({ success: true as const, data: item });
},
"update"
),
remove: async (c) =>
withOpenVault(
c,
vaultService,
extractor,
async (handle) => {
const params = VaultItemParamsSchema.parse(c.req.param());
const deleted = await vaultService.deleteItem(
handle,
params.id
);
if (!deleted) {
incrementOutcome("delete", "failure");
throw new VaultPluginError(
VAULT_PLUGIN_CODES.ITEM_NOT_FOUND,
"vault item not found",
{ context: { id: params.id, statusCode: 404 } }
);
}
getVaultPluginInstruments().itemsDeletedTotal.add(1, {
outcome: "success",
});
return c.json({
success: true as const,
data: { deleted: true as const },
});
},
"delete"
),
};
}
function toSummary(item: VaultItem) {
const { body: _body, ...summary } = item;
void _body;
return summary;
}
function enforceBodySize(
body: string,
maxBytes: number,
op: "create" | "update"
): void {
const bytes = Buffer.byteLength(body, "utf8");
if (bytes > maxBytes) {
incrementOutcome(op, "failure");
throw new VaultPluginError(
VAULT_PLUGIN_CODES.ITEM_TOO_LARGE,
"vault item exceeds maxItemSizeBytes",
{ context: { bytes, maxBytes, statusCode: 413 } }
);
}
}
function incrementOutcome(
op: "create" | "read" | "update" | "delete",
outcome: "success" | "failure"
): void {
const i = getVaultPluginInstruments();
if (op === "create") i.itemsCreatedTotal.add(1, { outcome });
else if (op === "read") i.itemsReadTotal.add(1, { outcome });
else if (op === "update") i.itemsUpdatedTotal.add(1, { outcome });
else i.itemsDeletedTotal.add(1, { outcome });
}
async function withOpenVault(
c: Context,
vaultService: VaultService,
extractor: VaultUnlockExtractor,
fn: (handle: VaultHandle) => Promise<Response>,
op: "list" | "create" | "read" | "update" | "delete"
): Promise<Response> {
// requireAuth has already verified the user is non-null before the
// route handler runs; the non-null assertion is safe because the
// guard above throws AuthError(UNAUTHENTICATED) otherwise.
const principal = c.get("user")!;
const unlockSecret = extractUnlock(c, extractor);
let handle: VaultHandle | undefined;
try {
handle = await vaultService.openForPrincipal(principal, unlockSecret);
return await fn(handle);
} catch (err) {
if (
!(err instanceof VaultPluginError) &&
(op === "list" || op === "read")
) {
incrementOutcome(op === "list" ? "read" : op, "failure");
}
throw err;
} finally {
if (handle !== undefined) await vaultService.close(handle);
}
}