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,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.