first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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.