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 ): (c: Context) => Promise { 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; readonly create: (c: Context) => Promise; readonly get: (c: Context) => Promise; readonly update: (c: Context) => Promise; readonly remove: (c: Context) => Promise; } 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(); 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; } = { 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(); 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; } = { ...(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, op: "list" | "create" | "read" | "update" | "delete" ): Promise { // 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); } }