first commit
This commit is contained in:
127
docs/api-stack/api/api-host/src/admin-api/admin-api.md
Normal file
127
docs/api-stack/api/api-host/src/admin-api/admin-api.md
Normal 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.
|
||||
73
docs/api-stack/api/api-host/src/admin-api/audit-sink.md
Normal file
73
docs/api-stack/api/api-host/src/admin-api/audit-sink.md
Normal 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.
|
||||
@@ -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.
|
||||
64
docs/api-stack/api/api-host/src/admin-api/rate-limiter.md
Normal file
64
docs/api-stack/api/api-host/src/admin-api/rate-limiter.md
Normal 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.
|
||||
Reference in New Issue
Block a user