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,166 @@
# @suaveplan/api-plugin-vault - Per-user vault HTTP plugin for @suaveplan/api. Wraps @suaveplan/vault as a @suaveplan/api-plugin definePlugin descriptor, applies a SurrealQL ownership schema (vault_item + vault_share) at boot via @suaveplan/api-migrations, mounts 5 OpenAPI routes through @suaveplan/api-openapi (CRUD over /vault/items), registers a typed vaultService in the host ServiceRegistry, and scopes every operation to c.get('user').id supplied by @suaveplan/api-plugin-auth. Third instance of the 3-way plug-and-play contract (.surql DDL + Zod boundary schemas + OpenAPI route registration).
Per-user vault HTTP plugin for `@suaveplan/api`. Third instance of the
3-way plug-and-play contract every `@suaveplan/api-plugin-*` package
follows: the plugin owns its `.surql` DDL, its Zod boundary schemas,
and its OpenAPI route definitions — all wired into the host's
`PluginRegistry`, `MigrationRunner`, and `OpenAPIRegistry` from a
single `definePlugin` descriptor. Wraps `@suaveplan/vault` for the
encrypted container and scopes every operation to `c.get("user").id`
supplied by `@suaveplan/api-plugin-auth`.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Stable enum of the 9 error codes raised by `@suaveplan/api-plugin-vault`. Codes are namespaced under the `VAULT_PLUGIN.…
- [error-codes](./src/error-codes/error-codes.md) — Stable enum of the 9 error codes raised by `@suaveplan/api-plugin-vault`. Codes are namespaced under the `VAULT_PLUGIN.…
- [otel](./src/otel/instruments.md) — OpenTelemetry instrument bundle for the vault plugin: four counters and one histogram covering the full CRUD surface. E…
- [instruments](./src/otel/instruments.md) — OpenTelemetry instrument bundle for the vault plugin: four counters and one histogram covering the full CRUD surface. E…
- [plugin](./src/plugin/plugin.md) — `createVaultPlugin(options)` is the factory that returns a typed `PluginDescriptor<VaultPluginConfig>` ready to registe…
- [plugin](./src/plugin/plugin.md) — `createVaultPlugin(options)` is the factory that returns a typed `PluginDescriptor<VaultPluginConfig>` ready to registe…
- [unlock](./src/plugin/unlock.md) — Tiny helper module that owns the "where does the vault unlock secret come from?" question. The plugin's default `unlock…
- [routes](./src/routes/build-routes.md) — Builds the 5 OpenAPI route entries that `createVaultPlugin` mounts on the host's Hono app: list, create, read, update,…
- [build-routes](./src/routes/build-routes.md) — Builds the 5 OpenAPI route entries that `createVaultPlugin` mounts on the host's Hono app: list, create, read, update,…
- [schema](./src/schema/schema.md) — Owns the SurrealQL DDL the vault plugin applies during `onInit` — the first leg of the 3-way plug-and-play contract. `V…
- [schema](./src/schema/schema.md) — Owns the SurrealQL DDL the vault plugin applies during `onInit` — the first leg of the 3-way plug-and-play contract. `V…
- [options](./src/schemas/options.md) — Zod schema validating the input options to `createVaultPlugin`. Every field has a Zod-derived default (or `optional()`)…
- [vault-item](./src/schemas/vault-item.md) — Zod schemas describing the wire shape of every body, parameter, and response handled by the 5 vault routes. Every schem…
- [registry-augmentation](./src/services/registry-augmentation.md) — Side-effect module that augments `@suaveplan/api-plugin`'s `ServiceRegistry` interface with the `vaultService` slot. Wi…
- [vault-service](./src/services/vault-service.md) — Canonical `VaultService` implementation registered by `createVaultPlugin` in the host's `ServiceRegistry` under the `va…
- [types](./src/types/types.md) — Public TypeScript surface for `@suaveplan/api-plugin-vault`. Every interface here is consumed by sibling modules (plugi…
- [types](./src/types/types.md) — Public TypeScript surface for `@suaveplan/api-plugin-vault`. Every interface here is consumed by sibling modules (plugi…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-plugin-vault
```
## Quick start
```ts
import { defineApp } from "@suaveplan/api";
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
import { createVaultPlugin } from "@suaveplan/api-plugin-vault";
const driveAdapter = /* @suaveplan/storage-s3-backed @suaveplan/drive, etc */;
const app = await defineApp()
.name("my-api")
.use(
createAuthPlugin({
betterAuth: {
/* ... Better-Auth options ... */
},
})
)
.use(
createVaultPlugin({
storage: driveAdapter,
serverPepper: process.env["VAULT_SERVER_PEPPER"]!,
})
)
.start();
```
`createVaultPlugin` returns a typed `PluginDescriptor` declaring
`dependencies: ["auth"]`. The host's `PluginRegistry` calls `onInit(ctx)`
which:
1. Resolves `app`, `openAPIRegistry`, `migrationRunner`, and `db` from
the host `ServiceRegistry`.
2. Applies `VAULT_OWNERSHIP_SCHEMA` (the `vault_item` + `vault_share`
tables plus their indexes).
3. Constructs the `vaultService` backed by the configured `storage` +
`serverPepper`.
4. Registers `vaultService` in the typed `ServiceRegistry`.
5. Mounts 5 OpenAPI routes via `definePluginRoutes` so they appear in
the unified `/openapi.json` doc with full Zod schemas — every route
is gated by `requireAuth()`.
## The 3-way plug-and-play contract
| Surface | Implementation |
|---|---|
| `.surql` DDL | `VAULT_OWNERSHIP_SCHEMA` defines `vault_item` and `vault_share` plus three indexes; applied via `MigrationRunner` (preferred) or direct `db.execute` fallback |
| Zod schemas | `CreateVaultItemBodySchema`, `UpdateVaultItemBodySchema` (strict), `VaultItemResponseSchema`, `VaultItemListResponseSchema`, etc. |
| OpenAPI routes | `buildVaultRoutes(vaultService, config)` returns 5 `RouteBuilder` entries; `definePluginRoutes` writes both Hono app AND `OpenAPIRegistry` |
## Routes
| Method | Path | Description |
|---|---|---|
| `GET` | `/vault/items` | List items owned by the principal |
| `POST` | `/vault/items` | Create a new item |
| `GET` | `/vault/items/{id}` | Fetch a single item |
| `PATCH` | `/vault/items/{id}` | Update fields (strict-mode body) |
| `DELETE` | `/vault/items/{id}` | Remove an item |
Every route requires `requireAuth()` upstream. Foreign-item references
return `404 VAULT_PLUGIN_ITEM_NOT_FOUND` (NEVER 403) — see the
"Cross-user isolation" section.
## X-Vault-Unlock header semantics
The plugin's default `unlockExtractor` reads the per-request unlock
secret from the `X-Vault-Unlock` header. The secret is forwarded to
`@suaveplan/vault`'s 2SKD pipeline along with the principal's id (used
as the Bitwarden-style "secret key") and a HKDF-derived per-principal
salt. The plugin NEVER logs the secret and NEVER persists it.
Alternative extractors (operators wire one via the `unlockExtractor`
option):
- **JWT-bound** — extract a claim from a JWT in the `Authorization`
header. Useful when the client already holds a session-bound JWT
with vault-key claims.
- **Cookie-bound** — extract from a session cookie set during sign-in.
- **Derived from password** — use the principal's authentication
password (already submitted at sign-in) as the unlock secret; the
caller does not need to send a second header at all. This requires
the auth plugin to expose the post-auth password to the request
context.
## Cross-user isolation (security note)
Items owned by another principal return `404 VAULT_PLUGIN_ITEM_NOT_FOUND`
— never `403`. Surfacing `403` would let an attacker enumerate item
ids that exist for other principals (a known weakness in many CRUD
plugins). The plugin's behaviour is deliberate; never override this
in a wrapper without auditing the consequences.
## Schema application
`VAULT_OWNERSHIP_SCHEMA` defines two tables. The `vault_item` table is
the active surface; `vault_share` is forward-declared so the future
`api-plugin-vault-sharing` follow-up can register routes without
requiring a database migration.
Operators can:
- accept the default (`applySchemaOnInit: true`) — schema applies on
every cold start, idempotently;
- set `applySchemaOnInit: false` to manage migrations externally and
apply `VAULT_OWNERSHIP_SCHEMA` via the operator's own runner.
## Modules
- [`error-codes/error-codes`](./src/error-codes/error-codes.md) — 9
stable `VAULT_PLUGIN_*` codes.
- [`otel/instruments`](./src/otel/instruments.md) — 4 counters + 1
histogram.
- [`plugin/plugin`](./src/plugin/plugin.md) — `createVaultPlugin`
factory.
- [`plugin/unlock`](./src/plugin/unlock.md) — header-based extractor.
- [`routes/build-routes`](./src/routes/build-routes.md) — the 5
OpenAPI route entries.
- [`schemas/options`](./src/schemas/options.md) — boundary schema.
- [`schemas/vault-item`](./src/schemas/vault-item.md) — wire schemas.
- [`schema/schema`](./src/schema/schema.md) — SurrealQL DDL.
- [`services/vault-service`](./src/services/vault-service.md) —
`vaultService` impl wrapping `@suaveplan/vault`.
- [`services/registry-augmentation`](./src/services/registry-augmentation.md)
— typed `ServiceRegistry` slot.
- [`types/types`](./src/types/types.md) — public TS surface.

View File

@@ -0,0 +1,127 @@
{
"name": "@suaveplan/api-plugin-vault",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Per-user vault HTTP plugin for @suaveplan/api. Wraps @suaveplan/vault as a @suaveplan/api-plugin definePlugin descriptor, applies a SurrealQL ownership schema (vault_item + vault_share) at boot via @suaveplan/api-migrations, mounts 5 OpenAPI routes through @suaveplan/api-openapi (CRUD over /vault/items), registers a typed vaultService in the host ServiceRegistry, and scopes every operation to c.get('user').id supplied by @suaveplan/api-plugin-auth. Third instance of the 3-way plug-and-play contract (.surql DDL + Zod boundary schemas + OpenAPI route registration).",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./plugin/plugin": {
"import": {
"types": "./dist/plugin/plugin.d.ts",
"default": "./dist/plugin/plugin.js"
}
},
"./routes/routes": {
"import": {
"types": "./dist/routes/build-routes.d.ts",
"default": "./dist/routes/build-routes.js"
}
},
"./services/vault-service": {
"import": {
"types": "./dist/services/vault-service.d.ts",
"default": "./dist/services/vault-service.js"
}
},
"./schemas/vault-item": {
"import": {
"types": "./dist/schemas/vault-item.d.ts",
"default": "./dist/schemas/vault-item.js"
}
},
"./schemas/options": {
"import": {
"types": "./dist/schemas/options.d.ts",
"default": "./dist/schemas/options.js"
}
},
"./schema/schema": {
"import": {
"types": "./dist/schema/schema.d.ts",
"default": "./dist/schema/schema.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./otel/instruments": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./types/types": {
"import": {
"types": "./dist/types/types.d.ts",
"default": "./dist/types/types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@hono/zod-openapi": "catalog:server",
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-migrations": "workspace:^",
"@suaveplan/api-openapi": "workspace:^",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/api-plugin-auth": "workspace:^",
"@suaveplan/crypto-vault": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/surrealdb": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/vault": "workspace:^",
"hono": "catalog:",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/better-auth-surrealdb": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-integration": "workspace:^",
"@suaveplan/testing-integration-surrealdb": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,252 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
import { EventBus } from "@suaveplan/events";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { Hono } from "hono";
import { createFakeVaultService } from "../tests/setup/fake-vault-service.js";
import { createMemoryDrive } from "../tests/setup/memory-drive.js";
import {
VAULT_PLUGIN_CODES,
VaultPluginError,
type VaultPluginErrorCode,
} from "./error-codes/error-codes.js";
import { createVaultPlugin } from "./plugin/plugin.js";
import { defaultUnlockExtractor, extractUnlock } from "./plugin/unlock.js";
import { buildVaultRoutes } from "./routes/build-routes.js";
import type { VaultService } from "./services/vault-service.js";
import { createVaultService } from "./services/vault-service.js";
import type { VaultHandle, VaultPluginConfig } from "./types/types.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
describe("api-plugin-vault spec conformance", () => {
it("every VAULT_PLUGIN_* code is thrown from a production path", {
timeout: 60_000,
}, async () => {
const observed = new Set<VaultPluginErrorCode>();
// INVALID_INPUT
try {
createVaultPlugin({} as never);
} catch (err) {
if (err instanceof VaultPluginError)
observed.add(err.vaultPluginCode);
}
// UNLOCK_HEADER_MISSING
try {
extractUnlock(
{ req: { header: () => undefined } } as never,
defaultUnlockExtractor
);
} catch (err) {
if (err instanceof VaultPluginError)
observed.add(err.vaultPluginCode);
}
// SERVICE_NOT_AVAILABLE
{
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: false,
});
try {
await plugin.onInit?.(
createPluginContext<VaultPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as VaultPluginConfig,
services: new Map(),
})
);
} catch (err) {
if (err instanceof VaultPluginError)
observed.add(err.vaultPluginCode);
}
}
// SCHEMA_APPLY_FAILED
{
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: true,
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
services.set("migrationRunner", {
async run() {
throw new Error("runner exploded");
},
});
try {
await plugin.onInit?.(
createPluginContext<VaultPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as VaultPluginConfig,
services,
})
);
} catch (err) {
if (err instanceof VaultPluginError)
observed.add(err.vaultPluginCode);
}
}
// ROUTE_PREFIX_COLLISION
{
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: false,
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
registry.registerRoute({
method: "get",
path: "/vault/preexisting",
tags: [],
pluginName: "other",
});
services.set("openAPIRegistry", registry);
try {
await plugin.onInit?.(
createPluginContext<VaultPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as VaultPluginConfig,
services,
})
);
} catch (err) {
if (err instanceof VaultPluginError)
observed.add(err.vaultPluginCode);
}
}
// UNLOCK_FAILED — real vault rejects wrong password.
{
const svc = createVaultService({
storage: createMemoryDrive(),
serverPepper: "p",
logger: noopLogger,
});
const principal = {
id: "user:x",
emailVerified: true,
roles: [],
};
const handle = await svc.openForPrincipal(principal, "right");
await svc.close(handle);
try {
await svc.openForPrincipal(principal, "wrong");
} catch (err) {
if (err instanceof VaultPluginError)
observed.add(err.vaultPluginCode);
}
}
// FORBIDDEN — operating on closed handle (real production path)
{
const svc = createVaultService({
storage: createMemoryDrive(),
serverPepper: "p",
logger: noopLogger,
});
const handle = await svc.openForPrincipal(
{ id: "user:y", emailVerified: true, roles: [] },
"ok"
);
await svc.close(handle);
try {
await svc.listItems(handle);
} catch (err) {
if (err instanceof VaultPluginError)
observed.add(err.vaultPluginCode);
}
}
// ITEM_NOT_FOUND + ITEM_TOO_LARGE — exercised by GET/POST routes.
{
const fake = createFakeVaultService();
const config: VaultPluginConfig = {
storage: createMemoryDrive(),
routePrefix: "/vault",
maxItemSizeBytes: 4,
applySchemaOnInit: false,
unlockExtractor: defaultUnlockExtractor,
serverPepper: "p",
};
const app = new Hono();
const principal = {
id: "user:z",
emailVerified: true,
roles: [],
};
app.use("*", async (c, next) => {
c.set("user", principal);
await next();
});
const entries = buildVaultRoutes(fake, config);
for (const e of entries) {
const method = e.route.method as
| "get"
| "post"
| "patch"
| "delete";
// Convert OpenAPI `{id}` to Hono `:id` for plain mounting.
const honoPath = e.route.path.replace(/\{(\w+)\}/gu, ":$1");
app[method](honoPath, async (c) => e.handler(c) as never);
}
app.onError((err, c) => {
if (err instanceof VaultPluginError)
observed.add(err.vaultPluginCode);
return c.text("err", 500);
});
await app.request("/vault/items/no-such-id", {
headers: { "X-Vault-Unlock": "secret" },
});
await app.request("/vault/items", {
method: "POST",
headers: {
"X-Vault-Unlock": "secret",
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "t",
kind: "k",
body: "longer-than-cap",
}),
});
}
const expected = new Set(Object.values(VAULT_PLUGIN_CODES));
const missing = [...expected].filter((c) => !observed.has(c));
expect(missing).toEqual([]);
});
it("ServiceRegistry slot typed as VaultService", { timeout: 5000 }, () => {
expectTypeOf<
ServiceRegistry["vaultService"]
>().toEqualTypeOf<VaultService>();
});
it("VaultHandle is opaque — type carries only principalId", {
timeout: 5000,
}, () => {
expectTypeOf<VaultHandle>().toHaveProperty("principalId");
expectTypeOf<VaultHandle["principalId"]>().toEqualTypeOf<string>();
});
});

View File

@@ -0,0 +1,97 @@
# error-codes/error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable enum of the 9 error codes raised by `@suaveplan/api-plugin-vault`.
Codes are namespaced under the `VAULT_PLUGIN.*` prefix and registered with
`defineCodes` from `@suaveplan/error` so the global registry can resolve
them for OTel span events, i18n, and the `/openapi.json` error-response
shape. Every failure path inside the plugin throws a `VaultPluginError`
carrying one of these codes; consumers (Hono error-handler middleware,
test assertions, OpenTelemetry instrumentation) branch on
`error.code === VAULT_PLUGIN_CODES.ITEM_NOT_FOUND` rather than parsing
human-readable messages.
## Features
- Frozen literal-union type `VaultPluginErrorCode` inferred from the
`defineCodes` call site — adding a new code is a compile-time change.
- `VaultPluginError` extends `SuaveplanError` with a typed `code`
narrowing and a public constructor for explicit construction at call
sites.
- Every code is exercised by at least one production path. The
spec-conformance test (`src/api-plugin-vault.spec-conformance.test.ts`)
asserts each code is reachable from a real call site.
- Status-code mapping is carried in `error.context.statusCode` so the
host's error-handler middleware translates 401/403/404/413/500
uniformly across plugins.
## Usage: Basic
```ts
import {
VAULT_PLUGIN_CODES,
VaultPluginError,
} from "@suaveplan/api-plugin-vault";
throw new VaultPluginError(
VAULT_PLUGIN_CODES.ITEM_NOT_FOUND,
"vault item not found",
{ context: { id: itemId, statusCode: 404 } }
);
```
## Usage: Advanced
```ts
try {
await vaultService.openForPrincipal(principal, password);
} catch (err) {
if (err instanceof VaultPluginError) {
if (err.vaultPluginCode === VAULT_PLUGIN_CODES.UNLOCK_FAILED) {
// Per-principal lockout / brute-force defence
await rateLimiter.flag(principal.id);
}
}
throw err;
}
```
## API Reference
### `VAULT_PLUGIN_CODES`
Frozen record produced by `defineCodes("VAULT_PLUGIN", […])`. Entries:
`INVALID_INPUT`, `ITEM_NOT_FOUND`, `FORBIDDEN`, `UNLOCK_FAILED`,
`ITEM_TOO_LARGE`, `SERVICE_NOT_AVAILABLE`, `SCHEMA_APPLY_FAILED`,
`ROUTE_PREFIX_COLLISION`, `UNLOCK_HEADER_MISSING`. Every value is a
literal string of form `VAULT_PLUGIN_<NAME>`.
### `VaultPluginErrorCode`
Literal-union type narrowed from `VAULT_PLUGIN_CODES`. Use this when
typing a discriminated handler.
### `VaultPluginError extends SuaveplanError<VaultPluginErrorCode>`
Throwable error class. Construct with `(code, message, options?)`. The
`options.context` field is forwarded to the base class for OTel span
events and i18n. `error.vaultPluginCode` is the narrowed code (also
available via `error.code`).
## Implementation Notes
- The anti-enumeration policy is baked in: `ITEM_NOT_FOUND` is the only
code raised when a principal references an item id they do not own.
Surfacing `FORBIDDEN` here would let an attacker probe the existence
of arbitrary item ids — a known weakness in many CRUD plugins.
- `FORBIDDEN` is kept distinct so plugin extensions (sharing,
attachments) raise it when an explicit ACL rejection happens against
a record the caller IS allowed to see.
- `UNLOCK_HEADER_MISSING` returns 400, not 401. The principal IS
authenticated; they merely failed to supply the secondary unlock
factor.

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_PLUGIN_CODES, VaultPluginError } from "./error-codes.js";
describe("error-codes/error-codes", () => {
it("registers exactly 9 codes prefixed with VAULT_PLUGIN_", {
timeout: 5000,
}, () => {
const codes = Object.values(VAULT_PLUGIN_CODES);
expect(codes.length).toBe(9);
for (const c of codes) expect(c.startsWith("VAULT_PLUGIN.")).toBe(true);
});
it("VaultPluginError narrows code via vaultPluginCode", {
timeout: 5000,
}, () => {
const err = new VaultPluginError(
VAULT_PLUGIN_CODES.ITEM_NOT_FOUND,
"missing"
);
expect(err).toBeInstanceOf(VaultPluginError);
expect(err.vaultPluginCode).toBe(VAULT_PLUGIN_CODES.ITEM_NOT_FOUND);
expect(err.code).toBe(VAULT_PLUGIN_CODES.ITEM_NOT_FOUND);
expect(err.message).toBe("missing");
});
it("forwards cause + context to the base SuaveplanError", {
timeout: 5000,
}, () => {
const underlying = new Error("upstream");
const err = new VaultPluginError(
VAULT_PLUGIN_CODES.SCHEMA_APPLY_FAILED,
"boom",
{ cause: underlying, context: { statusCode: 500 } }
);
expect(err.cause).toBe(underlying);
expect(err.context?.["statusCode"]).toBe(500);
});
});

View File

@@ -0,0 +1,70 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-plugin-vault`.
*
* Every failure path in the vault plugin raises a {@link VaultPluginError}
* carrying one of these 9 `VAULT_PLUGIN.*` codes. Consumers branch on the
* string value (e.g. `error.code === VAULT_PLUGIN_CODES.ITEM_NOT_FOUND`).
*
* - `INVALID_INPUT` — `createVaultPlugin(options)` received options that
* failed `VaultPluginOptionsSchema` validation. `cause` holds the
* `ZodError`.
* - `ITEM_NOT_FOUND` — the requested item id does not exist OR is owned
* by a different principal. (We DO NOT distinguish; surfacing
* ownership as 403 would let an attacker enumerate ids that exist for
* other users.)
* - `FORBIDDEN` — reserved for explicit ownership-policy failures the
* plugin's middleware chain may raise in the future (e.g. shared-link
* ACL rejection). Currently raised by the `vaultService` when a caller
* attempts to mutate via the service layer outside an open vault.
* - `UNLOCK_FAILED` — the principal's password failed to unlock the
* underlying `@suaveplan/vault` instance (wrong password, corrupted
* container).
* - `ITEM_TOO_LARGE` — the encoded body of an item write exceeded
* `options.maxItemSizeBytes`.
* - `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot was
* absent at `onInit`. `context.missing` lists the missing slot names.
* - `SCHEMA_APPLY_FAILED` — the bundled SurrealQL schema apply step
* threw (`migrationRunner.run` or the fallback `db.execute` rejected).
* - `ROUTE_PREFIX_COLLISION` — the configured `routePrefix` collides
* with routes already registered by another plugin in the host's
* `OpenAPIRegistry`.
* - `UNLOCK_HEADER_MISSING` — the `unlockExtractor` returned `null` for
* a route that requires the unlock secret. The default extractor reads
* `X-Vault-Unlock`.
*/
export const VAULT_PLUGIN_CODES = defineCodes("VAULT_PLUGIN", [
"INVALID_INPUT",
"ITEM_NOT_FOUND",
"FORBIDDEN",
"UNLOCK_FAILED",
"ITEM_TOO_LARGE",
"SERVICE_NOT_AVAILABLE",
"SCHEMA_APPLY_FAILED",
"ROUTE_PREFIX_COLLISION",
"UNLOCK_HEADER_MISSING",
] as const);
export type VaultPluginErrorCode =
(typeof VAULT_PLUGIN_CODES)[keyof typeof VAULT_PLUGIN_CODES];
/**
* Concrete error class for the api-plugin-vault package. Extends
* {@link SuaveplanError} so `instanceof VaultPluginError` narrows
* `code` to {@link VaultPluginErrorCode}. Constructor is `public` so
* callers may construct errors directly.
*/
export class VaultPluginError extends SuaveplanError<VaultPluginErrorCode> {
public readonly vaultPluginCode: VaultPluginErrorCode;
public constructor(
code: VaultPluginErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.vaultPluginCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export type { VaultPluginErrorCode } from "./error-codes.js";
export {
VAULT_PLUGIN_CODES,
VaultPluginError,
} from "./error-codes.js";

View File

@@ -0,0 +1,79 @@
// Side-effect imports — surface the registry augmentation to every
// consumer importing the package barrel.
import "./services/registry-augmentation.js";
// Error codes
export {
VAULT_PLUGIN_CODES,
VaultPluginError,
type VaultPluginErrorCode,
} from "./error-codes/error-codes.js";
// OTel instruments
export {
getVaultPluginInstruments,
resetVaultPluginInstruments,
type VaultPluginInstruments,
} from "./otel/instruments.js";
// Plugin
export { createVaultPlugin } from "./plugin/plugin.js";
// Unlock helpers
export {
defaultUnlockExtractor,
extractUnlock,
headerExtractor,
} from "./plugin/unlock.js";
// Routes
export { buildVaultRoutes } from "./routes/build-routes.js";
// Schema (DDL)
export {
applyVaultSchema,
applyVaultSchemaViaRunner,
type SurrealLikeExecutor,
VAULT_OWNERSHIP_SCHEMA,
} from "./schema/schema.js";
// Schemas
export {
type VaultPluginOptionsInput,
type VaultPluginOptionsResolved,
VaultPluginOptionsSchema,
} from "./schemas/options.js";
export {
type CreateVaultItemBody,
CreateVaultItemBodySchema,
DeleteVaultItemResponseSchema,
type DeleteVaultItemResponseType,
type UpdateVaultItemBody,
UpdateVaultItemBodySchema,
VaultItemListResponseSchema,
type VaultItemListResponseType,
VaultItemMetadataSchema,
type VaultItemParams,
VaultItemParamsSchema,
VaultItemResponseSchema,
type VaultItemResponseType,
VaultItemSchema,
VaultItemSummarySchema,
type VaultItemSummaryType,
type VaultItemType,
} from "./schemas/vault-item.js";
// Services
export {
type CreateVaultServiceOptions,
createVaultService,
type VaultService,
} from "./services/vault-service.js";
// Types
export type {
VaultHandle,
VaultItem,
VaultItemSummary,
VaultPluginConfig,
VaultPluginOptions,
VaultUnlockExtractor,
} from "./types/types.js";

View File

@@ -0,0 +1,5 @@
export {
getVaultPluginInstruments,
resetVaultPluginInstruments,
type VaultPluginInstruments,
} from "./instruments.js";

View File

@@ -0,0 +1,107 @@
# otel/instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry instrument bundle for the vault plugin: four counters and
one histogram covering the full CRUD surface. Every route handler reads
the cached bundle at the start of the call, records the metric on
success or failure, and forwards through to the next layer. Cached
binding ensures repeated handler calls do not redundantly call
`metrics.getMeter(...)` on every request.
## Features
- `vault.items.created_total{outcome}` — counter incremented on every
`POST /vault/items`. `outcome ∈ "success" | "failure"`.
- `vault.items.read_total{outcome}` — counter incremented on every
`GET /vault/items` or `GET /vault/items/:id`.
- `vault.items.updated_total{outcome}` — counter incremented on every
`PATCH /vault/items/:id`.
- `vault.items.deleted_total{outcome}` — counter incremented on every
`DELETE /vault/items/:id`.
- `vault.item.size_bytes` — histogram of item body byte sizes,
sampled on successful create + update. Unit `By` (bytes).
- `resetVaultPluginInstruments()` clears the cache so tests can install
a fresh `MeterProvider` between cases.
## Usage: Basic
```ts
import { getVaultPluginInstruments } from "@suaveplan/api-plugin-vault";
const instruments = getVaultPluginInstruments();
instruments.itemsCreatedTotal.add(1, { outcome: "success" });
instruments.itemSizeBytes.record(body.length);
```
## Usage: Advanced
```ts
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { metrics } from "@opentelemetry/api";
import {
getVaultPluginInstruments,
resetVaultPluginInstruments,
} from "@suaveplan/api-plugin-vault";
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 60_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetVaultPluginInstruments();
const instruments = getVaultPluginInstruments();
instruments.itemsCreatedTotal.add(1, { outcome: "success" });
await provider.forceFlush();
const points = exporter.getMetrics();
```
## API Reference
### `VaultPluginInstruments`
Readonly record:
- `itemsCreatedTotal: Counter`
- `itemsReadTotal: Counter`
- `itemsUpdatedTotal: Counter`
- `itemsDeletedTotal: Counter`
- `itemSizeBytes: Histogram`
### `getVaultPluginInstruments(): VaultPluginInstruments`
Returns the cached bundle, creating it lazily against the current
global `MeterProvider`. Safe to call from hot paths — subsequent calls
return the cached reference.
### `resetVaultPluginInstruments(): void`
Drops the cached bundle. Used in test setup to re-bind against a
freshly installed provider.
## Implementation Notes
- The meter name is `@suaveplan/api-plugin-vault` — match this when
configuring an exporter filter to isolate vault-plugin telemetry.
- The histogram's `unit: "By"` (OTel canonical for bytes) is required
for downstream tooling to recognise it as a size distribution rather
than a duration. Operators visualising in Grafana SHOULD set the
panel unit accordingly.
- The `outcome` label is the only attribute emitted with the counters
— kept low-cardinality on purpose. Per-tenant or per-route
partitioning happens at the OTel-collector layer, not at the
instrument site.

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
getVaultPluginInstruments,
resetVaultPluginInstruments,
} from "./instruments.js";
beforeEach(() => {
resetVaultPluginInstruments();
});
describe("otel/instruments", () => {
it("records all four counters + the histogram against a real MeterProvider", {
timeout: 10_000,
}, async () => {
const i = getVaultPluginInstruments();
i.itemsCreatedTotal.add(1, { outcome: "success" });
i.itemsReadTotal.add(1, { outcome: "success" });
i.itemsUpdatedTotal.add(1, { outcome: "success" });
i.itemsDeletedTotal.add(1, { outcome: "success" });
i.itemSizeBytes.record(1024);
const collected = await flushTestMetrics();
const names = collected
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.map((m) => m.descriptor.name);
expect(names).toContain("vault.items.created_total");
expect(names).toContain("vault.items.read_total");
expect(names).toContain("vault.items.updated_total");
expect(names).toContain("vault.items.deleted_total");
expect(names).toContain("vault.item.size_bytes");
});
it("getVaultPluginInstruments caches the bundle across calls", {
timeout: 5000,
}, () => {
const a = getVaultPluginInstruments();
const b = getVaultPluginInstruments();
expect(a).toBe(b);
});
it("resetVaultPluginInstruments clears the cache", {
timeout: 5000,
}, () => {
const a = getVaultPluginInstruments();
resetVaultPluginInstruments();
const b = getVaultPluginInstruments();
expect(a).not.toBe(b);
});
it("size_bytes histogram emits on create + update", {
timeout: 10_000,
}, async () => {
const i = getVaultPluginInstruments();
i.itemSizeBytes.record(100);
i.itemSizeBytes.record(2048);
const collected = await flushTestMetrics();
const histo = collected
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "vault.item.size_bytes");
expect(histo).toBeDefined();
});
it("created_total emits success", { timeout: 10_000 }, async () => {
const i = getVaultPluginInstruments();
i.itemsCreatedTotal.add(1, { outcome: "success" });
const collected = await flushTestMetrics();
const counter = collected
.flatMap((rm) => rm.scopeMetrics)
.flatMap((sm) => sm.metrics)
.find((m) => m.descriptor.name === "vault.items.created_total");
expect(counter).toBeDefined();
});
});

View File

@@ -0,0 +1,68 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* Bundle of OpenTelemetry instruments emitted by the vault plugin.
*
* - `itemsCreatedTotal{outcome}` — incremented on every `POST /vault/items`.
* - `itemsReadTotal{outcome}` — incremented on every `GET /vault/items/:id` or
* `GET /vault/items` listing.
* - `itemsUpdatedTotal{outcome}` — incremented on every `PATCH /vault/items/:id`.
* - `itemsDeletedTotal{outcome}` — incremented on every `DELETE /vault/items/:id`.
* - `itemSizeBytes` — histogram of item body byte size, sampled on
* successful create + update.
*
* `outcome ∈ "success" | "failure"`.
*/
export interface VaultPluginInstruments {
readonly itemsCreatedTotal: Counter;
readonly itemsReadTotal: Counter;
readonly itemsUpdatedTotal: Counter;
readonly itemsDeletedTotal: Counter;
readonly itemSizeBytes: Histogram;
}
let cached: VaultPluginInstruments | undefined;
/**
* Lazily build (and cache) the vault-plugin instruments bundle against
* the current global `MeterProvider`. Call `resetVaultPluginInstruments()`
* in test setup to force a re-bind against a freshly-installed provider.
*/
export function getVaultPluginInstruments(): VaultPluginInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-plugin-vault");
cached = {
itemsCreatedTotal: meter.createCounter("vault.items.created_total", {
description:
"Total vault-item create attempts, partitioned by outcome label",
}),
itemsReadTotal: meter.createCounter("vault.items.read_total", {
description:
"Total vault-item read attempts, partitioned by outcome label",
}),
itemsUpdatedTotal: meter.createCounter("vault.items.updated_total", {
description:
"Total vault-item update attempts, partitioned by outcome label",
}),
itemsDeletedTotal: meter.createCounter("vault.items.deleted_total", {
description:
"Total vault-item delete attempts, partitioned by outcome label",
}),
itemSizeBytes: meter.createHistogram("vault.item.size_bytes", {
description:
"Distribution of vault-item body sizes in bytes (create + update)",
unit: "By",
}),
};
return cached;
}
/**
* Drop the cached instrument bundle so the next
* `getVaultPluginInstruments()` call binds against whichever
* `MeterProvider` is globally registered at that moment. Tests call
* this after installing a fresh provider.
*/
export function resetVaultPluginInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1,6 @@
export { createVaultPlugin } from "./plugin.js";
export {
defaultUnlockExtractor,
extractUnlock,
headerExtractor,
} from "./unlock.js";

View File

@@ -0,0 +1,111 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createMemoryDrive } from "../../tests/setup/memory-drive.js";
import type { VaultPluginConfig } from "../types/types.js";
import { createVaultPlugin } from "./plugin.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
describe("plugin/on-init", () => {
it("applies VAULT_OWNERSHIP_SCHEMA via migrationRunner", {
timeout: 5000,
}, async () => {
const runs: string[] = [];
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: true,
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
services.set("migrationRunner", {
async run(ms: Array<{ name: string; content: string }>) {
for (const m of ms) runs.push(m.name);
},
});
await plugin.onInit?.(
createPluginContext<VaultPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as VaultPluginConfig,
services,
})
);
expect(runs).toEqual(["0000-vault-ownership-schema"]);
});
it("creates vault_item + vault_share tables", {
timeout: 5000,
}, async () => {
let appliedDdl = "";
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: true,
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
services.set("migrationRunner", {
async run(ms: Array<{ name: string; content: string }>) {
appliedDdl = ms[0]?.content ?? "";
},
});
await plugin.onInit?.(
createPluginContext<VaultPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as VaultPluginConfig,
services,
})
);
expect(appliedDdl).toContain("DEFINE TABLE OVERWRITE vault_item");
expect(appliedDdl).toContain("DEFINE TABLE OVERWRITE vault_share");
expect(appliedDdl).toContain("vault_item_owner_kind");
expect(appliedDdl).toContain("vault_item_owner_created");
});
it("applySchemaOnInit: false skips apply", { timeout: 5000 }, async () => {
let runnerCalled = false;
let dbCalled = false;
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: false,
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
services.set("migrationRunner", {
async run() {
runnerCalled = true;
},
});
services.set("db", {
async execute() {
dbCalled = true;
},
});
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
services.set("openAPIRegistry", registry);
await plugin.onInit?.(
createPluginContext<VaultPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as VaultPluginConfig,
services,
})
);
expect(runnerCalled).toBe(false);
expect(dbCalled).toBe(false);
expect(registry.getRoutesByPlugin("vault")).toHaveLength(5);
});
});

View File

@@ -0,0 +1,93 @@
# plugin/plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createVaultPlugin(options)` is the factory that returns a typed
`PluginDescriptor<VaultPluginConfig>` ready to register with the host's
`PluginRegistry`. Third instance of the 3-way plug-and-play contract:
the plugin owns its `.surql` DDL, its Zod schemas, and its OpenAPI
routes, and applies all three during `onInit`. Depends on
`api-plugin-auth` so every route's principal scoping has a typed
upstream supplier.
## Features
- Zod-validated options via `VaultPluginOptionsSchema` — invalid input
raises `VaultPluginError(VAULT_PLUGIN_INVALID_INPUT)` carrying the
`ZodError`.
- Declares `dependencies: ["auth"]` so the registry refuses to load
the plugin without `api-plugin-auth` registered upstream.
- 6-step `onInit` lifecycle: resolve services → apply schema →
construct `vaultService` → register `vaultService` → register routes
→ (no global middleware — `requireAuth()` is per-route).
- `routePrefix`-aware route mounting — paths are rebuilt at the prefix
the operator chose, both in the served Hono app and in the
`OpenAPIRegistry` snapshot. Default `/vault`.
- Collision detection — pre-existing routes under the configured
prefix raise `VAULT_PLUGIN_ROUTE_PREFIX_COLLISION`.
- Graceful degradation — when `openAPIRegistry` is absent, routes
mount on the Hono app directly (no doc); when neither
`migrationRunner` nor `db` is available with `applySchemaOnInit: true`,
init fails fast with `VAULT_PLUGIN_SERVICE_NOT_AVAILABLE`.
## Usage: Basic
```ts
import { defineApp } from "@suaveplan/api";
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
import { createVaultPlugin } from "@suaveplan/api-plugin-vault";
const app = await defineApp()
.name("my-api")
.use(createAuthPlugin({ betterAuth: { /* ... */ } }))
.use(createVaultPlugin({ storage: driveAdapter }))
.start();
```
## Usage: Advanced
```ts
const plugin = createVaultPlugin({
storage: driveAdapter,
routePrefix: "/v1/vault",
maxItemSizeBytes: 4 * 1024 * 1024,
serverPepper: process.env["VAULT_SERVER_PEPPER"]!,
unlockExtractor: (c) =>
c.req.header("X-Custom-Vault-Unlock") ?? null,
applySchemaOnInit: false, // operator runs migrations externally
});
```
## API Reference
### `createVaultPlugin(options: VaultPluginOptions): PluginDescriptor<VaultPluginConfig>`
Returns a `definePlugin` descriptor. `name: "vault"`, `version: "0.1.0"`,
`dependencies: ["auth"]`.
The `defaults` field carries every resolved option after Zod
validation — the registry passes that to `onInit` via `ctx.getOwnConfig()`.
`onInit(ctx)` resolves services, applies the schema, constructs the
service, registers it, and registers the 5 routes.
## Implementation Notes
- The plugin DOES NOT install global middleware. `requireAuth()` from
`api-plugin-auth` is applied per-route inside `buildVaultRoutes` so
the public `/openapi.json` doc only carries vault routes for the
authenticated scope and operators can leave the vault prefix
out of public-facing API documentation tooling.
- The `rewritePath` helper preserves `{id}` segments verbatim — when
the operator chooses `/v1/vault`, the route path
`/vault/items/{id}` becomes `/v1/vault/items/{id}`.
- The collision check inspects every previously registered route whose
path starts with the configured prefix — operators wiring multiple
vault plugin instances under different prefixes are unaffected.
- The plugin does not provide an `onClose` hook because the
`vaultService` is stateless across requests — every handle is
opened + closed within a single request lifecycle.

View File

@@ -0,0 +1,274 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createMemoryDrive } from "../../tests/setup/memory-drive.js";
import {
VAULT_PLUGIN_CODES,
VaultPluginError,
} from "../error-codes/error-codes.js";
import type { VaultPluginConfig } from "../types/types.js";
import { createVaultPlugin, rewritePath } from "./plugin.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function ctxArgs(
plugin: ReturnType<typeof createVaultPlugin>,
services: Map<string, unknown>
) {
return createPluginContext<VaultPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as VaultPluginConfig,
services,
});
}
describe("plugin/plugin", () => {
it("rewritePath normalises the bare /vault root when prefix is /", {
timeout: 5000,
}, () => {
expect(rewritePath("/vault", "/")).toBe("/");
expect(rewritePath("/vault/items", "/")).toBe("/items");
expect(rewritePath("/vault/items", "/v1/vault")).toBe(
"/v1/vault/items"
);
});
it("descriptor declares auth dependency", { timeout: 5000 }, () => {
const plugin = createVaultPlugin({ storage: createMemoryDrive() });
expect(plugin.name).toBe("vault");
expect(plugin.dependencies).toEqual(["auth"]);
expect(plugin.version).toBe("0.1.0");
});
it("missing storage raises VAULT_PLUGIN_INVALID_INPUT", {
timeout: 5000,
}, () => {
expect(() => createVaultPlugin({} as never)).toThrow(VaultPluginError);
try {
createVaultPlugin({} as never);
} catch (err) {
if (err instanceof VaultPluginError) {
expect(err.vaultPluginCode).toBe(
VAULT_PLUGIN_CODES.INVALID_INPUT
);
}
}
});
it("onInit without app service raises SERVICE_NOT_AVAILABLE", {
timeout: 5000,
}, async () => {
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: false,
});
let caught: VaultPluginError | undefined;
try {
await plugin.onInit?.(ctxArgs(plugin, new Map()));
} catch (err) {
caught = err as VaultPluginError;
}
expect(caught?.vaultPluginCode).toBe(
VAULT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE
);
});
it("onInit with app + no schema service raises SERVICE_NOT_AVAILABLE", {
timeout: 5000,
}, async () => {
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: true,
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
let caught: VaultPluginError | undefined;
try {
await plugin.onInit?.(ctxArgs(plugin, services));
} catch (err) {
caught = err as VaultPluginError;
}
expect(caught?.vaultPluginCode).toBe(
VAULT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE
);
});
it("onInit applies schema via migrationRunner when present", {
timeout: 5000,
}, async () => {
const runs: string[] = [];
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: true,
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
services.set("migrationRunner", {
async run(ms: Array<{ name: string }>) {
for (const m of ms) runs.push(m.name);
},
});
await plugin.onInit?.(ctxArgs(plugin, services));
expect(runs).toContain("0000-vault-ownership-schema");
const vs = services.get("vaultService");
expect(vs).toBeDefined();
});
it("onInit applies schema via db fallback when migrationRunner absent", {
timeout: 5000,
}, async () => {
const queries: string[] = [];
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: true,
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
services.set("db", {
async execute(q: string) {
queries.push(q);
},
});
await plugin.onInit?.(ctxArgs(plugin, services));
expect(queries[0]).toContain("vault_item");
});
it("onInit skips schema when applySchemaOnInit: false", {
timeout: 5000,
}, async () => {
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: false,
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
services.set("openAPIRegistry", registry);
await plugin.onInit?.(ctxArgs(plugin, services));
expect(registry.getRoutesByPlugin("vault")).toHaveLength(5);
});
it("onInit detects ROUTE_PREFIX_COLLISION", { timeout: 5000 }, async () => {
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: false,
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
registry.registerRoute({
method: "get",
path: "/vault/preexisting",
tags: [],
pluginName: "other",
});
services.set("openAPIRegistry", registry);
let caught: VaultPluginError | undefined;
try {
await plugin.onInit?.(ctxArgs(plugin, services));
} catch (err) {
caught = err as VaultPluginError;
}
expect(caught?.vaultPluginCode).toBe(
VAULT_PLUGIN_CODES.ROUTE_PREFIX_COLLISION
);
});
it("onInit falls back to plain Hono when openAPIRegistry absent", {
timeout: 5000,
}, async () => {
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: false,
});
const app = new OpenAPIHono();
const services = new Map<string, unknown>();
services.set("app", app);
await plugin.onInit?.(ctxArgs(plugin, services));
// Anonymous request — requireAuth fails. The point is the route
// is registered, so a Hono 401 (or any non-404) confirms routing.
app.onError((err, c) => {
return c.text(
(err as Error).message,
((err as { context?: { statusCode?: number } }).context
?.statusCode ?? 500) as 401 | 500
);
});
const res = await app.request("/vault/items");
expect(res.status).not.toBe(404);
});
it("onInit rewrites the route prefix when configured", {
timeout: 5000,
}, async () => {
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: false,
routePrefix: "/v1/vault",
});
const services = new Map<string, unknown>();
const app = new OpenAPIHono();
services.set("app", app);
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
services.set("openAPIRegistry", registry);
await plugin.onInit?.(ctxArgs(plugin, services));
const paths = registry.getRoutesByPlugin("vault").map((r) => r.path);
expect(paths.every((p) => p.startsWith("/v1/vault"))).toBe(true);
});
it("onInit rewrites prefix when routePrefix is the root '/'", {
timeout: 5000,
}, async () => {
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: false,
routePrefix: "/",
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
const registry = createOpenAPIRegistry({
info: { title: "t", version: "0.1" },
});
services.set("openAPIRegistry", registry);
await plugin.onInit?.(ctxArgs(plugin, services));
const paths = registry.getRoutesByPlugin("vault").map((r) => r.path);
// After stripping `/vault` and applying `/`, listing route → `/items` etc.
expect(paths).toContain("/items");
});
it("onInit propagates a custom logger override", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: false,
logger: {
debug: (m: string) => calls.push(m),
info: (m: string) => calls.push(m),
warn: (m: string) => calls.push(m),
error: (m: string) => calls.push(m),
},
});
const services = new Map<string, unknown>();
services.set("app", new OpenAPIHono());
await plugin.onInit?.(ctxArgs(plugin, services));
const vaultService = services.get("vaultService");
expect(vaultService).toBeDefined();
});
});

View File

@@ -0,0 +1,215 @@
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
import { definePluginRoutes } from "@suaveplan/api-openapi";
import {
definePlugin,
type PluginContext,
type PluginDescriptor,
} from "@suaveplan/api-plugin";
import {
VAULT_PLUGIN_CODES,
VaultPluginError,
} from "../error-codes/error-codes.js";
import { buildVaultRoutes } from "../routes/build-routes.js";
import { VaultPluginOptionsSchema } from "../schemas/options.js";
import "../services/registry-augmentation.js";
import {
applyVaultSchema,
applyVaultSchemaViaRunner,
} from "../schema/schema.js";
import { createVaultService } from "../services/vault-service.js";
import type {
VaultPluginConfig,
VaultPluginOptions,
VaultUnlockExtractor,
} from "../types/types.js";
import { defaultUnlockExtractor } from "./unlock.js";
/**
* Factory — produces a typed `PluginDescriptor` for the vault plugin.
*
* Zod-validates the supplied options at the boundary so invalid input
* fails fast with `VaultPluginError(VAULT_PLUGIN_INVALID_INPUT)`
* carrying the `ZodError` as `cause`. The returned descriptor declares
* `dependencies: ["auth"]` and runs the canonical 6-step lifecycle on
* init:
*
* 1. Resolve `app` + `openAPIRegistry` + (optionally) `migrationRunner`
* / `db` from the host `ServiceRegistry`.
* 2. Apply `VAULT_OWNERSHIP_SCHEMA` (when `applySchemaOnInit: true`).
* 3. Construct the `vaultService` backed by the configured `storage`
* + `serverPepper`.
* 4. Register `vaultService` in the `ServiceRegistry`.
* 5. Register the 5 OpenAPI routes via `definePluginRoutes` (mounted
* at `routePrefix`).
* 6. (No global middleware — vault routes are individually gated by
* `requireAuth()` inside `buildVaultRoutes`.)
*/
export function createVaultPlugin(
options: VaultPluginOptions
): PluginDescriptor<VaultPluginConfig> {
const parsed = VaultPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new VaultPluginError(
VAULT_PLUGIN_CODES.INVALID_INPUT,
"createVaultPlugin received invalid options",
{ cause: parsed.error, context: { statusCode: 500 } }
);
}
const parsedData = parsed.data;
const extractor: VaultUnlockExtractor =
parsedData.unlockExtractor ?? defaultUnlockExtractor;
const resolved: VaultPluginConfig = {
storage: parsedData.storage,
routePrefix: parsedData.routePrefix,
maxItemSizeBytes: parsedData.maxItemSizeBytes,
applySchemaOnInit: parsedData.applySchemaOnInit,
unlockExtractor: extractor,
serverPepper: parsedData.serverPepper,
...(parsedData.logger !== undefined
? { logger: parsedData.logger }
: {}),
};
return definePlugin<VaultPluginConfig>({
name: "vault",
version: "0.1.0",
description:
"Per-user vault HTTP plugin for @suaveplan/api. Wraps @suaveplan/vault, applies vault_item/vault_share ownership schema at boot, and mounts 5 CRUD routes scoped to c.get('user').id.",
dependencies: ["auth"],
defaults: resolved,
tags: ["vault", "security"],
onInit: async (ctx) => runOnInit(ctx, resolved),
});
}
async function runOnInit(
ctx: PluginContext<VaultPluginConfig>,
config: VaultPluginConfig
): Promise<void> {
// (1) Resolve required services from the host ServiceRegistry.
const app = ctx.getService("app");
if (app === undefined) {
throw new VaultPluginError(
VAULT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
"vault plugin requires the `app` service in ServiceRegistry",
{ context: { missing: ["app"], statusCode: 500 } }
);
}
const openAPIRegistry = ctx.getService("openAPIRegistry");
const migrationRunner = ctx.getService("migrationRunner");
const db = ctx.getService("db");
const logger = config.logger ?? ctx.logger;
// (2) Apply the bundled SurrealQL schema (when enabled).
if (config.applySchemaOnInit) {
if (migrationRunner !== undefined) {
await applyVaultSchemaViaRunner(migrationRunner);
} else if (db !== undefined) {
await applyVaultSchema(db);
} else {
throw new VaultPluginError(
VAULT_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
"applySchemaOnInit requires either a migrationRunner or db service",
{
context: {
missing: ["migrationRunner", "db"],
statusCode: 500,
},
}
);
}
}
// (3) Construct the canonical vaultService impl.
const vaultService = createVaultService({
storage: config.storage,
serverPepper: config.serverPepper,
logger,
});
// (4) Register the vaultService slot.
ctx.registerService("vaultService", vaultService);
// (5) Register the 5 OpenAPI routes mounted under routePrefix.
registerRoutes({
app,
openAPIRegistry,
vaultService,
config,
});
}
function registerRoutes(args: {
app: OpenAPIHono;
openAPIRegistry: OpenAPIRegistry | undefined;
vaultService: ReturnType<typeof createVaultService>;
config: VaultPluginConfig;
}): void {
const { app, openAPIRegistry, vaultService, config } = args;
const entries = buildVaultRoutes(vaultService, config);
const routePrefix = config.routePrefix;
// The route paths already include `/vault/...` — when the operator
// overrides `routePrefix` to something else (e.g. `/v1/vault`), we
// rebuild every path by stripping the literal `/vault` prefix and
// re-applying the configured prefix.
const prefixedEntries = entries.map((entry) => ({
...entry,
route: {
...entry.route,
path: rewritePath(entry.route.path, routePrefix),
},
}));
if (openAPIRegistry === undefined) {
// Without the OpenAPI registry we cannot collision-check or
// surface the doc. Fall back to plain Hono registration on the
// app; downstream consumers without the registry get routing
// but no /openapi.json entry.
for (const entry of prefixedEntries) {
const method = entry.route.method as
| "get"
| "post"
| "put"
| "patch"
| "delete";
app[method](
entry.route.path,
async (c) => (await entry.handler(c)) as Response
);
}
return;
}
const existing = openAPIRegistry
.getRoutes()
.filter((r) => r.path.startsWith(routePrefix));
if (existing.length > 0) {
throw new VaultPluginError(
VAULT_PLUGIN_CODES.ROUTE_PREFIX_COLLISION,
`Route prefix '${routePrefix}' collides with previously registered routes`,
{
context: {
prefix: routePrefix,
existing: existing.map((r) => r.path),
statusCode: 500,
},
}
);
}
definePluginRoutes(prefixedEntries, {
app,
registry: openAPIRegistry,
pluginName: "vault",
});
}
/**
* Replace the literal `/vault` segment at the start of `originalPath`
* with `prefix`. Keeps the rest of the path verbatim — `{id}` segments
* are preserved.
*/
/** Exported for tests only. */
export function rewritePath(originalPath: string, prefix: string): string {
const stripped = originalPath.replace(/^\/vault/u, "");
if (prefix === "/") return stripped === "" ? "/" : stripped;
return `${prefix}${stripped}`;
}

View File

@@ -0,0 +1,98 @@
# plugin/unlock
## Purpose
Tiny helper module that owns the "where does the vault unlock secret
come from?" question. The plugin's default `unlockExtractor` reads the
`X-Vault-Unlock` request header, but operators with JWT-bound secrets,
cookie-bound secrets, or password-derived flows override the extractor
when constructing the plugin. `extractUnlock(c, extractor)` is the
canonical call site invoked by every mutating route — it raises a typed
`VaultPluginError(VAULT_PLUGIN_UNLOCK_HEADER_MISSING)` (400) when the
extractor returns `null`.
## Features
- `headerExtractor(headerName)` — factory returning a
`VaultUnlockExtractor` that reads the named header. Header lookup is
case-insensitive per RFC 7230.
- `defaultUnlockExtractor` — the canonical default used when the
plugin's options do not specify one: reads `X-Vault-Unlock`.
- `extractUnlock(c, extractor)` — invokes the extractor and converts
`null` into a typed `VaultPluginError` so the host error handler
surfaces a clean 400 response.
- Extracted secrets are returned to the caller; the module DOES NOT
log or persist them. The §13a security audit explicitly forbids
logging the unlock secret.
## Usage: Basic
```ts
import {
defaultUnlockExtractor,
extractUnlock,
headerExtractor,
} from "@suaveplan/api-plugin-vault";
const unlockSecret = extractUnlock(c, defaultUnlockExtractor);
const handle = await vaultService.openForPrincipal(principal, unlockSecret);
```
## Usage: Advanced
```ts
// JWT-bound unlock: extract a hashed password from a custom claim.
const jwtExtractor: VaultUnlockExtractor = (c) => {
const auth = c.req.header("Authorization");
if (!auth?.startsWith("Bearer ")) return null;
const token = auth.slice(7);
try {
const payload = JSON.parse(
Buffer.from(token.split(".")[1] ?? "", "base64").toString("utf8")
) as { vaultSecret?: string };
return payload.vaultSecret ?? null;
} catch {
return null;
}
};
const plugin = createVaultPlugin({
storage,
unlockExtractor: jwtExtractor,
});
```
## API Reference
### `headerExtractor(headerName: string): VaultUnlockExtractor`
Returns an extractor that reads `headerName` from the request. Trims
the value; returns `null` if the header is absent or empty after
trimming.
### `defaultUnlockExtractor: VaultUnlockExtractor`
Pre-built extractor reading `X-Vault-Unlock`.
### `extractUnlock(c: Context, extractor: VaultUnlockExtractor): string`
Calls the extractor; raises `VaultPluginError(VAULT_PLUGIN_UNLOCK_HEADER_MISSING)`
(400) when it returns `null`. The returned string is the unlock secret
and MUST be passed directly to `vaultService.openForPrincipal`.
## Implementation Notes
- The unlock secret is NEVER logged. Both `defaultUnlockExtractor` and
the example custom extractors above only read; they do not emit.
- `headerExtractor` validates the header value is a non-empty trimmed
string. Whitespace-only values count as absent because operators
occasionally inject `X-Vault-Unlock:` placeholders during testing —
trimming catches those.
- The header name is forwarded verbatim to Hono's
`c.req.header(headerName)`; Hono performs case-insensitive matching
per the HTTP spec, so callers can supply any casing.
- Returning the secret as a plain string (rather than wrapping it in a
`SecureBuffer`) is a deliberate trade-off: the unlock secret has the
same lifetime as the request handler, and adding a heap-allocated
wrapper imposes per-request allocator pressure without preventing a
memory dump from disclosing the value.

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Context } from "hono";
import { VaultPluginError } from "../error-codes/error-codes.js";
import {
defaultUnlockExtractor,
extractUnlock,
headerExtractor,
} from "./unlock.js";
function makeCtx(headers: Record<string, string | undefined>): Context {
return {
req: {
header(name: string) {
const lower = name.toLowerCase();
for (const k of Object.keys(headers)) {
if (k.toLowerCase() === lower) return headers[k];
}
return undefined;
},
},
} as unknown as Context;
}
describe("plugin/unlock", () => {
it("default extractor reads X-Vault-Unlock", { timeout: 5000 }, () => {
const c = makeCtx({ "X-Vault-Unlock": "hunter2" });
expect(defaultUnlockExtractor(c)).toBe("hunter2");
});
it("default extractor returns null when missing", { timeout: 5000 }, () => {
expect(defaultUnlockExtractor(makeCtx({}))).toBeNull();
});
it("headerExtractor trims whitespace and treats blank as null", {
timeout: 5000,
}, () => {
const ex = headerExtractor("Authorization");
expect(ex(makeCtx({ Authorization: " " }))).toBeNull();
expect(ex(makeCtx({ Authorization: " token " }))).toBe("token");
});
it("headerExtractor returns null when header value is non-string", {
timeout: 5000,
}, () => {
const ex = headerExtractor("X-Vault-Unlock");
const c = {
req: { header: () => undefined },
} as unknown as Context;
expect(ex(c)).toBeNull();
});
it("missing header raises UNLOCK_HEADER_MISSING", { timeout: 5000 }, () => {
expect(() =>
extractUnlock(makeCtx({}), defaultUnlockExtractor)
).toThrow(VaultPluginError);
});
it("password never logged — extractUnlock does not call logger", {
timeout: 5000,
}, () => {
// Argument-free check — extract returns the secret without
// touching any side-effect surface. If logger were invoked, it
// would have to be passed in; the signature explicitly does not
// accept one.
const secret = extractUnlock(
makeCtx({ "X-Vault-Unlock": "p@ssw0rd" }),
defaultUnlockExtractor
);
expect(secret).toBe("p@ssw0rd");
// The function source — captured via toString — does not embed
// the supplied secret. Verifies the secret is not memoised in
// any closure-captured logger sink.
expect(extractUnlock.toString()).not.toContain("p@ssw0rd");
});
});

View File

@@ -0,0 +1,50 @@
import type { Context } from "hono";
import {
VAULT_PLUGIN_CODES,
VaultPluginError,
} from "../error-codes/error-codes.js";
import type { VaultUnlockExtractor } from "../types/types.js";
/**
* Build an `unlockExtractor` that reads the supplied header from the
* request. Returns `null` if the header is missing or blank — the
* route handler maps `null` to
* `VaultPluginError(VAULT_PLUGIN_UNLOCK_HEADER_MISSING)`.
*
* Header names are case-insensitive per RFC 7230; Hono normalises
* `c.req.header()` lookups, so callers can supply any casing.
*/
export function headerExtractor(headerName: string): VaultUnlockExtractor {
return function readHeader(c: Context): string | null {
const value = c.req.header(headerName);
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
}
/** Default extractor — reads `X-Vault-Unlock`. */
export const defaultUnlockExtractor: VaultUnlockExtractor =
headerExtractor("X-Vault-Unlock");
/**
* Run the configured `unlockExtractor` against the request context and
* raise `VaultPluginError(VAULT_PLUGIN_UNLOCK_HEADER_MISSING)` when it
* returns `null`. The extracted secret is RETURNED — it MUST be
* forwarded directly to `vaultService.openForPrincipal` and MUST NOT
* be logged or persisted.
*/
export function extractUnlock(
c: Context,
extractor: VaultUnlockExtractor
): string {
const value = extractor(c);
if (value === null) {
throw new VaultPluginError(
VAULT_PLUGIN_CODES.UNLOCK_HEADER_MISSING,
"vault unlock secret was not supplied",
{ context: { statusCode: 400 } }
);
}
return value;
}

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

View File

@@ -0,0 +1,6 @@
export type { SurrealLikeExecutor } from "./schema.js";
export {
applyVaultSchema,
applyVaultSchemaViaRunner,
VAULT_OWNERSHIP_SCHEMA,
} from "./schema.js";

View File

@@ -0,0 +1,97 @@
# schema/schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the SurrealQL DDL the vault plugin applies during `onInit` —
the first leg of the 3-way plug-and-play contract. `VAULT_OWNERSHIP_SCHEMA`
defines two tables (`vault_item` and `vault_share`) plus their indexes;
`applyVaultSchemaViaRunner` and `applyVaultSchema` provide the two
application paths the plugin uses (preferred runner path; direct
`db.execute` fallback). Original — the `@suaveplan/vault`
library encapsulates the encrypted container but exposes no opinion
about the HTTP-layer ownership store.
## Features
- Two tables; `vault_item` is the active surface, `vault_share` is
forward-declared so the future sharing-routes follow-up plugin lands
without a database migration churn.
- Three indexes covering the listing patterns the plugin actually
uses: per-owner-by-kind and per-owner-by-created-descending. The
share index is forward-declared too.
- `DEFINE TABLE OVERWRITE` + `DEFINE FIELD OVERWRITE` everywhere — the
schema is idempotent. Re-applying on every cold start is safe and
costs only the brief metadata-replace.
- `MigrationRunner` is the preferred application path because it
records the apply in `_migrations` and gives the operator a single
audit log of every DDL change.
- The direct `db.execute` fallback path raises a typed
`VaultPluginError(VAULT_PLUGIN_SCHEMA_APPLY_FAILED)` with the original
error as `cause` for telemetry surfacing.
## Usage: Basic
```ts
import {
applyVaultSchema,
VAULT_OWNERSHIP_SCHEMA,
} from "@suaveplan/api-plugin-vault";
await applyVaultSchema(surrealAdapter);
```
## Usage: Advanced
```ts
import {
applyVaultSchemaViaRunner,
VAULT_OWNERSHIP_SCHEMA,
} from "@suaveplan/api-plugin-vault";
import { createMigrationRunner } from "@suaveplan/api-migrations";
const runner = createMigrationRunner({ adapter: surrealqlAdapter });
await applyVaultSchemaViaRunner(runner);
// _migrations now lists `0000-vault-ownership-schema`
```
## API Reference
### `VAULT_OWNERSHIP_SCHEMA: string`
Frozen DDL string. Contains the two `DEFINE TABLE`s and three
`DEFINE INDEX`es plus every per-field declaration.
### `SurrealLikeExecutor`
Structural interface — `{ execute(query: string): Promise<unknown> }`.
Matches `@suaveplan/surrealdb`'s `SurrealAdapter.execute`.
### `applyVaultSchema(db: SurrealLikeExecutor): Promise<void>`
Direct application path. Throws `VaultPluginError(VAULT_PLUGIN_SCHEMA_APPLY_FAILED)`
on failure with the original error as `cause`.
### `applyVaultSchemaViaRunner(runner: MigrationRunner): Promise<void>`
Runner-mediated application path. Records the apply in the operator's
migration log. Same error contract.
## Implementation Notes
- The schema does NOT define any view, function, or event — those
cross plugin boundaries. The vault plugin owns the bare ownership
store; cross-plugin views (e.g. "all items shared with me") belong
in the sharing follow-up.
- `metadata` is `option<object>` so absent metadata stores as `NONE`
and zero-cost; clients can attach arbitrary JSON metadata without
schema churn.
- Indexes are NOT marked `UNIQUE` — there is no uniqueness constraint
on `(ownerId, kind)`; multiple items of the same kind per owner are
normal.
- The `containerKey` field is the opaque id used to look up the
encrypted body inside `@suaveplan/vault`'s container. Generated by
`vaultService` at create time; never returned to the wire.

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { VaultPluginError } from "../error-codes/error-codes.js";
import {
applyVaultSchema,
applyVaultSchemaViaRunner,
VAULT_OWNERSHIP_SCHEMA,
} from "./schema.js";
describe("schema/schema", () => {
it("VAULT_OWNERSHIP_SCHEMA contains both tables and indexes", {
timeout: 5000,
}, () => {
expect(VAULT_OWNERSHIP_SCHEMA).toContain(
"DEFINE TABLE OVERWRITE vault_item"
);
expect(VAULT_OWNERSHIP_SCHEMA).toContain(
"DEFINE TABLE OVERWRITE vault_share"
);
expect(VAULT_OWNERSHIP_SCHEMA).toContain("vault_item_owner_kind");
expect(VAULT_OWNERSHIP_SCHEMA).toContain("vault_item_owner_created");
expect(VAULT_OWNERSHIP_SCHEMA).toContain("vault_share_owner");
});
it("applyVaultSchema executes the DDL on the supplied db", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
await applyVaultSchema({
execute(query) {
calls.push(query);
return Promise.resolve();
},
});
expect(calls.length).toBe(1);
expect(calls[0]).toContain("vault_item");
});
it("applyVaultSchema wraps execute errors as VAULT_PLUGIN_SCHEMA_APPLY_FAILED", {
timeout: 5000,
}, async () => {
await expect(
applyVaultSchema({
execute: () => Promise.reject(new Error("nope")),
})
).rejects.toBeInstanceOf(VaultPluginError);
});
it("applyVaultSchemaViaRunner forwards one migration to the runner", {
timeout: 5000,
}, async () => {
const runs: Array<{ name: string; content: string }> = [];
const runner = {
async run(migrations: Array<{ name: string; content: string }>) {
for (const m of migrations)
runs.push({ name: m.name, content: m.content });
},
} as unknown as Parameters<typeof applyVaultSchemaViaRunner>[0];
await applyVaultSchemaViaRunner(runner);
expect(runs.length).toBe(1);
expect(runs[0]?.name).toBe("0000-vault-ownership-schema");
expect(runs[0]?.content).toContain("vault_item");
});
it("applyVaultSchemaViaRunner wraps runner errors as VAULT_PLUGIN_SCHEMA_APPLY_FAILED", {
timeout: 5000,
}, async () => {
const runner = {
async run() {
throw new Error("runner failed");
},
} as unknown as Parameters<typeof applyVaultSchemaViaRunner>[0];
await expect(applyVaultSchemaViaRunner(runner)).rejects.toBeInstanceOf(
VaultPluginError
);
});
});

View File

@@ -0,0 +1,113 @@
import type { MigrationRunner } from "@suaveplan/api-migrations";
import {
VAULT_PLUGIN_CODES,
VaultPluginError,
} from "../error-codes/error-codes.js";
/**
* Minimal SurrealDB execution surface used by the plugin's schema-apply
* fallback. Matches the shape exposed by `@suaveplan/surrealdb`'s
* `SurrealAdapter.execute`. Kept structural so the plugin does not
* runtime-import `@suaveplan/surrealdb` for the rare fallback path.
*/
export interface SurrealLikeExecutor {
execute(query: string): Promise<unknown>;
}
/**
* SurrealQL DDL applied by the vault plugin during `onInit`. Genesis-original
* — the plugin owns ownership/sharing metadata at the HTTP boundary while
* `@suaveplan/vault` owns the encrypted container itself.
*
* Tables:
*
* - `vault_item` — one row per per-principal vault item. `ownerId` is a
* record link to the `user` table so SurrealDB enforces referential
* integrity. `containerKey` is the opaque id used to look up the
* encrypted body inside `@suaveplan/vault`'s container. `kind` is a
* free-form discriminator chosen by the app (e.g. `"credential"`,
* `"note"`, `"document"`).
* - `vault_share` — forward-declared for the `api-plugin-vault-sharing`
* follow-up. No routes ship in v1 but the table exists so migrations
* are forward-compatible.
*
* Indexes:
*
* - `vault_item_owner_kind` on `(ownerId, kind)` for filtered listing.
* - `vault_item_owner_created` on `(ownerId, createdAt)` descending —
* the default listing order.
* - `vault_share_owner` on `(ownerId, itemId)` so sharing follow-ups
* can efficiently enumerate links per owner.
*
* Idempotency: `DEFINE TABLE OVERWRITE` and `DEFINE FIELD OVERWRITE`
* make the DDL safe to apply on every cold-start without conditional
* checks. Re-applying does not destroy data — `OVERWRITE` is a schema
* upsert.
*/
export const VAULT_OWNERSHIP_SCHEMA = `
-- vault_item: per-principal item ownership + metadata
DEFINE TABLE OVERWRITE vault_item SCHEMAFULL;
DEFINE FIELD OVERWRITE ownerId ON vault_item TYPE record<user>;
DEFINE FIELD OVERWRITE containerKey ON vault_item TYPE string;
DEFINE FIELD OVERWRITE title ON vault_item TYPE string;
DEFINE FIELD OVERWRITE kind ON vault_item TYPE string;
DEFINE FIELD OVERWRITE metadata ON vault_item TYPE option<object>;
DEFINE FIELD OVERWRITE createdAt ON vault_item TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE updatedAt ON vault_item TYPE datetime DEFAULT time::now() VALUE time::now();
DEFINE INDEX OVERWRITE vault_item_owner_kind ON vault_item FIELDS ownerId, kind;
DEFINE INDEX OVERWRITE vault_item_owner_created ON vault_item FIELDS ownerId, createdAt;
-- vault_share: forward-declared; sharing endpoints land in api-plugin-vault-sharing
DEFINE TABLE OVERWRITE vault_share SCHEMAFULL;
DEFINE FIELD OVERWRITE ownerId ON vault_share TYPE record<user>;
DEFINE FIELD OVERWRITE granteeId ON vault_share TYPE record<user>;
DEFINE FIELD OVERWRITE itemId ON vault_share TYPE record<vault_item>;
DEFINE FIELD OVERWRITE permissions ON vault_share TYPE array<string>;
DEFINE FIELD OVERWRITE grantedAt ON vault_share TYPE datetime DEFAULT time::now();
DEFINE FIELD OVERWRITE revokedAt ON vault_share TYPE option<datetime>;
DEFINE INDEX OVERWRITE vault_share_owner ON vault_share FIELDS ownerId, itemId;
`.trim();
/**
* Apply `VAULT_OWNERSHIP_SCHEMA` directly against a `SurrealLikeExecutor`.
* Used by the `onInit` fallback path when no `MigrationRunner` is
* registered. Throws `VaultPluginError(VAULT_PLUGIN_SCHEMA_APPLY_FAILED)`
* with the underlying error as `cause`.
*/
export async function applyVaultSchema(db: SurrealLikeExecutor): Promise<void> {
try {
await db.execute(VAULT_OWNERSHIP_SCHEMA);
} catch (err) {
throw new VaultPluginError(
VAULT_PLUGIN_CODES.SCHEMA_APPLY_FAILED,
"applyVaultSchema(db) failed",
{ cause: err, context: { statusCode: 500 } }
);
}
}
/**
* Apply `VAULT_OWNERSHIP_SCHEMA` via the host-supplied `MigrationRunner`.
* The migration is named `0000-vault-ownership-schema` so it sorts
* before any operator-supplied vault migrations.
*/
export async function applyVaultSchemaViaRunner(
runner: MigrationRunner
): Promise<void> {
try {
await runner.run([
{
name: "0000-vault-ownership-schema",
path: "<inline>",
order: 0,
content: VAULT_OWNERSHIP_SCHEMA,
},
]);
} catch (err) {
throw new VaultPluginError(
VAULT_PLUGIN_CODES.SCHEMA_APPLY_FAILED,
"migrationRunner.run(VAULT_OWNERSHIP_SCHEMA) failed",
{ cause: err, context: { statusCode: 500 } }
);
}
}

View File

@@ -0,0 +1,24 @@
export {
type VaultPluginOptionsInput,
type VaultPluginOptionsResolved,
VaultPluginOptionsSchema,
} from "./options.js";
export {
type CreateVaultItemBody,
CreateVaultItemBodySchema,
DeleteVaultItemResponseSchema,
type DeleteVaultItemResponseType,
type UpdateVaultItemBody,
UpdateVaultItemBodySchema,
VaultItemListResponseSchema,
type VaultItemListResponseType,
VaultItemMetadataSchema,
type VaultItemParams,
VaultItemParamsSchema,
VaultItemResponseSchema,
type VaultItemResponseType,
VaultItemSchema,
VaultItemSummarySchema,
type VaultItemSummaryType,
type VaultItemType,
} from "./vault-item.js";

View File

@@ -0,0 +1,93 @@
# schemas/options
## Purpose
Zod schema validating the input options to `createVaultPlugin`. Every
field has a Zod-derived default (or `optional()`) so callers can pass
just the required `storage` adapter and get sensible behaviour;
operators tightening security policy override defaults at the call site.
The schema runs at the factory boundary and `safeParse` failures raise
`VaultPluginError(VAULT_PLUGIN_INVALID_INPUT)` with the underlying
`ZodError` attached as `cause` for diagnostic surfaces (logs, span
events).
## Features
- Structural `z.custom` check for the `storage` adapter — Zod cannot
introspect the `DriveLike` interface, but a four-method duck-type
check covers every `DriveLike` implementation shipping today.
- Tight `routePrefix` regex matching the shared prefix-validation rule
used by `@suaveplan/api-plugin-auth`.
- `maxItemSizeBytes` capped at 256 MiB — bodies larger than that route
through the attachments surface (not in v1).
- `serverPepper` is a free-form opaque string; the default value is a
package-level constant, but production deployments SHOULD supply a
per-deployment secret loaded from `@suaveplan/env`.
## Usage: Basic
```ts
import { VaultPluginOptionsSchema } from "@suaveplan/api-plugin-vault";
const parsed = VaultPluginOptionsSchema.parse({
storage: myDriveAdapter,
});
console.log(parsed.routePrefix); // "/vault"
console.log(parsed.maxItemSizeBytes); // 10485760
```
## Usage: Advanced
```ts
import {
type VaultPluginOptionsInput,
VaultPluginOptionsSchema,
} from "@suaveplan/api-plugin-vault";
const input: VaultPluginOptionsInput = {
storage: myDriveAdapter,
routePrefix: "/v1/vault",
maxItemSizeBytes: 1024 * 1024,
serverPepper: process.env["VAULT_SERVER_PEPPER"] ?? "",
unlockExtractor: (c) => c.req.header("Authorization")?.slice(7) ?? null,
};
const resolved = VaultPluginOptionsSchema.safeParse(input);
if (!resolved.success) {
throw new Error("Invalid vault options: " + resolved.error.message);
}
```
## API Reference
### `VaultPluginOptionsSchema`
Top-level Zod `object`. Fields:
- `storage` (required, `z.custom<DriveLike>`).
- `routePrefix` (default `"/vault"`, regex-validated).
- `maxItemSizeBytes` (default `10 * 1024 * 1024`, range `[1, 256 MiB]`).
- `applySchemaOnInit` (default `true`).
- `unlockExtractor` (optional, function).
- `serverPepper` (default `"suaveplan-vault-plugin-pepper-v1"`, non-empty).
- `logger` (optional, structural `LoggerLike` check).
### `VaultPluginOptionsInput`
`z.input` of the schema — what callers MAY pass.
### `VaultPluginOptionsResolved`
`z.output` of the schema — every default applied.
## Implementation Notes
- The schema does not enforce mutual exclusion between `routePrefix`
ending in `/` and routes carrying leading `/` paths — the route
builder normalises this at registration time.
- `serverPepper` is not constrained beyond non-empty; if operators
re-deploy with a different pepper, existing vaults become unreadable
by design. Document this in the deployment runbook.
- `storage` is intentionally checked structurally rather than via
`instanceof` so any package implementing `DriveLike` (`@suaveplan/drive`,
bespoke S3 adapters, in-memory test doubles) passes.

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createMemoryDrive } from "../../tests/setup/memory-drive.js";
import { VaultPluginOptionsSchema } from "./options.js";
describe("schemas/options", () => {
it("applies defaults for routePrefix / maxItemSizeBytes / applySchemaOnInit / serverPepper", {
timeout: 5000,
}, () => {
const parsed = VaultPluginOptionsSchema.parse({
storage: createMemoryDrive(),
});
expect(parsed.routePrefix).toBe("/vault");
expect(parsed.maxItemSizeBytes).toBe(10 * 1024 * 1024);
expect(parsed.applySchemaOnInit).toBe(true);
expect(parsed.serverPepper).toBe("suaveplan-vault-plugin-pepper-v1");
});
it("rejects missing storage", { timeout: 5000 }, () => {
expect(VaultPluginOptionsSchema.safeParse({}).success).toBe(false);
});
it("rejects routePrefix with trailing slash", { timeout: 5000 }, () => {
expect(
VaultPluginOptionsSchema.safeParse({
storage: createMemoryDrive(),
routePrefix: "/vault/",
}).success
).toBe(false);
expect(
VaultPluginOptionsSchema.safeParse({
storage: createMemoryDrive(),
routePrefix: "/",
}).success
).toBe(true);
});
it("rejects non-positive maxItemSizeBytes", { timeout: 5000 }, () => {
expect(
VaultPluginOptionsSchema.safeParse({
storage: createMemoryDrive(),
maxItemSizeBytes: 0,
}).success
).toBe(false);
});
it("rejects an extractor that is not a function", { timeout: 5000 }, () => {
expect(
VaultPluginOptionsSchema.safeParse({
storage: createMemoryDrive(),
unlockExtractor: "not a fn",
}).success
).toBe(false);
});
it("rejects empty serverPepper", { timeout: 5000 }, () => {
expect(
VaultPluginOptionsSchema.safeParse({
storage: createMemoryDrive(),
serverPepper: "",
}).success
).toBe(false);
});
it("rejects storage that is missing required methods", {
timeout: 5000,
}, () => {
expect(
VaultPluginOptionsSchema.safeParse({ storage: {} }).success
).toBe(false);
});
it("rejects logger without info()", { timeout: 5000 }, () => {
expect(
VaultPluginOptionsSchema.safeParse({
storage: createMemoryDrive(),
logger: { debug: () => undefined } as never,
}).success
).toBe(false);
});
it("accepts a full custom configuration", { timeout: 5000 }, () => {
const parsed = VaultPluginOptionsSchema.parse({
storage: createMemoryDrive(),
routePrefix: "/v1/vault",
maxItemSizeBytes: 1024,
applySchemaOnInit: false,
unlockExtractor: () => "secret",
serverPepper: "my-pepper",
logger: {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
},
});
expect(parsed.routePrefix).toBe("/v1/vault");
expect(parsed.maxItemSizeBytes).toBe(1024);
});
});

View File

@@ -0,0 +1,81 @@
import z from "zod";
/**
* Zod schema for `VaultPluginOptions`. Validated by `createVaultPlugin`
* at the factory boundary — invalid input throws
* `VaultPluginError(VAULT_PLUGIN_INVALID_INPUT)` carrying the
* `ZodError` as `cause`.
*
* `storage` is a runtime object (the `DriveLike` adapter) so it is
* validated structurally via `z.custom` — the only requirement at the
* boundary is that it carries the four `DriveLike` methods.
*
* `routePrefix` MUST start with `/` and MUST NOT end with `/` unless
* the value is exactly `/`. Both forms produce broken Hono routes if
* accepted.
*
* `maxItemSizeBytes` defaults to 10 MiB; capping at 256 MiB defends
* against accidental misuse — bodies larger than that should go via the
* attachments surface (out of scope for v1).
*
* `serverPepper` is a free-form opaque string. The plugin mixes it
* into the Argon2id salt derivation via HKDF so a leak of the
* SurrealDB row does not by itself enable offline brute-force.
*/
export const VaultPluginOptionsSchema = z.object({
storage: z.custom<{
read: (p: string) => Promise<Uint8Array>;
write: (p: string, b: Uint8Array) => Promise<void>;
delete: (p: string) => Promise<boolean>;
exists: (p: string) => Promise<boolean>;
}>(
(val) =>
val !== null &&
typeof val === "object" &&
typeof (val as { read?: unknown }).read === "function" &&
typeof (val as { write?: unknown }).write === "function" &&
typeof (val as { delete?: unknown }).delete === "function" &&
typeof (val as { exists?: unknown }).exists === "function",
"storage must implement DriveLike (read/write/delete/exists)"
),
routePrefix: z
.string()
.regex(/^\/[A-Za-z0-9\-_/]*[A-Za-z0-9\-_]$|^\/$/u, {
message:
"routePrefix must start with '/' and not end with '/' (unless it is exactly '/')",
})
.default("/vault"),
maxItemSizeBytes: z
.number()
.int()
.positive()
.max(256 * 1024 * 1024)
.default(10 * 1024 * 1024),
applySchemaOnInit: z.boolean().default(true),
unlockExtractor: z
.custom<(c: unknown) => string | null>(
(val) => typeof val === "function",
"unlockExtractor must be a function"
)
.optional(),
serverPepper: z.string().min(1).default("suaveplan-vault-plugin-pepper-v1"),
logger: z
.custom<{
debug: (msg: string, ctx?: unknown) => void;
info: (msg: string, ctx?: unknown) => void;
warn: (msg: string, ctx?: unknown) => void;
error: (msg: string, ctx?: unknown) => void;
}>(
(val) =>
val !== null &&
typeof val === "object" &&
typeof (val as { info?: unknown }).info === "function",
"logger must be a LoggerLike with debug/info/warn/error"
)
.optional(),
});
export type VaultPluginOptionsInput = z.input<typeof VaultPluginOptionsSchema>;
export type VaultPluginOptionsResolved = z.output<
typeof VaultPluginOptionsSchema
>;

View File

@@ -0,0 +1,114 @@
# schemas/vault-item
## Purpose
Zod schemas describing the wire shape of every body, parameter, and
response handled by the 5 vault routes. Every schema is the single
source of truth: `RouteBuilder` registers them on the OpenAPI document,
Hono's `c.req.valid("json")` inference flows from them, and downstream
test fixtures derive sample data from them via `z.infer`.
The schemas exist at the boundary, not inside the encrypted container —
they describe what clients send and receive, not what is persisted on
disk. Persistence schemas live with `@suaveplan/vault` itself.
## Features
- 8 schemas: `VaultItemMetadataSchema`, `VaultItemSummarySchema`,
`VaultItemSchema`, `VaultItemResponseSchema`,
`VaultItemListResponseSchema`, `CreateVaultItemBodySchema`,
`UpdateVaultItemBodySchema` (strict mode),
`DeleteVaultItemResponseSchema`, plus `VaultItemParamsSchema` for the
URL `{id}` segment.
- `UpdateVaultItemBodySchema` uses `.strict()` so unknown fields cause a
Zod parse error — protects against typos and prevents schema-drift
by silently accepting client-side keys the server does not understand.
- `body` is `z.string()` so clients can carry arbitrary JSON, base64,
or text; size enforcement happens at the route layer against
`options.maxItemSizeBytes`.
- Paginated response carries `meta: { total }` — sufficient for v1; a
cursor field will land in a follow-up change once the underlying
vault library exposes one.
## Usage: Basic
```ts
import { CreateVaultItemBodySchema } from "@suaveplan/api-plugin-vault";
const parsed = CreateVaultItemBodySchema.parse({
title: "API key",
kind: "credential",
body: JSON.stringify({ user: "alice", key: "k..." }),
});
```
## Usage: Advanced
```ts
import {
type CreateVaultItemBody,
type UpdateVaultItemBody,
UpdateVaultItemBodySchema,
} from "@suaveplan/api-plugin-vault";
const result = UpdateVaultItemBodySchema.safeParse({
title: "X",
evil: "Y",
});
// result.success === false; result.error lists "Unrecognized key(s): evil"
```
## API Reference
### `VaultItemSummarySchema`
Object with `id`, `ownerId`, `title` (1..512), `kind` (1..128),
`metadata` (optional record), `createdAt`, `updatedAt`.
### `VaultItemSchema`
Extends `VaultItemSummarySchema` with `body: string`.
### `VaultItemResponseSchema`, `VaultItemListResponseSchema`,
`DeleteVaultItemResponseSchema`
Envelope responses returned by the 5 routes. The success envelope is
the standard `{ success: true, data }` shape used across suaveplan;
errors use the shared `ErrorResponseSchema` from
`@suaveplan/api-openapi`.
### `CreateVaultItemBodySchema`
POST body: `title`, `kind`, `body`, optional `metadata`. All four
fields validated as non-empty/length-bounded where applicable.
### `UpdateVaultItemBodySchema`
PATCH body (strict). Every field optional; `metadata` may be set to
omit. Unknown fields rejected.
### `VaultItemParamsSchema`
URL params: `{ id: string }`.
### Type aliases
`VaultItemSummaryType`, `VaultItemType`, `VaultItemResponseType`,
`VaultItemListResponseType`, `CreateVaultItemBody`,
`UpdateVaultItemBody`, `DeleteVaultItemResponseType`, `VaultItemParams`.
## Implementation Notes
- `metadata` uses `z.record(z.string(), z.unknown())` rather than an
explicit nested shape because callers attach app-specific fields
(tags, labels, custom expiry hints) that vary across consumers. The
strict `UpdateVaultItemBodySchema` mode does NOT recurse into
`metadata` — strict only blocks unknown top-level keys.
- `createdAt`/`updatedAt` are ISO-8601 strings, not numbers. SurrealDB
serialises `datetime` as ISO strings; the wire matches the persistence
layer.
- `body` is intentionally string-typed, not byte-typed. Clients
base64-encode binary payloads before sending. This keeps the wire
JSON-safe and consistent across HTTP/2, HTTP/3, and SSE transports
without forcing a multipart envelope.

View File

@@ -0,0 +1,113 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CreateVaultItemBodySchema,
DeleteVaultItemResponseSchema,
UpdateVaultItemBodySchema,
VaultItemListResponseSchema,
VaultItemParamsSchema,
VaultItemResponseSchema,
VaultItemSchema,
VaultItemSummarySchema,
} from "./vault-item.js";
describe("schemas/vault-item", () => {
it("CreateVaultItemBodySchema requires title and kind", {
timeout: 5000,
}, () => {
expect(CreateVaultItemBodySchema.safeParse({}).success).toBe(false);
expect(
CreateVaultItemBodySchema.safeParse({
title: "t",
kind: "k",
body: "x",
}).success
).toBe(true);
});
it("CreateVaultItemBodySchema accepts optional metadata", {
timeout: 5000,
}, () => {
const res = CreateVaultItemBodySchema.safeParse({
title: "t",
kind: "k",
body: "x",
metadata: { tags: ["a"] },
});
expect(res.success).toBe(true);
});
it("UpdateVaultItemBodySchema is strict", { timeout: 5000 }, () => {
const res = UpdateVaultItemBodySchema.safeParse({
title: "X",
evil: "Y",
});
expect(res.success).toBe(false);
});
it("UpdateVaultItemBodySchema accepts empty patch", {
timeout: 5000,
}, () => {
expect(UpdateVaultItemBodySchema.safeParse({}).success).toBe(true);
});
it("VaultItemSchema requires a body field beyond the summary", {
timeout: 5000,
}, () => {
const summaryShape = {
id: "x",
ownerId: "user:1",
title: "t",
kind: "k",
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
};
expect(VaultItemSummarySchema.safeParse(summaryShape).success).toBe(
true
);
expect(VaultItemSchema.safeParse(summaryShape).success).toBe(false);
expect(
VaultItemSchema.safeParse({ ...summaryShape, body: "secret" })
.success
).toBe(true);
});
it("response envelopes carry success literal + data", {
timeout: 5000,
}, () => {
const item = {
id: "x",
ownerId: "user:1",
title: "t",
kind: "k",
body: "b",
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
};
expect(
VaultItemResponseSchema.safeParse({
success: true,
data: item,
}).success
).toBe(true);
expect(
VaultItemListResponseSchema.safeParse({
success: true,
data: [],
meta: { total: 0 },
}).success
).toBe(true);
expect(
DeleteVaultItemResponseSchema.safeParse({
success: true,
data: { deleted: true },
}).success
).toBe(true);
});
it("VaultItemParamsSchema rejects empty id", { timeout: 5000 }, () => {
expect(VaultItemParamsSchema.safeParse({ id: "" }).success).toBe(false);
expect(VaultItemParamsSchema.safeParse({ id: "abc" }).success).toBe(
true
);
});
});

View File

@@ -0,0 +1,96 @@
// schema: boundary-only
import z from "zod";
/**
* Wire-shape schemas for the 5 vault routes.
*
* - `VaultItemSchema` — full item incl. decrypted body.
* - `VaultItemSummarySchema` — list-row shape excl. body.
* - `VaultItemResponseSchema` — single-item route response envelope.
* - `VaultItemListResponseSchema` — listing route response envelope (paginated).
* - `CreateVaultItemBodySchema` — POST request body.
* - `UpdateVaultItemBodySchema` — PATCH request body. **Strict mode** —
* unknown fields cause the request to be rejected with 400.
*/
const isoDate = z.string().min(1);
/**
* Free-form item metadata. Constrained to a record of JSON-serialisable
* scalars to keep the wire shape tractable. Nested objects are allowed
* via `z.unknown()` so downstream apps can attach arbitrary structured
* metadata without bumping the schema.
*/
export const VaultItemMetadataSchema = z
.record(z.string(), z.unknown())
.optional();
export const VaultItemSummarySchema = z.object({
id: z.string().min(1),
ownerId: z.string().min(1),
title: z.string().min(1).max(512),
kind: z.string().min(1).max(128),
metadata: VaultItemMetadataSchema,
createdAt: isoDate,
updatedAt: isoDate,
});
export const VaultItemSchema = VaultItemSummarySchema.extend({
body: z.string(),
});
export const VaultItemResponseSchema = z.object({
success: z.literal(true),
data: VaultItemSchema,
});
export const VaultItemListResponseSchema = z.object({
success: z.literal(true),
data: z.array(VaultItemSummarySchema),
meta: z.object({
total: z.number().int().nonnegative(),
}),
});
export const CreateVaultItemBodySchema = z.object({
title: z.string().min(1).max(512),
kind: z.string().min(1).max(128),
body: z.string(),
metadata: VaultItemMetadataSchema,
});
/**
* PATCH body — strict mode rejects unknown fields with a Zod issue
* (`Unrecognized key(s) in object`). Every field is optional so
* callers can update one column at a time.
*/
export const UpdateVaultItemBodySchema = z
.object({
title: z.string().min(1).max(512).optional(),
kind: z.string().min(1).max(128).optional(),
body: z.string().optional(),
metadata: VaultItemMetadataSchema,
})
.strict();
export const DeleteVaultItemResponseSchema = z.object({
success: z.literal(true),
data: z.object({ deleted: z.literal(true) }),
});
export const VaultItemParamsSchema = z.object({
id: z.string().min(1),
});
export type VaultItemSummaryType = z.infer<typeof VaultItemSummarySchema>;
export type VaultItemType = z.infer<typeof VaultItemSchema>;
export type VaultItemResponseType = z.infer<typeof VaultItemResponseSchema>;
export type VaultItemListResponseType = z.infer<
typeof VaultItemListResponseSchema
>;
export type CreateVaultItemBody = z.infer<typeof CreateVaultItemBodySchema>;
export type UpdateVaultItemBody = z.infer<typeof UpdateVaultItemBodySchema>;
export type DeleteVaultItemResponseType = z.infer<
typeof DeleteVaultItemResponseSchema
>;
export type VaultItemParams = z.infer<typeof VaultItemParamsSchema>;

View File

@@ -0,0 +1,5 @@
export {
type CreateVaultServiceOptions,
createVaultService,
type VaultService,
} from "./vault-service.js";

View File

@@ -0,0 +1,79 @@
# services/registry-augmentation
## Purpose
Side-effect module that augments `@suaveplan/api-plugin`'s
`ServiceRegistry` interface with the `vaultService` slot. Without this
augmentation, `ctx.getService("vaultService")` would return
`unknown | undefined`; with it, the call returns `VaultService |
undefined` and downstream plugins compile against the canonical
interface without an `as` cast.
## Features
- Declaration-merge module augmentation — zero runtime cost. The file
emits no JavaScript value; it is `tslib` / type-only.
- Re-exports nothing. The package root barrel imports the module for
its side effect; consumers never reference it directly.
- TypeScript merges this declaration with the host's own
`ServiceRegistry` augmentation (declared in `@suaveplan/api`), so
the plugin can be installed standalone or together with any other
augmented plugin.
## Usage: Basic
```ts
import "@suaveplan/api-plugin-vault"; // root barrel pulls in this augmentation
import { definePlugin } from "@suaveplan/api-plugin";
definePlugin({
name: "my-feature",
onInit(ctx) {
const vaultService = ctx.getService("vaultService");
// vaultService is `VaultService | undefined`
},
});
```
## Usage: Advanced
```ts
// Composition with other plugin augmentations — the registry exposes
// both slots without conflict thanks to TypeScript's declaration
// merging.
import "@suaveplan/api-plugin-auth";
import "@suaveplan/api-plugin-vault";
definePlugin({
name: "audit",
dependencies: ["auth", "vault"],
onInit(ctx) {
const auth = ctx.getService("authService")!;
const vault = ctx.getService("vaultService")!;
// Both typed correctly.
},
});
```
## API Reference
### `declare module "@suaveplan/api-plugin/service-registry"`
Augments the `ServiceRegistry` interface with:
```ts
interface ServiceRegistry {
vaultService: VaultService;
}
```
## Implementation Notes
- The augmentation lives next to the service file (not in `types/`) so
the relationship between the service interface and its registry slot
is one folder away. Moving the augmentation file requires renaming
the slot's `VaultService` import accordingly.
- The package root barrel performs `import
"./services/registry-augmentation.js"` for the side effect alone —
re-exporting it would be misleading because there is no runtime
symbol to import.

View File

@@ -0,0 +1,38 @@
/**
* ServiceRegistry module augmentation — declares the `vaultService`
* slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
* interface so downstream plugins typed against the augmented registry
* see `ctx.getService("vaultService")` resolve to `VaultService |
* undefined` without any cast.
*
* Importing this module for its side effects (which the package root
* barrel does automatically) is sufficient — every consumer of
* `@suaveplan/api-plugin-vault` gets typed `vaultService` access.
*
* The host-side slots (`app`, `openAPIRegistry`, `migrationRunner`,
* `db`, `logger`, `eventBus`, `clock`) are owned by `@suaveplan/api`'s
* own registry-augmentation module. We re-declare them here as a
* cumulative augmentation so the vault plugin's `onInit(ctx)` can
* resolve them with full typing even when the consumer has not
* explicitly imported `@suaveplan/api`. TypeScript merges duplicate
* declarations, so this is safe alongside other augmentations.
*/
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { MigrationRunner } from "@suaveplan/api-migrations";
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
import type { VaultService } from "./vault-service.js";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
vaultService: VaultService;
app: OpenAPIHono;
openAPIRegistry: OpenAPIRegistry;
migrationRunner?: MigrationRunner;
db?: SurrealAdapter;
logger: LoggerLike;
eventBus: EventBusLike;
clock: Clock;
}
}

View File

@@ -0,0 +1,128 @@
# services/vault-service
## Purpose
Canonical `VaultService` implementation registered by `createVaultPlugin`
in the host's `ServiceRegistry` under the `vaultService` slot. Wraps
`@suaveplan/vault.Vault` with per-principal scoping: every call passes
through an opaque `VaultHandle` minted by `openForPrincipal` which is
the only way to acquire an unlocked vault. The service is the bridge
between the plugin's request handlers and the heavy 2SKD pipeline
inside `@suaveplan/vault`.
## Features
- Per-principal deterministic Argon2id salt derivation via
`HKDF-SHA256(serverPepper, principalId, "suaveplan-vault-plugin-argon2id-salt")`.
Bound to BOTH inputs so a leak of the server pepper alone cannot
brute-force a known principal's vault.
- `secretKey = principal.id` — the server-known stable identifier
doubles as the Bitwarden-style second secret, so the unlock contract
only needs the per-request password.
- Opaque `VaultHandle` — a frozen `{ __brand, principalId }` whose
underlying `Vault` lives in a package-private `WeakMap`, so JSON
serialisation of the handle cannot leak secrets.
- No cross-request handle cache. Route handlers MUST `close(handle)`
in a `finally` so a forgotten password cannot let later requests
bypass authentication.
- Built-in projection: `Vault.items` plaintext + concealed fields are
flattened to the wire shape (`VaultItem` / `VaultItemSummary`).
- No-op stubs for `DriveSharingLike` and `VaultSyncLike` — sharing
ships in `api-plugin-vault-sharing`; sync ships in
`@suaveplan/vault-sync`. Plugin v1 routes never reach the stubs.
## Usage: Basic
```ts
import {
createVaultService,
type VaultService,
} from "@suaveplan/api-plugin-vault";
const service = createVaultService({
storage: driveAdapter,
serverPepper: process.env["VAULT_SERVER_PEPPER"] ?? "",
logger: pluginLogger,
});
const handle = await service.openForPrincipal(principal, password);
try {
const item = await service.createItem(handle, {
title: "API key",
kind: "credential",
body: JSON.stringify({ user, key }),
});
} finally {
await service.close(handle);
}
```
## Usage: Advanced
```ts
// Read + update flow with optimistic re-fetch.
const handle = await vaultService.openForPrincipal(principal, password);
try {
const items = await vaultService.listItems(handle);
for (const item of items) {
if (item.kind !== "credential") continue;
await vaultService.updateItem(handle, item.id, {
metadata: { ...item.metadata, lastSeenAt: Date.now() },
});
}
} finally {
await vaultService.close(handle);
}
```
## API Reference
### `VaultService`
- `openForPrincipal(principal, password): Promise<VaultHandle>` —
wrong password raises `VaultPluginError(VAULT_PLUGIN_UNLOCK_FAILED)`.
- `listItemsForOwner(ownerId): Promise<VaultItemSummary[]>` — metadata
surface for background workers that do not hold a password; returns
`[]` in the default impl (route handlers query SurrealDB directly).
- `listItems(handle): Promise<VaultItem[]>` — full item list for the
open vault.
- `readItem(handle, itemId): Promise<VaultItem | null>` — `null` for
unknown / tombstoned ids.
- `createItem(handle, { title, kind, body, metadata? }): Promise<VaultItem>`
— auto-generates the id; calls `vault.flush()` before returning.
- `updateItem(handle, itemId, patch): Promise<VaultItem | null>` —
`null` if the id is unknown. Each field is optional; omitted fields
preserve their previous value.
- `deleteItem(handle, itemId): Promise<boolean>` — `false` when the id
was not present.
- `close(handle): Promise<void>` — locks the underlying vault and drops
the handle's `WeakMap` entry.
### `CreateVaultServiceOptions`
`{ storage: DriveLike; serverPepper: string; logger: LoggerLike }`.
### `createVaultService(options)`
Factory returning a fresh `VaultService` instance backed by the
supplied storage + pepper.
## Implementation Notes
- The handle's internal state (`{ vault, principal }`) is stored in a
module-level `WeakMap<VaultHandle, OpenVaultState>` so a `delete()`
on the map AND letting the handle go out of scope together fully
release the entry.
- `vault.flush()` is called after every mutating operation so the
encrypted container is written to drive before the response returns.
Without `flush`, a process crash between the mutation and the next
scheduled flush would lose the write.
- Field projection: `@suaveplan/vault` stores the `body` payload in
`concealedFields.body` (encrypted on persistence, decrypted lazily on
read). `metadata` is serialised to a JSON string in `fields.metadata`
and reconstructed on read; non-object metadata is dropped to avoid
carrying invalid wire shapes.
- The fallback `listItemsForOwner` in this default impl returns `[]`.
The route handler does not consume that path — it queries SurrealDB
via the resolved `db` adapter — but the service contract preserves it
for background callers that have an `ownerId` without a password.

View File

@@ -0,0 +1,11 @@
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { VaultService } from "./vault-service.js";
describe("services/vault-service spec conformance", () => {
it("ServiceRegistry slot typed as VaultService", { timeout: 5000 }, () => {
expectTypeOf<
ServiceRegistry["vaultService"]
>().toEqualTypeOf<VaultService>();
});
});

View File

@@ -0,0 +1,399 @@
import type { Principal } from "@suaveplan/api-plugin-auth";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createMemoryDrive } from "../../tests/setup/memory-drive.js";
import {
VAULT_PLUGIN_CODES,
VaultPluginError,
} from "../error-codes/error-codes.js";
import { __test, createVaultService } from "./vault-service.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const principal: Principal = {
id: "user:alice",
email: "alice@example.test",
emailVerified: true,
roles: [],
};
describe("services/vault-service (real @suaveplan/vault wrap)", () => {
it("openForPrincipal returns VaultHandle; createItem + readItem + updateItem + deleteItem roundtrip", {
timeout: 60_000,
}, async () => {
const service = createVaultService({
storage: createMemoryDrive(),
serverPepper: "test-pepper",
logger: noopLogger,
});
const handle = await service.openForPrincipal(principal, "Pwd-1234");
expect(handle.principalId).toBe("user:alice");
const created = await service.createItem(handle, {
title: "API",
kind: "credential",
body: "secret-body",
metadata: { tags: ["a"] },
});
expect(created.title).toBe("API");
expect(created.body).toBe("secret-body");
expect(created.metadata).toEqual({ tags: ["a"] });
const read = await service.readItem(handle, created.id);
expect(read?.body).toBe("secret-body");
const updated = await service.updateItem(handle, created.id, {
title: "API2",
kind: "cred",
body: "new-secret",
metadata: { tags: ["b"] },
});
expect(updated?.title).toBe("API2");
expect(updated?.body).toBe("new-secret");
const listed = await service.listItems(handle);
expect(listed.length).toBe(1);
expect(listed[0]?.id).toBe(created.id);
const deleted = await service.deleteItem(handle, created.id);
expect(deleted).toBe(true);
await service.close(handle);
});
it("bad password raises UNLOCK_FAILED", { timeout: 60_000 }, async () => {
const drive = createMemoryDrive();
const service = createVaultService({
storage: drive,
serverPepper: "test-pepper",
logger: noopLogger,
});
// Seed a container with one password
const h = await service.openForPrincipal(principal, "Pwd-1234");
await service.close(h);
// Re-open with wrong password
let caught: VaultPluginError | undefined;
try {
await service.openForPrincipal(principal, "wrong-password");
} catch (err) {
caught = err as VaultPluginError;
}
expect(caught?.vaultPluginCode).toBe(VAULT_PLUGIN_CODES.UNLOCK_FAILED);
});
it("readItem returns null for missing id", {
timeout: 60_000,
}, async () => {
const service = createVaultService({
storage: createMemoryDrive(),
serverPepper: "test-pepper",
logger: noopLogger,
});
const handle = await service.openForPrincipal(principal, "Pwd-1234");
const read = await service.readItem(handle, "nope");
expect(read).toBeNull();
await service.close(handle);
});
it("updateItem returns null for missing id and deleteItem returns false", {
timeout: 60_000,
}, async () => {
const service = createVaultService({
storage: createMemoryDrive(),
serverPepper: "test-pepper",
logger: noopLogger,
});
const handle = await service.openForPrincipal(principal, "Pwd-1234");
const updated = await service.updateItem(handle, "nope", {
title: "x",
});
expect(updated).toBeNull();
const deleted = await service.deleteItem(handle, "nope");
expect(deleted).toBe(false);
await service.close(handle);
});
it("operations on a closed handle raise FORBIDDEN", {
timeout: 60_000,
}, async () => {
const service = createVaultService({
storage: createMemoryDrive(),
serverPepper: "test-pepper",
logger: noopLogger,
});
const handle = await service.openForPrincipal(principal, "Pwd-1234");
await service.close(handle);
let caught: VaultPluginError | undefined;
try {
await service.listItems(handle);
} catch (err) {
caught = err as VaultPluginError;
}
expect(caught?.vaultPluginCode).toBe(VAULT_PLUGIN_CODES.FORBIDDEN);
});
it("close is idempotent — calling on an already-closed handle is a no-op", {
timeout: 60_000,
}, async () => {
const service = createVaultService({
storage: createMemoryDrive(),
serverPepper: "test-pepper",
logger: noopLogger,
});
const handle = await service.openForPrincipal(principal, "Pwd-1234");
await service.close(handle);
await expect(service.close(handle)).resolves.toBeUndefined();
});
it("listItemsForOwner stub returns []", { timeout: 5000 }, async () => {
const service = createVaultService({
storage: createMemoryDrive(),
serverPepper: "test-pepper",
logger: noopLogger,
});
const result = await service.listItemsForOwner("user:bob");
expect(result).toEqual([]);
});
it("derivePrincipalSalt produces deterministic 16-byte salts", {
timeout: 5000,
}, () => {
const a = __test.derivePrincipalSalt("pepper", "user:1");
const b = __test.derivePrincipalSalt("pepper", "user:1");
const c = __test.derivePrincipalSalt("pepper", "user:2");
expect(a.length).toBe(16);
expect(Array.from(a)).toEqual(Array.from(b));
expect(Array.from(a)).not.toEqual(Array.from(c));
});
it("projectItem drops malformed metadata JSON gracefully", {
timeout: 5000,
}, () => {
const libItem = {
id: "x",
title: "t",
category: "k",
fields: { metadata: "not-json{" },
concealedFields: { body: "b" },
tags: [],
favorite: false,
createdAt: 0,
updatedAt: 0,
version: 1,
deleted: false,
} as unknown as Parameters<typeof __test.projectItem>[0];
const projected = __test.projectItem(libItem, "user:1");
expect(projected.body).toBe("b");
expect(projected.metadata).toBeUndefined();
});
it("projectItem ignores non-object metadata JSON", {
timeout: 5000,
}, () => {
const libItem = {
id: "x",
title: "t",
category: "k",
fields: { metadata: '"a-string"' },
concealedFields: { body: "b" },
tags: [],
favorite: false,
createdAt: 0,
updatedAt: 0,
version: 1,
deleted: false,
} as unknown as Parameters<typeof __test.projectItem>[0];
expect(__test.projectItem(libItem, "u").metadata).toBeUndefined();
});
it("projectItem reads body from fields.body when no concealedFields", {
timeout: 5000,
}, () => {
const libItem = {
id: "x",
title: "t",
category: "k",
fields: { body: "fallback" },
concealedFields: {},
tags: [],
favorite: false,
createdAt: 0,
updatedAt: 0,
version: 1,
deleted: false,
} as unknown as Parameters<typeof __test.projectItem>[0];
expect(__test.projectItem(libItem, "u").body).toBe("fallback");
});
it("projectItem tolerates missing fields/concealedFields maps", {
timeout: 5000,
}, () => {
const libItem = {
id: "x",
title: "t",
category: "k",
// fields + concealedFields omitted on purpose
tags: [],
favorite: false,
createdAt: 0,
updatedAt: 0,
version: 1,
deleted: false,
} as unknown as Parameters<typeof __test.projectItem>[0];
expect(__test.projectItem(libItem, "u").body).toBe("");
});
it("projectItem returns empty body when neither source carries it", {
timeout: 5000,
}, () => {
const libItem = {
id: "x",
title: "t",
category: "k",
fields: {},
concealedFields: {},
tags: [],
favorite: false,
createdAt: 0,
updatedAt: 0,
version: 1,
deleted: false,
} as unknown as Parameters<typeof __test.projectItem>[0];
expect(__test.projectItem(libItem, "u").body).toBe("");
});
it("toSummary strips the body field", { timeout: 5000 }, () => {
const item = {
id: "x",
ownerId: "u",
title: "t",
kind: "k",
body: "secret",
createdAt: "2026-05-18T00:00:00Z",
updatedAt: "2026-05-18T00:00:00Z",
};
const summary = __test.toSummary(item) as Record<string, unknown>;
expect("body" in summary).toBe(false);
expect(summary["title"]).toBe("t");
});
it("updateItem without metadata patch covers no-metadata branch", {
timeout: 60_000,
}, async () => {
const service = createVaultService({
storage: createMemoryDrive(),
serverPepper: "test-pepper",
logger: noopLogger,
});
const handle = await service.openForPrincipal(principal, "Pwd-1234");
const created = await service.createItem(handle, {
title: "t",
kind: "k",
body: "b",
});
const updated = await service.updateItem(handle, created.id, {
title: "renamed",
});
expect(updated?.title).toBe("renamed");
await service.close(handle);
});
it("createItem without metadata still creates and roundtrips", {
timeout: 60_000,
}, async () => {
const service = createVaultService({
storage: createMemoryDrive(),
serverPepper: "test-pepper",
logger: noopLogger,
});
const handle = await service.openForPrincipal(principal, "Pwd-1234");
const created = await service.createItem(handle, {
title: "no-meta",
kind: "note",
body: "x",
});
expect(created.metadata).toBeUndefined();
await service.close(handle);
});
it("updateItem with only a metadata patch leaves title/kind/body unchanged", {
timeout: 60_000,
}, async () => {
const service = createVaultService({
storage: createMemoryDrive(),
serverPepper: "test-pepper",
logger: noopLogger,
});
const handle = await service.openForPrincipal(principal, "Pwd-1234");
const created = await service.createItem(handle, {
title: "orig",
kind: "note",
body: "b",
});
const updated = await service.updateItem(handle, created.id, {
metadata: { v: 1 },
});
expect(updated?.title).toBe("orig");
expect(updated?.metadata).toEqual({ v: 1 });
await service.close(handle);
});
it("NOOP_DRIVE_SHARING.createLink rejects with FORBIDDEN", {
timeout: 5000,
}, async () => {
let caught: unknown;
try {
await __test.NOOP_DRIVE_SHARING.createLink({
partitionId: "p",
itemId: "i",
expiresAt: null,
maxViews: null,
wrappedKey: new Uint8Array(0),
});
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(VaultPluginError);
});
it("NOOP_DRIVE_SHARING.revokeLink and isActive are inert", {
timeout: 5000,
}, async () => {
await expect(
__test.NOOP_DRIVE_SHARING.revokeLink("x")
).resolves.toBeUndefined();
expect(await __test.NOOP_DRIVE_SHARING.isActive("x")).toBe(false);
});
it("NOOP_VAULT_SYNC methods return inert snapshots", {
timeout: 5000,
}, async () => {
__test.NOOP_VAULT_SYNC.hydrate({ items: [], snapshotAt: 0 });
const pull = await __test.NOOP_VAULT_SYNC.pull();
const push = await __test.NOOP_VAULT_SYNC.push();
const sync = await __test.NOOP_VAULT_SYNC.sync();
expect(pull.merged).toEqual([]);
expect(push.merged).toEqual([]);
expect(sync.merged).toEqual([]);
expect(__test.NOOP_VAULT_SYNC.currentSnapshot().items).toEqual([]);
});
it("uses principal.id as fallback email when email is undefined", {
timeout: 60_000,
}, async () => {
const service = createVaultService({
storage: createMemoryDrive(),
serverPepper: "test-pepper",
logger: noopLogger,
});
const handle = await service.openForPrincipal(
{ id: "user:noemail", emailVerified: true, roles: [] },
"Pwd-1234"
);
expect(handle.principalId).toBe("user:noemail");
await service.close(handle);
});
});

View File

@@ -0,0 +1,387 @@
import { hkdfSync } from "node:crypto";
import type { Principal } from "@suaveplan/api-plugin-auth";
import { DEFAULT_ARGON2ID_PARAMS } from "@suaveplan/crypto-vault";
import type { LoggerLike } from "@suaveplan/types";
import {
type DriveLike,
type DriveSharingLike,
Vault,
type VaultConfig,
type VaultItem as VaultLibItem,
type VaultSyncLike,
} from "@suaveplan/vault";
import {
VAULT_PLUGIN_CODES,
VaultPluginError,
} from "../error-codes/error-codes.js";
import type {
VaultHandle,
VaultItem,
VaultItemSummary,
} from "../types/types.js";
/**
* Service slot registered by `createVaultPlugin` in the host's
* `ServiceRegistry`. Downstream plugins call
* `ctx.getService("vaultService")` to open per-principal vaults,
* enumerate ownership rows, and release handles.
*/
export interface VaultService {
/**
* Open the encrypted vault for `principal` using the supplied
* `password`. Returns an opaque `VaultHandle`; release it with
* `close(handle)` once operations are finished. Wrong password →
* `VaultPluginError(VAULT_PLUGIN_UNLOCK_FAILED)`.
*/
openForPrincipal(
principal: Principal,
password: string
): Promise<VaultHandle>;
/**
* List item summaries for `ownerId` from the SurrealDB ownership
* rows. Does NOT require the vault to be open — returns metadata
* only, never decrypted body. Implemented by `vault-service` to
* delegate to whichever query backend the registered handler chose.
*
* The default implementation returns an empty array — concrete
* resolution lives in the `routes/build-routes.ts` handler that
* couples the service to the request-scoped Hono context's
* SurrealDB adapter. The service contract is preserved so future
* background workers can list ownership rows without a request.
*/
listItemsForOwner(ownerId: string): Promise<VaultItemSummary[]>;
/**
* Read a single decrypted item from an opened vault.
*/
readItem(handle: VaultHandle, itemId: string): Promise<VaultItem | null>;
/**
* Create a new item in the opened vault. Returns the full decrypted
* record. `id` is generated by the vault library.
*/
createItem(
handle: VaultHandle,
input: {
title: string;
kind: string;
body: string;
metadata?: Record<string, unknown>;
}
): Promise<VaultItem>;
/**
* Update an existing item. Fields not in `patch` are preserved.
*/
updateItem(
handle: VaultHandle,
itemId: string,
patch: {
title?: string;
kind?: string;
body?: string;
metadata?: Record<string, unknown>;
}
): Promise<VaultItem | null>;
/**
* Delete an item from the vault. Returns `true` on success, `false`
* if the id was not present.
*/
deleteItem(handle: VaultHandle, itemId: string): Promise<boolean>;
/**
* Enumerate every item for the principal whose vault is open behind
* `handle`.
*/
listItems(handle: VaultHandle): Promise<VaultItem[]>;
/**
* Release a handle: lock the underlying vault and forget the
* internal state.
*/
close(handle: VaultHandle): Promise<void>;
}
/**
* Opaque internal record stored against each issued `VaultHandle`. The
* `WeakMap<VaultHandle, OpenVaultState>` is package-private so callers
* cannot inspect the underlying Vault instance.
*/
interface OpenVaultState {
readonly vault: Vault;
readonly principal: Principal;
}
/**
* Construction options for the canonical vault-service implementation.
*/
export interface CreateVaultServiceOptions {
readonly storage: DriveLike;
readonly serverPepper: string;
readonly logger: LoggerLike;
}
const PER_HANDLE_STATE = new WeakMap<VaultHandle, OpenVaultState>();
/** No-op `DriveSharingLike` — sharing routes ship in a follow-up plugin. */
const NOOP_DRIVE_SHARING: DriveSharingLike = {
createLink() {
return Promise.reject(
new VaultPluginError(
VAULT_PLUGIN_CODES.FORBIDDEN,
"vault sharing is not enabled in api-plugin-vault v1",
{ context: { statusCode: 403 } }
)
);
},
revokeLink() {
return Promise.resolve();
},
isActive() {
return Promise.resolve(false);
},
};
/** No-op `VaultSyncLike` — sync delegation lives in `@suaveplan/vault-sync`. */
const NOOP_VAULT_SYNC: VaultSyncLike = {
hydrate() {
/* no-op */
},
async pull() {
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
},
async push() {
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
},
async sync() {
return { merged: [], conflicts: [], deleted: [], durationMs: 0 };
},
currentSnapshot() {
return { items: [], snapshotAt: 0 };
},
};
/**
* Derive the per-principal 16-byte Argon2id salt deterministically from
* `(serverPepper, principalId)` via HKDF-SHA256. Bound to both inputs
* so a leak of the server pepper alone (without `principalId`) cannot
* be used to brute-force a known vault.
*/
function derivePrincipalSalt(
serverPepper: string,
principalId: string
): Uint8Array {
const ikm = Buffer.from(serverPepper, "utf8");
const salt = Buffer.from(principalId, "utf8");
const info = Buffer.from("suaveplan-vault-plugin-argon2id-salt", "utf8");
const derived = hkdfSync("sha256", ikm, salt, info, 16);
return new Uint8Array(derived);
}
/**
* Build a `VaultConfig` for the supplied principal, using the
* service-level pepper to derive a stable per-principal salt.
*/
function buildPrincipalVaultConfig(
principal: Principal,
storage: DriveLike,
serverPepper: string
): VaultConfig {
const salt = derivePrincipalSalt(serverPepper, principal.id);
return {
vaultId: principal.id,
email: principal.email ?? principal.id,
containerPath: `vaults/${principal.id}/container.bin`,
attachmentsPrefix: `vaults/${principal.id}/attachments`,
argon2id: DEFAULT_ARGON2ID_PARAMS,
argon2idSalt: salt,
drive: storage,
driveSharing: NOOP_DRIVE_SHARING,
sync: NOOP_VAULT_SYNC,
};
}
function projectItem(libItem: VaultLibItem, principalId: string): VaultItem {
const fields = libItem.fields ?? {};
const concealed = libItem.concealedFields ?? {};
const body = concealed["body"] ?? fields["body"] ?? "";
const metadataJson = fields["metadata"];
let metadata: Record<string, unknown> | undefined;
if (typeof metadataJson === "string" && metadataJson.length > 0) {
try {
const parsed: unknown = JSON.parse(metadataJson);
if (
parsed !== null &&
typeof parsed === "object" &&
!Array.isArray(parsed)
) {
metadata = parsed as Record<string, unknown>;
}
} catch {
metadata = undefined;
}
}
const result: VaultItem = {
id: libItem.id,
ownerId: principalId,
title: libItem.title,
kind: libItem.category,
...(metadata !== undefined ? { metadata } : {}),
body,
createdAt: new Date(libItem.createdAt).toISOString(),
updatedAt: new Date(libItem.updatedAt).toISOString(),
};
return result;
}
function toSummary(item: VaultItem): VaultItemSummary {
const { body: _body, ...summary } = item;
void _body;
return summary;
}
function getState(handle: VaultHandle): OpenVaultState {
const state = PER_HANDLE_STATE.get(handle);
if (state === undefined) {
throw new VaultPluginError(
VAULT_PLUGIN_CODES.FORBIDDEN,
"VaultHandle is invalid or has already been closed",
{ context: { statusCode: 403 } }
);
}
return state;
}
function buildHandle(principalId: string): VaultHandle {
return Object.freeze({
__brand: "VaultHandle" as const,
principalId,
});
}
/**
* Build the canonical `VaultService` implementation backed by an
* unlocked `@suaveplan/vault.Vault` instance per principal. Handles
* are short-lived: the route handler opens, performs the operation,
* then closes — there is no cross-request handle cache, so a forgotten
* password cannot let later requests bypass authentication.
*/
export function createVaultService(
options: CreateVaultServiceOptions
): VaultService {
const { storage, serverPepper, logger } = options;
return {
async openForPrincipal(principal, password) {
const config = buildPrincipalVaultConfig(
principal,
storage,
serverPepper
);
const vault = Vault.open(config);
try {
await vault.unlock(password, principal.id);
} catch (err) {
throw new VaultPluginError(
VAULT_PLUGIN_CODES.UNLOCK_FAILED,
"vault unlock failed for principal",
{
cause: err,
context: {
principalId: principal.id,
statusCode: 401,
},
}
);
}
const handle = buildHandle(principal.id);
PER_HANDLE_STATE.set(handle, { vault, principal });
return handle;
},
async listItemsForOwner(_ownerId) {
// Implementation note — see service docs. Real listing
// happens through `listItems(handle)` for the active vault.
// Background callers that do not have a password go through
// SurrealDB directly, which the route handler does via the
// injected `db` adapter; this fallback exists for type
// contract completeness.
logger.debug(
"vaultService.listItemsForOwner called without an active handle",
{ ownerId: _ownerId }
);
return [];
},
async listItems(handle) {
const { vault, principal } = getState(handle);
const items = vault.items.list();
return items.map((item) => projectItem(item, principal.id));
},
async readItem(handle, itemId) {
const { vault, principal } = getState(handle);
const item = vault.items.get(itemId);
if (item === null) return null;
return projectItem(item, principal.id);
},
async createItem(handle, input) {
const { vault, principal } = getState(handle);
const fieldsRecord: Record<string, string> = {};
if (input.metadata !== undefined) {
fieldsRecord["metadata"] = JSON.stringify(input.metadata);
}
const item = vault.items.create({
title: input.title,
category: input.kind,
fields: fieldsRecord,
concealedFields: { body: input.body },
tags: [],
favorite: false,
});
await vault.flush();
return projectItem(item, principal.id);
},
async updateItem(handle, itemId, patch) {
const { vault, principal } = getState(handle);
const existing = vault.items.get(itemId);
if (existing === null) return null;
const partial: {
title?: string;
category?: string;
fields?: Record<string, string>;
concealedFields?: Record<string, string>;
} = {};
if (patch.title !== undefined) partial.title = patch.title;
if (patch.kind !== undefined) partial.category = patch.kind;
if (patch.body !== undefined) {
partial.concealedFields = { body: patch.body };
}
if (patch.metadata !== undefined) {
partial.fields = {
metadata: JSON.stringify(patch.metadata),
};
}
const updated = vault.items.update(itemId, partial);
await vault.flush();
return projectItem(updated, principal.id);
},
async deleteItem(handle, itemId) {
const { vault } = getState(handle);
const existing = vault.items.get(itemId);
if (existing === null) return false;
vault.items.delete(itemId);
await vault.flush();
return true;
},
async close(handle) {
const state = PER_HANDLE_STATE.get(handle);
if (state === undefined) return;
try {
await state.vault.lock();
} finally {
PER_HANDLE_STATE.delete(handle);
}
},
};
}
/** Helpers exported for tests that need to project items directly. */
export const __test = {
projectItem,
toSummary,
derivePrincipalSalt,
NOOP_DRIVE_SHARING,
NOOP_VAULT_SYNC,
};

View File

@@ -0,0 +1,8 @@
export type {
VaultHandle,
VaultItem,
VaultItemSummary,
VaultPluginConfig,
VaultPluginOptions,
VaultUnlockExtractor,
} from "./types.js";

View File

@@ -0,0 +1,119 @@
# types/types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public TypeScript surface for `@suaveplan/api-plugin-vault`. Every
interface here is consumed by sibling modules (plugin factory, route
builder, vault service, schemas) AND re-exported from the package root so
downstream apps can author handlers, custom unlock extractors, and
service stubs against stable types. Splitting these into a dedicated
module keeps the rest of the source tree free of cross-feature imports
and makes it cheap to grep for the canonical shape of any plugin
artifact.
## Features
- `VaultPluginOptions` — what callers pass into `createVaultPlugin`.
Optional fields carry their defaults in the Zod schema; the factory
fills them in before constructing the descriptor.
- `VaultPluginConfig` — the resolved post-Zod shape with every default
applied. The `PluginDescriptor<VaultPluginConfig>` typed generic flows
this through every lifecycle hook.
- `VaultUnlockExtractor` — the signature for the per-request unlock
reader. Operators override the default `X-Vault-Unlock` header reader
here for cookie-based or JWT-claim-based setups.
- `VaultHandle` — opaque branded type returned by
`vaultService.openForPrincipal`. Callers MUST NOT inspect the internal
state; `vaultService.close(handle)` releases it.
- `VaultItem` + `VaultItemSummary` — the wire shapes returned by the 5
CRUD routes.
## Usage: Basic
```ts
import type {
VaultPluginOptions,
VaultUnlockExtractor,
} from "@suaveplan/api-plugin-vault";
const customExtractor: VaultUnlockExtractor = (c) =>
c.req.header("X-Custom-Unlock") ?? null;
const options: VaultPluginOptions = {
storage: driveAdapter,
unlockExtractor: customExtractor,
};
```
## Usage: Advanced
```ts
import type {
VaultHandle,
VaultItemSummary,
} from "@suaveplan/api-plugin-vault";
async function paginateOwnedItems(
ownerId: string,
cursor?: string
): Promise<{ summaries: VaultItemSummary[]; next?: string }> {
const summaries = await vaultService.listItemsForOwner(ownerId);
// ...cursor logic...
return { summaries };
}
```
The branded `VaultHandle` type guarantees a handle can only originate
from `vaultService.openForPrincipal` — accidental forgery (e.g.
constructing an object literal with `principalId`) is rejected by the
type system because the `__brand` field has a unique literal type.
## API Reference
### `VaultPluginOptions`
- `storage: DriveLike` — required; backing store for encrypted blobs.
- `routePrefix?: string` — default `/vault`.
- `maxItemSizeBytes?: number` — default `10 * 1024 * 1024`.
- `applySchemaOnInit?: boolean` — default `true`.
- `unlockExtractor?: VaultUnlockExtractor` — default
`headerExtractor("X-Vault-Unlock")`.
- `serverPepper?: string` — default constant; operators supply a
per-deployment value to harden offline brute-force on the per-principal
salt derivation.
- `logger?: LoggerLike` — falls back to `PluginContext.logger`.
### `VaultPluginConfig`
Resolved variant of `VaultPluginOptions` — every default applied.
### `VaultUnlockExtractor`
`(c: Context) => string | null`. Returning `null` raises
`VAULT_PLUGIN_UNLOCK_HEADER_MISSING` at the route boundary.
### `VaultHandle`
Branded type with `principalId: string` plus internal `__brand`.
### `VaultItem`, `VaultItemSummary`
Wire shapes. `VaultItem` extends `VaultItemSummary` with a decrypted
`body: string`.
## Implementation Notes
- `VaultHandle` carries only the `principalId` field at the type level
— the rest of the internal vault state is held in a private weak map
inside `vault-service.ts` so the handle cannot be inspected even with
`JSON.stringify`.
- `metadata` is `Record<string, unknown>` to support arbitrary
JSON-serialisable values without forcing a schema bump for new fields
client apps want to attach.
- `createdAt` / `updatedAt` are ISO-8601 strings on the wire, not
numbers. SurrealDB returns `datetime` values as ISO strings; the
serialisation boundary stays at the schema layer.

View File

@@ -0,0 +1,108 @@
import type { LoggerLike } from "@suaveplan/types";
import type { DriveLike } from "@suaveplan/vault";
import type { Context } from "hono";
/**
* Top-level options accepted by `createVaultPlugin`. Validated by
* `VaultPluginOptionsSchema` at the factory boundary; the resolved
* shape (with defaults applied) is the runtime `VaultPluginConfig`.
*/
export interface VaultPluginOptions {
/**
* The drive adapter that backs encrypted vault containers. The
* plugin writes one container per principal at
* `vaults/<principal-id>/container.bin`.
*/
readonly storage: DriveLike;
/**
* URL prefix where the plugin mounts its 5 routes. Defaults to
* `/vault`. Must start with `/` and not carry a trailing slash
* (the exact root `/` is allowed).
*/
readonly routePrefix?: string;
/**
* Hard cap on the byte length of a single item payload. Default is
* 10 MiB. Items exceeding this raise `VAULT_PLUGIN_ITEM_TOO_LARGE`.
*/
readonly maxItemSizeBytes?: number;
/**
* When `true` (the default), `onInit` applies
* `VAULT_OWNERSHIP_SCHEMA` against the resolved migration runner
* (preferred) or `SurrealAdapter` fallback. Set `false` when
* operators manage migrations externally.
*/
readonly applySchemaOnInit?: boolean;
/**
* Extract the per-request unlock secret from the Hono context.
* Defaults to `headerExtractor("X-Vault-Unlock")`. Returning
* `null` from the extractor causes `VAULT_PLUGIN_UNLOCK_HEADER_MISSING`
* on routes that require unlock.
*/
readonly unlockExtractor?: VaultUnlockExtractor;
/**
* Server-known pepper mixed into the per-principal Argon2id salt
* derivation. Defaults to a constant ASCII label; operators SHOULD
* supply a per-deployment value to harden offline brute-force.
*/
readonly serverPepper?: string;
/**
* Optional logger override. When omitted, the plugin uses the
* `PluginContext` logger supplied by the registry.
*/
readonly logger?: LoggerLike;
}
/** Fully-resolved options after Zod parse — every default has been applied. */
export interface VaultPluginConfig {
readonly storage: DriveLike;
readonly routePrefix: string;
readonly maxItemSizeBytes: number;
readonly applySchemaOnInit: boolean;
readonly unlockExtractor: VaultUnlockExtractor;
readonly serverPepper: string;
readonly logger?: LoggerLike;
}
/**
* Signature for a function that reads the unlock secret out of a Hono
* request context. Returning `null` signals "absent / not supplied" —
* the plugin maps that to `VAULT_PLUGIN_UNLOCK_HEADER_MISSING`.
*/
export type VaultUnlockExtractor = (c: Context) => string | null;
/**
* Opaque handle returned by `vaultService.openForPrincipal`. Internally
* boxes the unlocked `@suaveplan/vault.Vault` instance plus the
* principal id it was opened for; callers MUST treat it as opaque and
* release it via `vaultService.close(handle)`.
*
* The `__brand` field is removed from the runtime shape but ensures the
* type can only originate from `vaultService.openForPrincipal`.
*/
export interface VaultHandle {
readonly __brand: "VaultHandle";
readonly principalId: string;
}
/**
* Public summary of a vault item — what the listing endpoint returns
* per row. Body bytes are not included; clients fetch the full
* item via `GET /vault/items/:id`.
*/
export interface VaultItemSummary {
readonly id: string;
readonly ownerId: string;
readonly title: string;
readonly kind: string;
readonly metadata?: Readonly<Record<string, unknown>>;
readonly createdAt: string;
readonly updatedAt: string;
}
/**
* Full vault item — same shape as the summary plus the decrypted
* `body` field.
*/
export interface VaultItem extends VaultItemSummary {
readonly body: string;
}

View File

@@ -0,0 +1,112 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const pkgRoot = resolve(__dirname, "..");
const srcRoot = join(pkgRoot, "src");
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) out.push(...walk(full));
else out.push(full);
}
return out;
}
describe("@suaveplan/api-plugin-vault dep graph + layout", () => {
it("package.json carries required dependencies", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(pkgRoot, "package.json"), "utf8")
) as {
dependencies: Record<string, string>;
};
const deps = pkg.dependencies;
expect(deps["@suaveplan/api-plugin"]).toBe("workspace:^");
expect(deps["@suaveplan/api-plugin-auth"]).toBe("workspace:^");
expect(deps["@suaveplan/api-openapi"]).toBe("workspace:^");
expect(deps["@suaveplan/api-migrations"]).toBe("workspace:^");
expect(deps["@suaveplan/vault"]).toBe("workspace:^");
expect(deps["@suaveplan/crypto-vault"]).toBe("workspace:^");
expect(deps["@suaveplan/error"]).toBe("workspace:^");
expect(deps["@suaveplan/surrealdb"]).toBe("workspace:^");
expect(deps["@suaveplan/types"]).toBe("workspace:^");
expect(deps["hono"]).toBe("catalog:");
expect(deps["@hono/zod-openapi"]).toBe("catalog:server");
expect(deps["zod"]).toBe("catalog:forms");
expect(deps["@opentelemetry/api"]).toBe("catalog:observability");
});
it("package.json exposes the 10 documented subpath exports", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(pkgRoot, "package.json"), "utf8")
) as { exports: Record<string, unknown> };
const subs = Object.keys(pkg.exports).filter(
(k) => k.startsWith("./") && k !== "./package.json"
);
// 9 named feature subpaths (root `.` and `./package.json` are
// filtered separately).
expect(subs.length).toBe(9);
});
it("every subpath export resolves to a file within src/", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(pkgRoot, "package.json"), "utf8")
) as {
exports: Record<string, { import?: { default?: string } } | string>;
};
const missing: string[] = [];
for (const [sub, val] of Object.entries(pkg.exports)) {
if (sub === "./package.json") continue;
const defaultPath =
typeof val === "string"
? val
: (val?.import?.default ?? undefined);
if (defaultPath === undefined) continue;
const rel = defaultPath
.replace(/^\.\/dist\//, "")
.replace(/\.js$/, ".ts");
const srcPath = join(srcRoot, rel);
try {
statSync(srcPath);
} catch {
missing.push(`${sub} → ${srcPath}`);
}
}
expect(missing).toEqual([]);
});
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
const missing: string[] = [];
for (const file of walk(srcRoot)) {
if (!file.endsWith(".ts")) continue;
if (
file.endsWith(".test.ts") ||
file.endsWith(".spec.ts") ||
file.endsWith(".types.ts") ||
file.endsWith("/index.ts")
) {
continue;
}
const md = file.replace(/\.ts$/, ".md");
try {
statSync(md);
} catch {
missing.push(md);
}
}
expect(missing).toEqual([]);
});
it("README.md is present at package root", { timeout: 5000 }, () => {
const readme = readFileSync(join(pkgRoot, "README.md"), "utf8");
expect(readme.length).toBeGreaterThan(200);
expect(readme).toContain("@suaveplan/api-plugin-vault");
});
});

View File

@@ -0,0 +1,184 @@
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 { VAULT_PLUGIN_CODES } from "../../src/error-codes/error-codes.js";
import { defaultUnlockExtractor } from "../../src/plugin/unlock.js";
import { buildVaultRoutes } from "../../src/routes/build-routes.js";
import { createVaultService } from "../../src/services/vault-service.js";
import type { VaultPluginConfig } from "../../src/types/types.js";
import { createMemoryDrive } from "../setup/memory-drive.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function buildApp(principal: Principal) {
const drive = createMemoryDrive();
const service = createVaultService({
storage: drive,
serverPepper: "test-pepper",
logger: noopLogger,
});
const config: VaultPluginConfig = {
storage: drive,
routePrefix: "/vault",
maxItemSizeBytes: 10 * 1024 * 1024,
applySchemaOnInit: false,
unlockExtractor: defaultUnlockExtractor,
serverPepper: "test-pepper",
};
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 | 401 | 403 | 404 | 500
);
});
return { app, service, drive };
}
describe("cross-user-isolation", () => {
it("user A cannot see / modify user B's items", {
timeout: 120_000,
}, async () => {
const alice: Principal = {
id: "user:alice",
emailVerified: true,
roles: [],
};
const bob: Principal = {
id: "user:bob",
emailVerified: true,
roles: [],
};
// Alice's setup shares the drive with Bob's app — but each
// principal opens its own vault container.
const drive = createMemoryDrive();
function makeApp(principal: Principal) {
const service = createVaultService({
storage: drive,
serverPepper: "test-pepper",
logger: noopLogger,
});
const config: VaultPluginConfig = {
storage: drive,
routePrefix: "/vault",
maxItemSizeBytes: 10 * 1024 * 1024,
applySchemaOnInit: false,
unlockExtractor: defaultUnlockExtractor,
serverPepper: "test-pepper",
};
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 | 401 | 403 | 404 | 500
);
});
return { app };
}
// Bob creates an item.
const bobApp = makeApp(bob).app;
const bobCreate = await bobApp.request("/vault/items", {
method: "POST",
headers: {
"X-Vault-Unlock": "BobPwd-2026",
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "bob-secret",
kind: "credential",
body: "bob-content",
}),
});
expect(bobCreate.status).toBe(201);
const bobItem = (await bobCreate.json()) as {
data: { id: string };
};
// Alice tries to read Bob's item. Even though Alice supplies
// a valid X-Vault-Unlock header, her vault container is
// distinct from Bob's, so the lookup yields ITEM_NOT_FOUND.
const aliceApp = makeApp(alice).app;
const aliceRead = await aliceApp.request(
`/vault/items/${bobItem.data.id}`,
{
headers: { "X-Vault-Unlock": "AlicePwd-2026" },
}
);
expect(aliceRead.status).toBe(404);
const json = (await aliceRead.json()) as { error: { code: string } };
expect(json.error.code).toBe(VAULT_PLUGIN_CODES.ITEM_NOT_FOUND);
// Alice's own listing returns zero items.
const aliceList = await aliceApp.request("/vault/items", {
headers: { "X-Vault-Unlock": "AlicePwd-2026" },
});
const listJson = (await aliceList.json()) as {
meta: { total: number };
};
expect(listJson.meta.total).toBe(0);
});
it("makeApp helper is exported (sanity)", { timeout: 5000 }, () => {
const result = buildApp({
id: "x",
emailVerified: true,
roles: [],
});
expect(result.app).toBeDefined();
expect(result.service).toBeDefined();
});
});

View File

@@ -0,0 +1,216 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import type { AuthService } from "@suaveplan/api-plugin-auth";
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
import { EventBus } from "@suaveplan/events";
import { SurrealAdapter } from "@suaveplan/surrealdb";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
import { createVaultPlugin } from "../../src/plugin/plugin.js";
import { createMemoryDrive } from "../setup/memory-drive.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
let fixture: SurrealDBFixture | null = null;
let client: SurrealAdapter | null = null;
beforeAll(async () => {
fixture = new SurrealDBFixture();
const cfg = await fixture.start();
client = new SurrealAdapter({
url: cfg.url,
namespace: cfg.namespace,
database: cfg.database,
username: cfg.username,
password: cfg.password,
});
await client.connect();
await client.execute("DEFINE TABLE OVERWRITE user SCHEMAFULL;");
await client.execute(
"DEFINE FIELD OVERWRITE email ON user TYPE option<string>;"
);
await client.execute(
"DEFINE FIELD OVERWRITE phoneNumber ON user TYPE option<string>;"
);
await client.execute(
"DEFINE FIELD OVERWRITE createdAt ON user TYPE option<datetime> DEFAULT time::now() VALUE $before OR time::now();"
);
await client.execute(
"DEFINE FIELD OVERWRITE updatedAt ON user TYPE option<datetime> DEFAULT time::now() VALUE time::now();"
);
}, 180_000);
afterAll(async () => {
if (client) {
await client.disconnect();
client = null;
}
if (fixture) {
await fixture.stop();
fixture = null;
}
}, 60_000);
describe("full integration boot — auth + vault against real SurrealDB", () => {
it("sign-up → POST → GET → PATCH → DELETE /vault/items roundtrip against real SurrealDB", {
timeout: 180_000,
}, async () => {
if (client === null) throw new Error("client not ready");
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "vault-integration", version: "0.1.0" },
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
services.set("db", client);
// 1. Auth plugin — applies schema + registers authService +
// installs session middleware on the app.
const authPlugin = createAuthPlugin({
betterAuth: {
database: betterAuthSurrealAdapter({ client }),
secret: "test-secret-at-least-32-chars-long-for-better-auth",
baseURL: "http://localhost:3000/auth",
emailAndPassword: { enabled: true },
},
});
await authPlugin.onInit?.(
createPluginContext({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: authPlugin.defaults as never,
services,
})
);
// 2. Vault plugin — applies vault schema + registers vaultService
// + mounts 5 routes.
const drive = createMemoryDrive();
const vaultPlugin = createVaultPlugin({
storage: drive,
applySchemaOnInit: true,
});
await vaultPlugin.onInit?.(
createPluginContext({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: vaultPlugin.defaults as never,
services,
})
);
// Error-handler so VaultPluginError → JSON envelope w/ statusCode.
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 | 403 | 404 | 413 | 500
);
});
// 3. Sign up via Better-Auth.
const authService = services.get("authService") as AuthService;
expect(authService).toBeDefined();
const auth = authService.getBetterAuthInstance();
const signUp = (await auth.api.signUpEmail({
body: {
email: "vault-integration@example.test",
password: "Passw0rd!2026",
name: "Vault Integration",
},
asResponse: true,
})) as Response;
expect(signUp.status).toBeLessThan(400);
const cookie = signUp.headers.get("set-cookie") ?? "";
// 4. POST /vault/items
const createRes = await app.request("/vault/items", {
method: "POST",
headers: {
"X-Vault-Unlock": "MyVaultPwd-2026",
"Content-Type": "application/json",
Cookie: cookie.split(";")[0] ?? "",
},
body: JSON.stringify({
title: "API key",
kind: "credential",
body: "secret-body-value",
}),
});
expect(createRes.status).toBe(201);
const created = (await createRes.json()) as {
data: { id: string; title: string; body: string };
};
expect(created.data.body).toBe("secret-body-value");
const itemId = created.data.id;
// 5. GET /vault/items
const listRes = await app.request("/vault/items", {
headers: {
"X-Vault-Unlock": "MyVaultPwd-2026",
Cookie: cookie.split(";")[0] ?? "",
},
});
expect(listRes.status).toBe(200);
const list = (await listRes.json()) as {
meta: { total: number };
};
expect(list.meta.total).toBe(1);
// 6. PATCH /vault/items/:id
const patchRes = await app.request(`/vault/items/${itemId}`, {
method: "PATCH",
headers: {
"X-Vault-Unlock": "MyVaultPwd-2026",
"Content-Type": "application/json",
Cookie: cookie.split(";")[0] ?? "",
},
body: JSON.stringify({ title: "Renamed" }),
});
expect(patchRes.status).toBe(200);
// 7. DELETE /vault/items/:id
const delRes = await app.request(`/vault/items/${itemId}`, {
method: "DELETE",
headers: {
"X-Vault-Unlock": "MyVaultPwd-2026",
Cookie: cookie.split(";")[0] ?? "",
},
});
expect(delRes.status).toBe(200);
// 8. Final list should be empty.
const finalRes = await app.request("/vault/items", {
headers: {
"X-Vault-Unlock": "MyVaultPwd-2026",
Cookie: cookie.split(";")[0] ?? "",
},
});
const final = (await finalRes.json()) as { meta: { total: number } };
expect(final.meta.total).toBe(0);
});
});

View File

@@ -0,0 +1,90 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createVaultPlugin } from "../../src/plugin/plugin.js";
import { createMemoryDrive } from "../setup/memory-drive.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
describe("openapi-doc", () => {
it("served doc lists 5 /vault/items routes", {
timeout: 30_000,
}, async () => {
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "vault-test", version: "0.1.0" },
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: false,
});
await plugin.onInit?.(
createPluginContext({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as never,
services,
})
);
const routes = registry.getRoutesByPlugin("vault");
expect(routes.length).toBe(5);
registry.applyTo(app, "/openapi.json");
const res = await app.request("/openapi.json");
const doc = (await res.json()) as {
paths: Record<string, Record<string, unknown>>;
};
const vaultPaths = Object.keys(doc.paths).filter((p) =>
p.startsWith("/vault/")
);
expect(vaultPaths.length).toBeGreaterThanOrEqual(2);
expect(doc.paths["/vault/items"]).toBeDefined();
expect(doc.paths["/vault/items/{id}"]).toBeDefined();
});
it("served /openapi.json includes 5 /vault/items routes", {
timeout: 30_000,
}, async () => {
const app = new OpenAPIHono();
const registry = createOpenAPIRegistry({
info: { title: "vault-test", version: "0.1.0" },
});
const services = new Map<string, unknown>();
services.set("app", app);
services.set("openAPIRegistry", registry);
const plugin = createVaultPlugin({
storage: createMemoryDrive(),
applySchemaOnInit: false,
});
await plugin.onInit?.(
createPluginContext({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as never,
services,
})
);
const methods = registry
.getRoutesByPlugin("vault")
.map((r) => `${r.method.toUpperCase()} ${r.path}`);
expect(methods).toEqual([
"GET /vault/items",
"POST /vault/items",
"GET /vault/items/{id}",
"PATCH /vault/items/{id}",
"DELETE /vault/items/{id}",
]);
});
});

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { CreateVaultItemBodySchema } from "../src/schemas/vault-item.js";
describe("CreateVaultItemBodySchema fast-check size invariant", () => {
it("size invariant: payloads under 10 MiB always validate (200 runs)", {
timeout: 60_000,
}, () => {
fc.assert(
fc.property(
fc.record({
title: fc.string({ minLength: 1, maxLength: 64 }),
kind: fc.string({ minLength: 1, maxLength: 32 }),
body: fc.string({ maxLength: 4096 }),
}),
(sample) => {
const result = CreateVaultItemBodySchema.safeParse(sample);
// Payloads of arbitrary string body, title and kind
// pass the schema — schema does not enforce body
// size; that is enforced at the route layer.
expect(result.success).toBe(true);
}
),
{ numRuns: 200 }
);
});
});

View File

@@ -0,0 +1,128 @@
import {
VAULT_PLUGIN_CODES,
VaultPluginError,
} from "../../src/error-codes/error-codes.js";
import type { VaultService } from "../../src/services/vault-service.js";
import type {
VaultHandle,
VaultItem,
VaultItemSummary,
} from "../../src/types/types.js";
/**
* Lightweight in-memory `VaultService` used by unit tests so route
* coverage does not pay for a real Argon2id pipeline. Stores items in a
* per-principal Map.
*/
export interface FakeVaultServiceOptions {
/** Maps principalId → expected password. Wrong password rejects. */
readonly passwords?: Record<string, string>;
}
export function createFakeVaultService(
options?: FakeVaultServiceOptions
): VaultService {
const passwords = options?.passwords ?? {};
const byPrincipal = new Map<string, Map<string, VaultItem>>();
const handles = new WeakMap<VaultHandle, { principalId: string }>();
let idCounter = 0;
function bucket(principalId: string): Map<string, VaultItem> {
let b = byPrincipal.get(principalId);
if (b === undefined) {
b = new Map();
byPrincipal.set(principalId, b);
}
return b;
}
function getState(handle: VaultHandle) {
const s = handles.get(handle);
if (s === undefined) {
throw new VaultPluginError(
VAULT_PLUGIN_CODES.FORBIDDEN,
"fake handle invalid",
{ context: { statusCode: 403 } }
);
}
return s;
}
return {
async openForPrincipal(principal, password) {
const expected = passwords[principal.id];
if (expected !== undefined && expected !== password) {
throw new VaultPluginError(
VAULT_PLUGIN_CODES.UNLOCK_FAILED,
"fake unlock failed",
{ context: { statusCode: 401 } }
);
}
const handle: VaultHandle = Object.freeze({
__brand: "VaultHandle" as const,
principalId: principal.id,
});
handles.set(handle, { principalId: principal.id });
return handle;
},
async listItemsForOwner(ownerId) {
const b = byPrincipal.get(ownerId);
if (b === undefined) return [];
const out: VaultItemSummary[] = [];
for (const i of b.values()) {
const { body: _b, ...rest } = i;
void _b;
out.push(rest);
}
return out;
},
async listItems(handle) {
const { principalId } = getState(handle);
return Array.from(bucket(principalId).values());
},
async readItem(handle, itemId) {
const { principalId } = getState(handle);
return bucket(principalId).get(itemId) ?? null;
},
async createItem(handle, input) {
const { principalId } = getState(handle);
idCounter += 1;
const now = new Date().toISOString();
const item: VaultItem = {
id: `item-${idCounter}`,
ownerId: principalId,
title: input.title,
kind: input.kind,
body: input.body,
createdAt: now,
updatedAt: now,
...(input.metadata !== undefined
? { metadata: input.metadata }
: {}),
};
bucket(principalId).set(item.id, item);
return item;
},
async updateItem(handle, itemId, patch) {
const { principalId } = getState(handle);
const existing = bucket(principalId).get(itemId);
if (existing === undefined) return null;
const updated: VaultItem = {
...existing,
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.kind !== undefined ? { kind: patch.kind } : {}),
...(patch.body !== undefined ? { body: patch.body } : {}),
...(patch.metadata !== undefined
? { metadata: patch.metadata }
: {}),
updatedAt: new Date().toISOString(),
};
bucket(principalId).set(itemId, updated);
return updated;
},
async deleteItem(handle, itemId) {
const { principalId } = getState(handle);
return bucket(principalId).delete(itemId);
},
async close(handle) {
handles.delete(handle);
},
};
}

View File

@@ -0,0 +1,23 @@
import type { DriveLike } from "@suaveplan/vault";
/** Minimal in-memory `DriveLike` for tests. */
export function createMemoryDrive(): DriveLike {
const store = new Map<string, Uint8Array>();
return {
async read(path) {
const bytes = store.get(path);
if (bytes === undefined)
throw new Error(`memory-drive: missing path ${path}`);
return bytes;
},
async write(path, data) {
store.set(path, data);
},
async delete(path) {
return store.delete(path);
},
async exists(path) {
return store.has(path);
},
};
}

View File

@@ -0,0 +1,34 @@
/**
* Test setup for @suaveplan/api-plugin-vault.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it. Node 18+
* + Bun expose `crypto.subtle` / `crypto.getRandomValues` globally so no
* crypto polyfill is required.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-plugin-vault",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^@hono\//,
/^node:/,
"hono",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 60000,
hookTimeout: 120000,
},
});