Initial Commit
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user