371 lines
14 KiB
TypeScript
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);
|
|
}
|
|
}
|