first commit
This commit is contained in:
166
docs/api-stack/api/api-plugin-vault/README.md
Normal file
166
docs/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.
|
||||
|
||||
@@ -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.
|
||||
107
docs/api-stack/api/api-plugin-vault/src/otel/instruments.md
Normal file
107
docs/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.
|
||||
93
docs/api-stack/api/api-plugin-vault/src/plugin/plugin.md
Normal file
93
docs/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.
|
||||
98
docs/api-stack/api/api-plugin-vault/src/plugin/unlock.md
Normal file
98
docs/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.
|
||||
103
docs/api-stack/api/api-plugin-vault/src/routes/build-routes.md
Normal file
103
docs/api-stack/api/api-plugin-vault/src/routes/build-routes.md
Normal 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`.
|
||||
97
docs/api-stack/api/api-plugin-vault/src/schema/schema.md
Normal file
97
docs/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.
|
||||
93
docs/api-stack/api/api-plugin-vault/src/schemas/options.md
Normal file
93
docs/api-stack/api/api-plugin-vault/src/schemas/options.md
Normal 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.
|
||||
114
docs/api-stack/api/api-plugin-vault/src/schemas/vault-item.md
Normal file
114
docs/api-stack/api/api-plugin-vault/src/schemas/vault-item.md
Normal 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.
|
||||
@@ -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,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.
|
||||
119
docs/api-stack/api/api-plugin-vault/src/types/types.md
Normal file
119
docs/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.
|
||||
Reference in New Issue
Block a user