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,127 @@
# `admin-api.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createAdminApi` builds the host-side Hono sub-router that exposes the
design §8.4 admin surface — every operational endpoint the host
publishes for plugin install / uninstall / start / stop / reload /
rollback / restart, plus the read views on plugin state, capabilities,
and health. The router is host-owned (not plugin-owned) so it short-
circuits the plugin pipeline entirely: requests resolve a principal,
enforce a `host:admin` scope, consume a per-principal rate-limit
bucket, dedupe via `Idempotency-Key`, dispatch the driver call, and
emit an audit envelope on every outcome.
The returned `Hono` instance is unmounted — the host bootstrap calls
`.route('/admin', adminApi)` onto its parent app when `adminPrefix` is
non-null, OR drops the router entirely when the operator configures
`adminPrefix: null` for sidecar-only deployments.
## Features
- Every design §8.4 endpoint (`GET /plugins`, `/plugins/:name`,
`/plugins/:name/journal`, `/capabilities`, `/capabilities/:id`,
`/health`; `POST /plugins/:name/{install,uninstall,start,stop,
reload,rollback,restart}`).
- Principal resolver port: deployments wire `@suaveplan/api-auth`'s
`SessionResolver` behind the narrow `AdminPrincipalResolver`
interface.
- `host:admin` scope enforced on every endpoint (configurable).
- Per-principal token-bucket rate limiter: 60 reads/min, 1
destructive/min by default.
- Mutating endpoints require `Idempotency-Key` — replays within the
TTL window raise `ADMIN.IDEMPOTENCY_KEY_REPLAY`.
- Audit envelope (`AdminAuditRecord`) emitted on every outcome
through the operator-supplied `AdminAuditSink`.
- OTel `host.admin_action_total{action, outcome[, plugin]}` counter +
`host.admin_call_duration_ms` histogram recorded per call.
- Zod-validated request bodies for `install`, `uninstall`, `reload`,
`rollback`.
## Usage: Basic
```ts
import { Hono } from "hono";
import {
createAdminApi,
MemoryAdminAuditSink,
} from "@suaveplan/api-host/admin-api";
const adminApi = createAdminApi({
lifecycle,
reload,
capabilities,
ledger,
instruments,
audit: new MemoryAdminAuditSink(),
resolver: {
resolve: (c) => ({ id: "alice", scopes: ["host:admin"] }),
},
});
const app = new Hono();
app.route("/admin", adminApi);
```
## Usage: Advanced
```ts
import { InMemoryIdempotencyStore } from "@suaveplan/api-host/admin-api";
const idempotency = new InMemoryIdempotencyStore();
const adminApi = createAdminApi({
/* ... */,
idempotencyStore: idempotency,
idempotencyTtlMs: 60_000,
readRatePerMinute: 120,
destructiveRatePerMinute: 5,
requiredScope: "host:admin",
health: async () => "ok",
});
// Drive a mutating endpoint with the required Idempotency-Key.
const res = await adminApi.request("/plugins/billing/start", {
method: "POST",
headers: { "Idempotency-Key": "op-001" },
});
```
## API Reference
- `createAdminApi(options): Hono` — constructs the router.
- `AdminApiOptions` — `{ lifecycle, reload, capabilities, ledger,
instruments, resolver, audit, requiredScope?, now?, health?,
idempotencyStore?, idempotencyTtlMs?, readRatePerMinute?,
destructiveRatePerMinute? }`.
- `AdminPrincipal` — narrow `{ id, scopes }` shape consumed by the
resolver.
- `AdminPrincipalResolver` — `{ resolve(ctx): Promise<AdminPrincipal |
null> | AdminPrincipal | null }`.
- `AdminAuditRecord` — `{ at, principal, action, plugin, params,
outcome, cause? }`.
- `AdminAuditSink` — `{ emit(record): Promise<void> | void }`.
## Implementation Notes
`withGuard` is the per-endpoint pre-handler. It runs in this order:
principal resolution → scope check → rate-limit consume → (mutating
only) idempotency-key check + record → driver dispatch → audit emit
→ metric record. The function is exhaustive in its error paths —
every failure shape produces an audit "failure" envelope so the audit
trail captures denied requests just like accepted ones.
The router never installs CSRF or body-size limits itself — those
are responsibilities of the parent app's middleware stack. The
router DOES enforce JSON content-type on mutating endpoints (any
mismatch yields `ADMIN.INVALID_REQUEST`), since the Zod parsers
expect JSON.
When the operator-supplied audit sink throws, the admin call is NOT
failed — the failure is recorded via the
`host.admin_action_total{action="host.audit.emit", outcome="fail"}`
counter so observability captures the audit-pipeline incident
without blocking the operational action.

View File

@@ -0,0 +1,73 @@
# `audit-sink.ts`
## Purpose
`MemoryAdminAuditSink` is the in-memory default audit sink for the
host's admin API. Every admin endpoint emits a structured
`AdminAuditRecord` after the request settles — the sink decides what
to do with it. The in-memory sink records into a local array suitable
for tests + dev; production deployments swap in a sink that forwards
to `@suaveplan/audit-logging`, syslog, or a managed audit pipeline.
The sink interface is intentionally narrow (`emit(record)`) so the
admin API never depends on a specific audit backend. The
`AdminAuditRecord` shape carries the principal id, action,
optional plugin name, request params, outcome (success / failure),
and the failure cause when applicable.
## Features
- Zero-dependency in-memory recorder.
- `snapshot()` reads without clearing — useful for asserting state
across multiple admin calls.
- `drain()` reads + clears in one atomic step.
- Implements the public `AdminAuditSink` interface so it composes with
the admin API's mounting helper.
## Usage: Basic
```ts
import { MemoryAdminAuditSink } from "@suaveplan/api-host/admin-api";
const sink = new MemoryAdminAuditSink();
sink.emit({
at: new Date().toISOString(),
principal: { id: "alice", scopes: ["host:admin"] },
action: "plugin.start",
plugin: "@acme/billing",
params: {},
outcome: "success",
});
console.log(sink.snapshot().length); // 1
```
## Usage: Advanced
```ts
// In an integration test — drain between scenarios.
const sink = new MemoryAdminAuditSink();
// ... hit admin endpoint ...
const first = sink.drain();
// ... hit admin endpoint again ...
const second = sink.drain();
```
## API Reference
- `new MemoryAdminAuditSink()` — zero-arg ctor.
- `emit(record)` — append to the in-memory log.
- `snapshot(): readonly AdminAuditRecord[]` — copy without clearing.
- `drain(): readonly AdminAuditRecord[]` — copy + clear.
## Implementation Notes
`emit` is synchronous for the in-memory case — the admin API treats
the return value as `Promise<void> | void` so an async sink (HTTP
POST, IPC) plugs in identically. A failed emit raises
`ADMIN.AUDIT_EMIT_FAILED` on the request boundary — the admin call
is still considered a success because the side effect already
occurred, but the audit failure is logged.
The sink never persists past process exit. Production sinks MUST
ship audit records to durable storage before acknowledging the emit;
otherwise tamper-evidence is impossible to assert.

View File

@@ -0,0 +1,66 @@
# `idempotency-store.ts`
## Purpose
`InMemoryIdempotencyStore` is the process-local default for the admin
API's `Idempotency-Key` enforcement. Mutating admin endpoints (install,
uninstall, start, stop, reload, rollback, restart) require the caller
to supply a unique key per logical operation; a replayed key within the
TTL window raises `ADMIN.IDEMPOTENCY_KEY_REPLAY`. The store is the
piece of state that records every accepted key so a replay can be
detected.
The store is deliberately thin: a `Map<string, number>` where the
value is the epoch-ms expiry. The TTL is enforced on every `has()` via
the `prune()` sweep — expired entries are evicted lazily so a long-idle
process doesn't carry stale keys forever. Production deployments
replace it with a Redis-backed implementation sharing the same
`IdempotencyStore` interface.
## Features
- Process-local Map-backed storage.
- Lazy TTL pruning on every `has()` call.
- Injectable clock for deterministic tests.
- Zero allocations on the cache hit path.
## Usage: Basic
```ts
import { InMemoryIdempotencyStore } from "@suaveplan/api-host/admin-api";
const store = new InMemoryIdempotencyStore();
store.set("op-1", Date.now() + 60_000);
store.has("op-1"); // true
```
## Usage: Advanced
```ts
// Inject a fake clock so tests can fast-forward past the TTL window.
let now = 1000;
const store = new InMemoryIdempotencyStore(() => now);
store.set("op-1", 1500);
store.has("op-1"); // true
now = 1600;
store.has("op-1"); // false — entry expired and was evicted
```
## API Reference
- `new InMemoryIdempotencyStore(now?)` — defaults to `Date.now`.
- `has(key): boolean` — runs the prune sweep then checks membership.
- `set(key, expiresAt): void` — record `key` with absolute epoch-ms
expiry.
## Implementation Notes
The prune sweep walks every entry on every `has()`. This is fine for
the admin API's expected scale (mutating ops are rate-limited to 1/min
per principal). A Redis-backed production store amortises the cost via
the server-side TTL — no client-side sweep is needed.
The store intentionally does NOT expose a `delete()` method: keys
are immutable once recorded and only evicted by TTL expiry. This
matches HTTP RFC 7231's idempotency semantics — a successfully-replayed
request returns the original response, never a fresh result.

View File

@@ -0,0 +1,64 @@
# `rate-limiter.ts`
## Purpose
`AdminRateLimiter` enforces the design §8.4 per-principal rate limits
on every admin endpoint. Read endpoints share a 60-calls-per-minute
bucket; destructive endpoints share a 1-per-minute bucket. Both buckets
are scoped per principal id so a misbehaving operator cannot starve
the host of admin throughput for everyone else.
The limiter is a thin wrapper around `@suaveplan/rate-limiter`'s
`TokenBucket` — one bucket per `(principalId, kind)` pair, allocated
lazily on first contact. The refill rate is set so the bucket starts
full and drains to zero after `capacity` consecutive calls in a
minute; refill is linear, so a steady stream is permitted at the
documented rate. Bursts up to `capacity` are allowed.
## Features
- Per-principal isolation — one bucket pair per id.
- Lazy allocation — principals that never hit admin are zero-cost.
- Token-bucket refill maps `capacity` calls to one minute.
- Injectable clock for deterministic tests.
## Usage: Basic
```ts
import { AdminRateLimiter } from "@suaveplan/api-host/admin-api";
const limiter = new AdminRateLimiter(60, 1);
limiter.consume("alice", "read"); // true
limiter.consume("alice", "destructive"); // true
limiter.consume("alice", "destructive"); // false (until refill)
```
## Usage: Advanced
```ts
let now = 0;
const limiter = new AdminRateLimiter(60, 1, () => now);
limiter.consume("alice", "destructive"); // true
limiter.consume("alice", "destructive"); // false
now = 60_001;
limiter.consume("alice", "destructive"); // true — refilled
```
## API Reference
- `new AdminRateLimiter(readPerMinute, destructivePerMinute, clock?)`
- `consume(principalId, kind): boolean` — `true` on success, `false`
when rate-limited.
## Implementation Notes
`TokenBucket`'s `consume(1)` updates the bucket's internal counter and
returns a `RateLimitResult`. The admin API only cares about the
`allowed` boolean — it surfaces `429` + `Retry-After` purely from
constants when `consume` returns `false`.
The principal-id key is the rate-limit cardinality bound. A
deployment with many distinct admin principals carries a small
memory cost (~80 bytes per bucket pair) — gateways should rate-limit
admin traffic at the edge before it reaches the host if extreme
multi-tenant cardinality is anticipated.