Initial Commit
This commit is contained in:
166
packages/api-stack/api/api-plugin-vault/README.md
Normal file
166
packages/api-stack/api/api-plugin-vault/README.md
Normal 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.
|
||||
|
||||
127
packages/api-stack/api/api-plugin-vault/package.json
Normal file
127
packages/api-stack/api/api-plugin-vault/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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>();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { VaultPluginErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
VAULT_PLUGIN_CODES,
|
||||
VaultPluginError,
|
||||
} from "./error-codes.js";
|
||||
79
packages/api-stack/api/api-plugin-vault/src/index.ts
Normal file
79
packages/api-stack/api/api-plugin-vault/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getVaultPluginInstruments,
|
||||
resetVaultPluginInstruments,
|
||||
type VaultPluginInstruments,
|
||||
} from "./instruments.js";
|
||||
107
packages/api-stack/api/api-plugin-vault/src/otel/instruments.md
Normal file
107
packages/api-stack/api/api-plugin-vault/src/otel/instruments.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { createVaultPlugin } from "./plugin.js";
|
||||
export {
|
||||
defaultUnlockExtractor,
|
||||
extractUnlock,
|
||||
headerExtractor,
|
||||
} from "./unlock.js";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
93
packages/api-stack/api/api-plugin-vault/src/plugin/plugin.md
Normal file
93
packages/api-stack/api/api-plugin-vault/src/plugin/plugin.md
Normal 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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
215
packages/api-stack/api/api-plugin-vault/src/plugin/plugin.ts
Normal file
215
packages/api-stack/api/api-plugin-vault/src/plugin/plugin.ts
Normal 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}`;
|
||||
}
|
||||
98
packages/api-stack/api/api-plugin-vault/src/plugin/unlock.md
Normal file
98
packages/api-stack/api/api-plugin-vault/src/plugin/unlock.md
Normal 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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
50
packages/api-stack/api/api-plugin-vault/src/plugin/unlock.ts
Normal file
50
packages/api-stack/api/api-plugin-vault/src/plugin/unlock.ts
Normal 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;
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { buildVaultRoutes } from "./build-routes.js";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export type { SurrealLikeExecutor } from "./schema.js";
|
||||
export {
|
||||
applyVaultSchema,
|
||||
applyVaultSchemaViaRunner,
|
||||
VAULT_OWNERSHIP_SCHEMA,
|
||||
} from "./schema.js";
|
||||
97
packages/api-stack/api/api-plugin-vault/src/schema/schema.md
Normal file
97
packages/api-stack/api/api-plugin-vault/src/schema/schema.md
Normal 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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
113
packages/api-stack/api/api-plugin-vault/src/schema/schema.ts
Normal file
113
packages/api-stack/api/api-plugin-vault/src/schema/schema.ts
Normal 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 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
24
packages/api-stack/api/api-plugin-vault/src/schemas/index.ts
Normal file
24
packages/api-stack/api/api-plugin-vault/src/schemas/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
>;
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CreateVaultServiceOptions,
|
||||
createVaultService,
|
||||
type VaultService,
|
||||
} from "./vault-service.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
VaultHandle,
|
||||
VaultItem,
|
||||
VaultItemSummary,
|
||||
VaultPluginConfig,
|
||||
VaultPluginOptions,
|
||||
VaultUnlockExtractor,
|
||||
} from "./types.js";
|
||||
119
packages/api-stack/api/api-plugin-vault/src/types/types.md
Normal file
119
packages/api-stack/api/api-plugin-vault/src/types/types.md
Normal 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.
|
||||
108
packages/api-stack/api/api-plugin-vault/src/types/types.ts
Normal file
108
packages/api-stack/api/api-plugin-vault/src/types/types.ts
Normal 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;
|
||||
}
|
||||
112
packages/api-stack/api/api-plugin-vault/tests/dep-graph.test.ts
Normal file
112
packages/api-stack/api/api-plugin-vault/tests/dep-graph.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-vault/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-vault/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-vault/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-plugin-vault/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
38
packages/api-stack/api/api-plugin-vault/vite.config.ts
Normal file
38
packages/api-stack/api/api-plugin-vault/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/api-stack/api/api-plugin-vault/vitest.config.ts
Normal file
33
packages/api-stack/api/api-plugin-vault/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user