Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
# routes/build-routes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds the 5 OpenAPI route entries that `createVaultPlugin` mounts on
the host's Hono app: list, create, read, update, delete. Each entry is
a `PluginRouteEntry` — a `RouteConfig` plus a handler — with full Zod
inference through the `RouteBuilder` chain. The OpenAPI document and
the runtime routing surface cannot drift because both are produced from
the same builder output.
## Features
- Five routes — `GET /vault/items`, `POST /vault/items`,
`GET /vault/items/:id`, `PATCH /vault/items/:id`,
`DELETE /vault/items/:id` — each tagged `vault` for grouped doc
display.
- Per-handler upstream `requireAuth()` invocation — every route is
gated; anonymous calls produce `AUTH_UNAUTHENTICATED` (401) via the
auth plugin's middleware.
- Per-request open/close lifecycle: every route opens the principal's
vault via `vaultService.openForPrincipal`, performs the operation,
and closes the handle in `finally` so the vault is locked again
before the response returns.
- Anti-enumeration policy baked in: any reference to an item id that
does not exist OR is owned by another principal raises
`VAULT_PLUGIN_ITEM_NOT_FOUND` (404) — NEVER 403. This prevents an
attacker from probing the existence of item ids owned by other
principals.
- Size enforcement at the handler boundary, not at the Zod schema, so
the error message can include the exact byte count and the configured
`maxItemSizeBytes`.
- 4 OTel counters + 1 histogram emitted per operation (success +
failure outcomes).
## Usage: Basic
```ts
import { buildVaultRoutes } from "@suaveplan/api-plugin-vault";
const entries = buildVaultRoutes(vaultService, config);
definePluginRoutes(entries, {
app,
registry: openAPIRegistry,
pluginName: "vault",
});
```
## Usage: Advanced
```ts
// Drop one of the 5 routes by filtering before registration — operators
// running a read-only mirror can elide the mutation routes.
const entries = buildVaultRoutes(vaultService, config).filter((e) =>
e.route.method === "get"
);
definePluginRoutes(entries, { app, registry, pluginName: "vault" });
```
## API Reference
### `buildVaultRoutes(vaultService, config): PluginRouteEntry[]`
Returns 5 entries in registration order: list, create, get, update,
delete. Every entry has:
- `route` — a built `RouteConfig` carrying the full Zod schema chain.
- `handler` — async function taking a Hono `Context` and returning
`Promise<Response>`.
The handler wraps the actual operation in a `requireAuth()` gate; the
auth plugin's `Principal` is read from `c.get("user")`.
## Implementation Notes
- The `RouteBuilder` `.params(VaultItemParamsSchema)` registers the
`{id}` URL parameter on the OpenAPI doc using the OpenAPI convention
(`{id}`); Hono's runtime router consumes the same builder output, so
the path matches both surfaces.
- The handler explicitly normalises the wire envelope: every success
response is `{ success: true, data }` (list also includes
`meta: { total }`). The shared error envelope is contributed by the
`StandardResponses` block from `@suaveplan/api-openapi` — handlers
never construct an error envelope themselves; they `throw` a
`VaultPluginError` and let the host's error-handler middleware
serialise it.
- `Buffer.byteLength(body, "utf8")` is used for size enforcement so
the byte count matches what an HTTP client would actually transmit
(rather than the JavaScript string `length` which counts UTF-16
code units).
- The defensive null-check on `c.get("user")` inside `withOpenVault`
exists because `requireAuth()` runs in a separate middleware step
and TypeScript cannot narrow across the await boundary. Production
cannot reach the branch (requireAuth has already thrown), but the
branch is reachable from unit tests that bypass the guard.
- Coverage note: the `requireAuthThen` `response === undefined` branch
exists for TypeScript narrowing; the unit tests assert it raises
`VAULT_PLUGIN_FORBIDDEN` by injecting a stubbed `requireAuth` that
returns without invoking `next`.

View File

@@ -0,0 +1,703 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import {
createOpenAPIRegistry,
definePluginRoutes,
} from "@suaveplan/api-openapi";
import type { Principal } from "@suaveplan/api-plugin-auth";
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { createFakeVaultService } from "../../tests/setup/fake-vault-service.js";
import { createMemoryDrive } from "../../tests/setup/memory-drive.js";
import { VAULT_PLUGIN_CODES } from "../error-codes/error-codes.js";
import { resetVaultPluginInstruments } from "../otel/instruments.js";
import { defaultUnlockExtractor } from "../plugin/unlock.js";
import type { VaultPluginConfig } from "../types/types.js";
import { buildVaultRoutes } from "./build-routes.js";
function makeApp(args: {
principal: Principal | null;
config: VaultPluginConfig;
fake?: ReturnType<typeof createFakeVaultService>;
}): { app: OpenAPIHono; service: ReturnType<typeof createFakeVaultService> } {
const service = args.fake ?? createFakeVaultService();
const app = new OpenAPIHono();
app.use("*", async (c, next) => {
c.set("user", args.principal);
await next();
});
const registry = createOpenAPIRegistry({
info: { title: "vault-test", version: "0.1.0" },
});
const entries = buildVaultRoutes(service, args.config);
definePluginRoutes(entries, { app, registry, pluginName: "vault" });
app.onError((err, c) => {
const ctx = (err as { context?: { statusCode?: number } }).context;
const status = ctx?.statusCode ?? 500;
const code = (err as { code?: string }).code ?? "UNKNOWN";
return c.json(
{
success: false,
error: {
code,
message: (err as Error).message ?? "error",
timestamp: new Date().toISOString(),
},
},
status as 400 | 401 | 403 | 404 | 413 | 500
);
});
return { app, service };
}
const baseConfig = (): VaultPluginConfig => ({
storage: createMemoryDrive(),
routePrefix: "/vault",
maxItemSizeBytes: 10 * 1024 * 1024,
applySchemaOnInit: true,
unlockExtractor: defaultUnlockExtractor,
serverPepper: "test-pepper",
});
const principal: Principal = {
id: "user:alice",
emailVerified: true,
roles: [],
};
beforeEach(() => {
resetVaultPluginInstruments();
});
describe("routes/build-routes", () => {
it("registers exactly 5 routes", { timeout: 10_000 }, () => {
const service = createFakeVaultService();
const entries = buildVaultRoutes(service, baseConfig());
expect(entries.length).toBe(5);
expect(
entries.map(
(e) => `${e.route.method.toUpperCase()} ${e.route.path}`
)
).toEqual([
"GET /vault/items",
"POST /vault/items",
"GET /vault/items/{id}",
"PATCH /vault/items/{id}",
"DELETE /vault/items/{id}",
]);
});
it("requires auth — anonymous principal returns 401", {
timeout: 10_000,
}, async () => {
const { app } = makeApp({ principal: null, config: baseConfig() });
const res = await app.request("/vault/items", {
headers: { "X-Vault-Unlock": "p" },
});
expect(res.status).toBe(401);
});
it("missing unlock header raises UNLOCK_HEADER_MISSING (400)", {
timeout: 10_000,
}, async () => {
const { app } = makeApp({ principal, config: baseConfig() });
const res = await app.request("/vault/items");
expect(res.status).toBe(400);
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.UNLOCK_HEADER_MISSING);
});
it("POST /vault/items creates and returns the item", {
timeout: 10_000,
}, async () => {
const { app } = makeApp({ principal, config: baseConfig() });
const res = await app.request("/vault/items", {
method: "POST",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "API key",
kind: "credential",
body: "k",
}),
});
expect(res.status).toBe(201);
const json = (await res.json()) as {
data: { id: string; title: string; ownerId: string };
};
expect(json.data.title).toBe("API key");
expect(json.data.ownerId).toBe("user:alice");
});
it("POST rejects invalid body (400)", { timeout: 10_000 }, async () => {
const { app } = makeApp({ principal, config: baseConfig() });
const res = await app.request("/vault/items", {
method: "POST",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
// Zod auto-validation at the @hono/zod-openapi boundary returns
// 400 before our handler runs.
expect(res.status).toBe(400);
});
it("POST rejects oversize bodies with ITEM_TOO_LARGE (413)", {
timeout: 10_000,
}, async () => {
const config: VaultPluginConfig = {
...baseConfig(),
maxItemSizeBytes: 16,
};
const { app } = makeApp({ principal, config });
const res = await app.request("/vault/items", {
method: "POST",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "t",
kind: "k",
body: "x".repeat(64),
}),
});
expect(res.status).toBe(413);
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.ITEM_TOO_LARGE);
});
it("returns only principal's items in GET /vault/items", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
// Seed three items for Alice + two for Bob.
const bob: Principal = {
id: "user:bob",
emailVerified: true,
roles: [],
};
const aliceHandle = await service.openForPrincipal(principal, "p");
await service.createItem(aliceHandle, {
title: "a1",
kind: "k",
body: "1",
});
await service.createItem(aliceHandle, {
title: "a2",
kind: "k",
body: "2",
});
await service.createItem(aliceHandle, {
title: "a3",
kind: "k",
body: "3",
});
await service.close(aliceHandle);
const bobHandle = await service.openForPrincipal(bob, "p");
await service.createItem(bobHandle, {
title: "b1",
kind: "k",
body: "1",
});
await service.createItem(bobHandle, {
title: "b2",
kind: "k",
body: "2",
});
await service.close(bobHandle);
const res = await app.request("/vault/items", {
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: Array<{ ownerId: string }>;
meta: { total: number };
};
expect(json.meta.total).toBe(3);
for (const it of json.data) expect(it.ownerId).toBe("user:alice");
});
it("GET /vault/items/:id of foreign item returns 404 (ITEM_NOT_FOUND)", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const bob: Principal = {
id: "user:bob",
emailVerified: true,
roles: [],
};
const bobHandle = await service.openForPrincipal(bob, "p");
const item = await service.createItem(bobHandle, {
title: "secret",
kind: "credential",
body: "k",
});
await service.close(bobHandle);
const res = await app.request(`/vault/items/${item.id}`, {
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBe(404);
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.ITEM_NOT_FOUND);
});
it("GET /vault/items/:id returns the item when owned", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const handle = await service.openForPrincipal(principal, "p");
const created = await service.createItem(handle, {
title: "t",
kind: "k",
body: "body-value",
});
await service.close(handle);
const res = await app.request(`/vault/items/${created.id}`, {
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { body: string } };
expect(json.data.body).toBe("body-value");
});
it("PATCH /vault/items/:id rejects unknown fields (strict mode)", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const handle = await service.openForPrincipal(principal, "p");
const created = await service.createItem(handle, {
title: "t",
kind: "k",
body: "b",
});
await service.close(handle);
const res = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ title: "X", evil: "Y" }),
});
expect(res.status).toBe(400);
});
it("PATCH /vault/items/:id returns 404 for unknown id", {
timeout: 10_000,
}, async () => {
const { app } = makeApp({ principal, config: baseConfig() });
const res = await app.request("/vault/items/nope", {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ title: "X" }),
});
expect(res.status).toBe(404);
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.ITEM_NOT_FOUND);
});
it("PATCH /vault/items/:id rejects oversize body", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const config: VaultPluginConfig = {
...baseConfig(),
maxItemSizeBytes: 8,
};
const { app } = makeApp({ principal, config, fake: service });
const handle = await service.openForPrincipal(principal, "p");
const created = await service.createItem(handle, {
title: "t",
kind: "k",
body: "b",
});
await service.close(handle);
const res = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ body: "x".repeat(64) }),
});
expect(res.status).toBe(413);
});
it("PATCH /vault/items/:id updates the item and returns it", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const handle = await service.openForPrincipal(principal, "p");
const created = await service.createItem(handle, {
title: "old",
kind: "k",
body: "b",
});
await service.close(handle);
const res = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ title: "new" }),
});
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { title: string } };
expect(json.data.title).toBe("new");
});
it("DELETE /vault/items/:id of foreign item returns 404 (not 403)", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const bob: Principal = {
id: "user:bob",
emailVerified: true,
roles: [],
};
const bobHandle = await service.openForPrincipal(bob, "p");
const item = await service.createItem(bobHandle, {
title: "b",
kind: "k",
body: "b",
});
await service.close(bobHandle);
const res = await app.request(`/vault/items/${item.id}`, {
method: "DELETE",
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBe(404);
});
it("DELETE /vault/items/:id deletes and returns { deleted: true }", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const handle = await service.openForPrincipal(principal, "p");
const created = await service.createItem(handle, {
title: "t",
kind: "k",
body: "b",
});
await service.close(handle);
const res = await app.request(`/vault/items/${created.id}`, {
method: "DELETE",
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBe(200);
const json = (await res.json()) as { data: { deleted: boolean } };
expect(json.data.deleted).toBe(true);
});
it("UNLOCK_FAILED surfaces 401 when service rejects the password", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService({
passwords: { "user:alice": "correct" },
});
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const res = await app.request("/vault/items", {
headers: { "X-Vault-Unlock": "wrong" },
});
expect(res.status).toBe(401);
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.UNLOCK_FAILED);
});
it("POST with explicit metadata roundtrips", {
timeout: 10_000,
}, async () => {
const { app } = makeApp({ principal, config: baseConfig() });
const res = await app.request("/vault/items", {
method: "POST",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "with-meta",
kind: "note",
body: "x",
metadata: { v: 1 },
}),
});
expect(res.status).toBe(201);
const json = (await res.json()) as {
data: { metadata: { v: number } };
};
expect(json.data.metadata.v).toBe(1);
});
it("PATCH supports kind / body / metadata permutations", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const handle = await service.openForPrincipal(principal, "p");
const created = await service.createItem(handle, {
title: "t",
kind: "k",
body: "b",
});
await service.close(handle);
// metadata only
const r1 = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ metadata: { x: 1 } }),
});
expect(r1.status).toBe(200);
// kind only
const r2 = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ kind: "new-kind" }),
});
expect(r2.status).toBe(200);
// body only
const r3 = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ body: "new-body" }),
});
expect(r3.status).toBe(200);
});
it("withOpenVault failure path on LIST forwards non-VaultPluginError", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
(service as { listItems: unknown }).listItems = async () => {
throw new Error("list-failure");
};
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const res = await app.request("/vault/items", {
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBeGreaterThanOrEqual(500);
});
it("withOpenVault failure path on read forwards instrument increment for non-VaultPluginError", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
// Override readItem to throw a plain Error so the catch block
// exercises the non-VaultPluginError branch.
(service as { readItem: unknown }).readItem = async () => {
throw new Error("non-vault-plugin-error");
};
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const res = await app.request("/vault/items/something", {
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBeGreaterThanOrEqual(500);
});
it("invalid POST body is caught by handler safeParse when bypassing auto-validator", {
timeout: 10_000,
}, async () => {
// Mount routes on a plain Hono — handlers run with explicit
// safeParse path which throws VAULT_PLUGIN.INVALID_INPUT.
const { Hono } = await import("hono");
const service = createFakeVaultService();
const config = baseConfig();
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
const entries = buildVaultRoutes(service, config);
for (const e of entries) {
const method = e.route.method as
| "get"
| "post"
| "patch"
| "delete";
const honoPath = e.route.path.replace(/\{(\w+)\}/gu, ":$1");
app[method](honoPath, async (c) => e.handler(c) as never);
}
app.onError((err, c) => {
return c.json(
{
success: false,
error: {
code: (err as { code?: string }).code ?? "X",
},
},
400
);
});
const res = await app.request("/vault/items", {
method: "POST",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.INVALID_INPUT);
});
it("invalid PATCH body via plain Hono throws INVALID_INPUT", {
timeout: 10_000,
}, async () => {
const { Hono } = await import("hono");
const service = createFakeVaultService();
const config = baseConfig();
const h = await service.openForPrincipal(principal, "p");
const created = await service.createItem(h, {
title: "t",
kind: "k",
body: "b",
});
await service.close(h);
const app = new Hono();
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
const entries = buildVaultRoutes(service, config);
for (const e of entries) {
const method = e.route.method as
| "get"
| "post"
| "patch"
| "delete";
const honoPath = e.route.path.replace(/\{(\w+)\}/gu, ":$1");
app[method](honoPath, async (c) => e.handler(c) as never);
}
app.onError((err, c) =>
c.json(
{
success: false,
error: { code: (err as { code?: string }).code ?? "X" },
},
400
)
);
const res = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ title: 0 }),
});
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.INVALID_INPUT);
});
it("PATCH /vault/items/:id with body field records size histogram", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const handle = await service.openForPrincipal(principal, "p");
const created = await service.createItem(handle, {
title: "t",
kind: "k",
body: "b",
});
await service.close(handle);
const res = await app.request(`/vault/items/${created.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ body: "new-payload" }),
});
expect(res.status).toBe(200);
});
it("GET /vault/items/:id returns 500 if a non-VaultPluginError leaks from the service", {
timeout: 10_000,
}, async () => {
const service = createFakeVaultService();
// Override readItem to throw a plain Error.
(service as { readItem: unknown }).readItem = async () => {
throw new Error("boom");
};
const { app } = makeApp({
principal,
config: baseConfig(),
fake: service,
});
const res = await app.request("/vault/items/something", {
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBeGreaterThanOrEqual(500);
});
});

View File

@@ -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);
}
}

View File

@@ -0,0 +1,81 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import {
createOpenAPIRegistry,
definePluginRoutes,
} from "@suaveplan/api-openapi";
import type { Principal } from "@suaveplan/api-plugin-auth";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createFakeVaultService } from "../../tests/setup/fake-vault-service.js";
import { createMemoryDrive } from "../../tests/setup/memory-drive.js";
import { VAULT_PLUGIN_CODES } from "../error-codes/error-codes.js";
import { defaultUnlockExtractor } from "../plugin/unlock.js";
import type { VaultPluginConfig } from "../types/types.js";
import { buildVaultRoutes } from "./build-routes.js";
function build(principal: Principal, maxBytes: number) {
const service = createFakeVaultService();
const config: VaultPluginConfig = {
storage: createMemoryDrive(),
routePrefix: "/vault",
maxItemSizeBytes: maxBytes,
applySchemaOnInit: false,
unlockExtractor: defaultUnlockExtractor,
serverPepper: "p",
};
const app = new OpenAPIHono();
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
definePluginRoutes(buildVaultRoutes(service, config), {
app,
registry,
pluginName: "vault",
});
app.onError((err, c) => {
const status =
(err as { context?: { statusCode?: number } }).context
?.statusCode ?? 500;
return c.json(
{
success: false,
error: {
code: (err as { code?: string }).code ?? "X",
message: (err as Error).message,
timestamp: new Date().toISOString(),
},
},
status as 400 | 413 | 500
);
});
return { app };
}
describe("routes/create", () => {
it("oversize body raises ITEM_TOO_LARGE", { timeout: 10_000 }, async () => {
const alice: Principal = {
id: "user:alice",
emailVerified: true,
roles: [],
};
const { app } = build(alice, 8);
const res = await app.request("/vault/items", {
method: "POST",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "t",
kind: "k",
body: "x".repeat(64),
}),
});
expect(res.status).toBe(413);
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.ITEM_TOO_LARGE);
});
});

View File

@@ -0,0 +1,82 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import {
createOpenAPIRegistry,
definePluginRoutes,
} from "@suaveplan/api-openapi";
import type { Principal } from "@suaveplan/api-plugin-auth";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createFakeVaultService } from "../../tests/setup/fake-vault-service.js";
import { createMemoryDrive } from "../../tests/setup/memory-drive.js";
import { defaultUnlockExtractor } from "../plugin/unlock.js";
import type { VaultPluginConfig } from "../types/types.js";
import { buildVaultRoutes } from "./build-routes.js";
function build(principal: Principal) {
const service = createFakeVaultService();
const config: VaultPluginConfig = {
storage: createMemoryDrive(),
routePrefix: "/vault",
maxItemSizeBytes: 10 * 1024 * 1024,
applySchemaOnInit: false,
unlockExtractor: defaultUnlockExtractor,
serverPepper: "p",
};
const app = new OpenAPIHono();
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
definePluginRoutes(buildVaultRoutes(service, config), {
app,
registry,
pluginName: "vault",
});
app.onError((err, c) => {
const status =
(err as { context?: { statusCode?: number } }).context
?.statusCode ?? 500;
return c.json(
{
success: false,
error: {
code: (err as { code?: string }).code ?? "X",
message: (err as Error).message,
timestamp: new Date().toISOString(),
},
},
status as 404 | 500
);
});
return { app, service };
}
describe("routes/delete", () => {
it("foreign item returns 404", { timeout: 10_000 }, async () => {
const alice: Principal = {
id: "user:alice",
emailVerified: true,
roles: [],
};
const bob: Principal = {
id: "user:bob",
emailVerified: true,
roles: [],
};
const { app, service } = build(alice);
const bh = await service.openForPrincipal(bob, "p");
const item = await service.createItem(bh, {
title: "b",
kind: "k",
body: "b",
});
await service.close(bh);
const res = await app.request(`/vault/items/${item.id}`, {
method: "DELETE",
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBe(404);
});
});

View File

@@ -0,0 +1,84 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import {
createOpenAPIRegistry,
definePluginRoutes,
} from "@suaveplan/api-openapi";
import type { Principal } from "@suaveplan/api-plugin-auth";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createFakeVaultService } from "../../tests/setup/fake-vault-service.js";
import { createMemoryDrive } from "../../tests/setup/memory-drive.js";
import { VAULT_PLUGIN_CODES } from "../error-codes/error-codes.js";
import { defaultUnlockExtractor } from "../plugin/unlock.js";
import type { VaultPluginConfig } from "../types/types.js";
import { buildVaultRoutes } from "./build-routes.js";
function build(principal: Principal) {
const service = createFakeVaultService();
const config: VaultPluginConfig = {
storage: createMemoryDrive(),
routePrefix: "/vault",
maxItemSizeBytes: 10 * 1024 * 1024,
applySchemaOnInit: false,
unlockExtractor: defaultUnlockExtractor,
serverPepper: "p",
};
const app = new OpenAPIHono();
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
definePluginRoutes(buildVaultRoutes(service, config), {
app,
registry,
pluginName: "vault",
});
app.onError((err, c) => {
const status =
(err as { context?: { statusCode?: number } }).context
?.statusCode ?? 500;
return c.json(
{
success: false,
error: {
code: (err as { code?: string }).code ?? "X",
message: (err as Error).message,
timestamp: new Date().toISOString(),
},
},
status as 404 | 500
);
});
return { app, service };
}
describe("routes/get", () => {
it("foreign item returns ITEM_NOT_FOUND", { timeout: 10_000 }, async () => {
const alice: Principal = {
id: "user:alice",
emailVerified: true,
roles: [],
};
const bob: Principal = {
id: "user:bob",
emailVerified: true,
roles: [],
};
const { app, service } = build(alice);
const bh = await service.openForPrincipal(bob, "p");
const item = await service.createItem(bh, {
title: "secret",
kind: "k",
body: "b",
});
await service.close(bh);
const res = await app.request(`/vault/items/${item.id}`, {
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBe(404);
const json = (await res.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.ITEM_NOT_FOUND);
});
});

View File

@@ -0,0 +1 @@
export { buildVaultRoutes } from "./build-routes.js";

View File

@@ -0,0 +1,86 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import {
createOpenAPIRegistry,
definePluginRoutes,
} from "@suaveplan/api-openapi";
import type { Principal } from "@suaveplan/api-plugin-auth";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createFakeVaultService } from "../../tests/setup/fake-vault-service.js";
import { createMemoryDrive } from "../../tests/setup/memory-drive.js";
import { defaultUnlockExtractor } from "../plugin/unlock.js";
import type { VaultPluginConfig } from "../types/types.js";
import { buildVaultRoutes } from "./build-routes.js";
function build(principal: Principal | null) {
const service = createFakeVaultService();
const config: VaultPluginConfig = {
storage: createMemoryDrive(),
routePrefix: "/vault",
maxItemSizeBytes: 10 * 1024 * 1024,
applySchemaOnInit: false,
unlockExtractor: defaultUnlockExtractor,
serverPepper: "p",
};
const app = new OpenAPIHono();
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
const entries = buildVaultRoutes(service, config);
definePluginRoutes(entries, { app, registry, pluginName: "vault" });
app.onError((err, c) => {
const status =
(err as { context?: { statusCode?: number } }).context
?.statusCode ?? 500;
return c.json(
{
success: false,
error: {
code: (err as { code?: string }).code ?? "X",
message: (err as Error).message,
timestamp: new Date().toISOString(),
},
},
status as 400 | 401 | 404 | 500
);
});
return { app, service };
}
describe("routes/list", () => {
it("returns only principal's items", { timeout: 10_000 }, async () => {
const alice: Principal = {
id: "user:alice",
emailVerified: true,
roles: [],
};
const bob: Principal = {
id: "user:bob",
emailVerified: true,
roles: [],
};
const { app, service } = build(alice);
const ah = await service.openForPrincipal(alice, "p");
await service.createItem(ah, { title: "a1", kind: "k", body: "1" });
await service.createItem(ah, { title: "a2", kind: "k", body: "2" });
await service.createItem(ah, { title: "a3", kind: "k", body: "3" });
await service.close(ah);
const bh = await service.openForPrincipal(bob, "p");
await service.createItem(bh, { title: "b1", kind: "k", body: "1" });
await service.createItem(bh, { title: "b2", kind: "k", body: "2" });
await service.close(bh);
const res = await app.request("/vault/items", {
headers: { "X-Vault-Unlock": "secret" },
});
expect(res.status).toBe(200);
const json = (await res.json()) as {
data: Array<{ ownerId: string }>;
meta: { total: number };
};
expect(json.meta.total).toBe(3);
for (const i of json.data) expect(i.ownerId).toBe("user:alice");
});
});

View File

@@ -0,0 +1,81 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import {
createOpenAPIRegistry,
definePluginRoutes,
} from "@suaveplan/api-openapi";
import type { Principal } from "@suaveplan/api-plugin-auth";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createFakeVaultService } from "../../tests/setup/fake-vault-service.js";
import { createMemoryDrive } from "../../tests/setup/memory-drive.js";
import { defaultUnlockExtractor } from "../plugin/unlock.js";
import type { VaultPluginConfig } from "../types/types.js";
import { buildVaultRoutes } from "./build-routes.js";
function build(principal: Principal) {
const service = createFakeVaultService();
const config: VaultPluginConfig = {
storage: createMemoryDrive(),
routePrefix: "/vault",
maxItemSizeBytes: 10 * 1024 * 1024,
applySchemaOnInit: false,
unlockExtractor: defaultUnlockExtractor,
serverPepper: "p",
};
const app = new OpenAPIHono();
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
definePluginRoutes(buildVaultRoutes(service, config), {
app,
registry,
pluginName: "vault",
});
app.onError((err, c) => {
const status =
(err as { context?: { statusCode?: number } }).context
?.statusCode ?? 500;
return c.json(
{
success: false,
error: {
code: (err as { code?: string }).code ?? "X",
message: (err as Error).message,
timestamp: new Date().toISOString(),
},
},
status as 400 | 500
);
});
return { app, service };
}
describe("routes/patch", () => {
it("rejects unknown fields", { timeout: 10_000 }, async () => {
const alice: Principal = {
id: "user:alice",
emailVerified: true,
roles: [],
};
const { app, service } = build(alice);
const h = await service.openForPrincipal(alice, "p");
const item = await service.createItem(h, {
title: "t",
kind: "k",
body: "b",
});
await service.close(h);
const res = await app.request(`/vault/items/${item.id}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ title: "X", evil: "Y" }),
});
expect(res.status).toBe(400);
});
});