Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of error codes thrown across the worker → main `SecretsLike`
|
||||
proxy boundary. The codes here are distinct from the `SECRETS.*` namespace
|
||||
owned by `@suaveplan/api-secrets` so an operator reading a log line can tell
|
||||
at a glance whether the failure originated in the proxy transport (whitelist
|
||||
rejection, disposed proxy, transport-side not-found) versus the concrete
|
||||
underlying source. This separation matters during incident response: a
|
||||
`SECRETS_PROXY.KEY_NOT_PERMITTED` is a sandbox-escape attempt, whereas a
|
||||
`SECRETS.SECRET_NOT_FOUND` is a misconfigured deployment.
|
||||
|
||||
## Features
|
||||
|
||||
- Three stable codes under the `SECRETS_PROXY` namespace, declared with
|
||||
`defineCodes` so the union type is statically inferrable.
|
||||
- `SecretsProxyError` class with the canonical `error.name = "SecretsProxyError"`
|
||||
pinned by the constructor so structured loggers can aggregate every
|
||||
proxy-domain failure under one bucket without inspecting the code.
|
||||
- Full `SuaveplanError` ancestry — `cause`, `context`, `timestamp`, `traceId`
|
||||
preserved across the wire by `api-worker-rpc`'s error-reconstruction.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
SECRETS_PROXY_CODES,
|
||||
SecretsProxyError,
|
||||
} from "@suaveplan/api-secrets-worker-proxy/error-codes";
|
||||
|
||||
throw new SecretsProxyError(
|
||||
SECRETS_PROXY_CODES.KEY_NOT_PERMITTED,
|
||||
"error.secrets_proxy.key_not_permitted",
|
||||
{ context: { name: "STRIPE_KEY" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
SECRETS_PROXY_CODES,
|
||||
type SecretsProxyCode,
|
||||
SecretsProxyError,
|
||||
} from "@suaveplan/api-secrets-worker-proxy/error-codes";
|
||||
|
||||
function isProxyFailure(err: unknown): err is SecretsProxyError {
|
||||
return err instanceof SecretsProxyError;
|
||||
}
|
||||
|
||||
function classify(err: unknown): SecretsProxyCode | "OTHER" {
|
||||
if (!isProxyFailure(err)) return "OTHER";
|
||||
return err.code;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `SECRETS_PROXY_CODES` — frozen record `{ KEY_NOT_PERMITTED, KEY_NOT_FOUND, PROXY_DISPOSED }`.
|
||||
- `SecretsProxyCode` — union of fully-qualified `SECRETS_PROXY.*` codes.
|
||||
- `SECRETS_PROXY_ERROR_NAME` — literal `"SecretsProxyError"`.
|
||||
- `class SecretsProxyError extends SuaveplanError<SecretsProxyCode>` — public constructor `(code, messageKey, options?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `defineCodes` produces an immutable record; the prefix is supplied
|
||||
once and combined with each leaf to form the wire code.
|
||||
- The constructor widens the abstract base's protected constructor to
|
||||
public so callers throw without per-call subclasses.
|
||||
- The `name` field is pinned AFTER `super()` runs because the base
|
||||
resets it; this is the documented pattern across the project.
|
||||
@@ -0,0 +1,76 @@
|
||||
# `main-side`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Mounts a concrete `SecretsLike` adapter onto a main-process `MessagePort`
|
||||
so a worker-isolated API plugin can request secrets across the
|
||||
`api-worker-rpc` transport without ever holding a value in its own heap
|
||||
longer than the immediate caller scope. The allowed-keys whitelist is
|
||||
enforced on the MAIN side BEFORE the secret value crosses the wire —
|
||||
this is the sandbox-gating invariant from the API plugin system
|
||||
design doc §5.2b. A worker reading a value it did not declare in its
|
||||
manifest is a security incident, and pre-marshal enforcement is the
|
||||
mechanism that turns "incident" into "rejected request."
|
||||
|
||||
## Features
|
||||
|
||||
- `createSecretsProxyServer(port, secrets, allowedKeys)` returns a
|
||||
`WorkerRpcServer` whose `close()` tears down the transport.
|
||||
- Three served methods: `get`, `require`, `list`. Every `get`/`require`
|
||||
validates the name argument with Zod (non-empty string) at the wire
|
||||
boundary even though the typed proxy delivers a string.
|
||||
- `list()` returns the FROZEN snapshot of `allowedKeys` — NOT the
|
||||
underlying source's `list()` output. The worker is therefore unable
|
||||
to enumerate names outside its manifest, regardless of source state.
|
||||
- Source errors from `require()` are wrapped as
|
||||
`SECRETS_PROXY.KEY_NOT_FOUND` so the proxy boundary owns a single
|
||||
not-found code; the original error is preserved on `cause`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createSecretsProxyServer } from "@suaveplan/api-secrets-worker-proxy/main-side";
|
||||
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
|
||||
|
||||
const ports = createInProcessPortPair();
|
||||
const server = createSecretsProxyServer(ports.serverPort, mySecrets, [
|
||||
"STRIPE_KEY",
|
||||
"DATABASE_URL",
|
||||
]);
|
||||
// hand `ports.clientPort` to the worker; tear down with `server.close()`.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createSecretsProxyServer } from "@suaveplan/api-secrets-worker-proxy/main-side";
|
||||
|
||||
// Per-plugin sandbox: each plugin gets its own port + its own
|
||||
// allowed-keys list derived from manifest.secrets[].
|
||||
for (const plugin of plugins) {
|
||||
const allowed = plugin.manifest.secrets.map((d) => d.name);
|
||||
const server = createSecretsProxyServer(plugin.port, vaultSecrets, allowed);
|
||||
plugin.onDispose(() => {
|
||||
server.close("plugin-unloaded");
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createSecretsProxyServer(port: PortLike, secrets: SecretsLike, allowedKeys: ReadonlyArray<string>): WorkerRpcServer`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `allowSet` is built once at construction; lookups are O(1).
|
||||
- `frozenList` is `Object.freeze`d at construction so even the served
|
||||
handler cannot mutate it.
|
||||
- Both `get` and `require` re-validate the params tuple against
|
||||
`nameArgsSchema` — defence-in-depth against forged inbound frames.
|
||||
- The `require` path wraps the source's `SECRETS.SECRET_NOT_FOUND` into
|
||||
`SECRETS_PROXY.KEY_NOT_FOUND` so the worker sees a single, stable
|
||||
transport-domain code.
|
||||
@@ -0,0 +1,75 @@
|
||||
# `redaction`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Re-exports the canonical `<redacted>` marker and `redactSecret` helper
|
||||
from `@suaveplan/api-secrets`, plus an additional `redactSecretLine`
|
||||
worker-side paranoid helper that scrubs values out of arbitrary log
|
||||
lines. The redaction surface is duplicated here so worker code under
|
||||
sandbox isolation can scrub its log pipe without taking a direct
|
||||
dependency on `@suaveplan/api-secrets` (which transitively pulls in the
|
||||
canonical Zod schemas it does not need). Logging is the most common
|
||||
secret-leak path and an explicit, documented helper at the proxy
|
||||
package boundary is a defence-in-depth measure.
|
||||
|
||||
## Features
|
||||
|
||||
- `REDACTED_MARKER` — the literal `"<redacted>"` token every sink
|
||||
in the stack grep-matches.
|
||||
- `redactSecret(value)` — replaces any secret string with the marker,
|
||||
intentionally ignoring contents so empty-vs-set distinctions cannot
|
||||
leak through observation.
|
||||
- `redactSecretLine(line, name)` — finds `name = value` or
|
||||
`name: value` patterns in the supplied line and replaces the value
|
||||
with the marker, preserving the name so an operator can still tell
|
||||
which secret was logged.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
REDACTED_MARKER,
|
||||
redactSecret,
|
||||
redactSecretLine,
|
||||
} from "@suaveplan/api-secrets-worker-proxy/redaction";
|
||||
|
||||
const masked = redactSecret(value); // "<redacted>"
|
||||
const scrubbed = redactSecretLine(
|
||||
"STRIPE_KEY=sk_live_abc loaded",
|
||||
"STRIPE_KEY"
|
||||
); // "STRIPE_KEY=<redacted> loaded"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { redactSecretLine } from "@suaveplan/api-secrets-worker-proxy/redaction";
|
||||
|
||||
function scrubAll(line: string, names: readonly string[]): string {
|
||||
return names.reduce(
|
||||
(acc, name) => redactSecretLine(acc, name),
|
||||
line
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(scrubAll(rawLine, manifest.secrets.map((d) => d.name)));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `REDACTED_MARKER: "<redacted>"` — canonical token.
|
||||
- `redactSecret(value: string): "<redacted>"` — returns the marker.
|
||||
- `redactSecretLine(line: string, name: string): string` — name-aware line scrub.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Regex meta-characters in the supplied name are escaped before
|
||||
compilation so callers can safely pass names containing `.` or `$`.
|
||||
- An empty `name` is a no-op so callers can unconditionally invoke
|
||||
the helper without a guard.
|
||||
- The value-shape pattern after the delimiter is `\S+` (conservative);
|
||||
callers needing multi-token values should pre-tokenise.
|
||||
@@ -0,0 +1,82 @@
|
||||
# `testing`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Real, no-mock testing harness for the worker-side secrets proxy. Wraps
|
||||
a real `MessageChannel` (via `api-worker-rpc`'s `createInProcessPortPair`),
|
||||
a real in-memory `SecretsLike` adapter (`Map`-backed, throws real
|
||||
`SECRETS.SECRET_NOT_FOUND` from `require`), the main-side proxy server,
|
||||
and the worker-side proxy client into a single `SecretsProxyFixture`
|
||||
that downstream tests construct in a single call. The harness is the
|
||||
exact surface every downstream Stage-4 source uses to demonstrate
|
||||
conformance — the in-memory adapter is the reference implementation.
|
||||
|
||||
## Features
|
||||
|
||||
- `InMemorySecrets` — real `SecretsLike` implementation with a
|
||||
`Map` store, `set/delete` mutation accessors for write-then-read
|
||||
cache-bust tests, and full `get/require/list` parity.
|
||||
- `createSecretsProxyFixture(opts)` — wires up channel + source +
|
||||
server + client; returns the four artifacts plus a `close()` that
|
||||
tears them all down in order.
|
||||
- Returned client is seeded with `initialList = allowedKeys` so the
|
||||
sync `list()` accessor works without a round-trip.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createSecretsProxyFixture } from "@suaveplan/api-secrets-worker-proxy/testing";
|
||||
|
||||
const fx = createSecretsProxyFixture({
|
||||
seed: { STRIPE_KEY: "sk_test_abc" },
|
||||
allowedKeys: ["STRIPE_KEY"],
|
||||
});
|
||||
try {
|
||||
const v = await fx.client.require("STRIPE_KEY");
|
||||
} finally {
|
||||
fx.close();
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createSecretsProxyFixture } from "@suaveplan/api-secrets-worker-proxy/testing";
|
||||
|
||||
// Demonstrate no-cache invariant: mutate the source mid-test and
|
||||
// observe the worker proxy seeing the rotated value.
|
||||
const fx = createSecretsProxyFixture({
|
||||
seed: { K: "v1" },
|
||||
allowedKeys: ["K"],
|
||||
});
|
||||
try {
|
||||
await fx.client.get("K"); // "v1"
|
||||
fx.source.set("K", "v2");
|
||||
await fx.client.get("K"); // "v2" — no caching
|
||||
} finally {
|
||||
fx.close();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class InMemorySecrets implements SecretsLike` — `get`, `require`,
|
||||
`list`, `set`, `delete`.
|
||||
- `createSecretsProxyFixture(opts: SecretsProxyFixtureOptions): SecretsProxyFixture`
|
||||
- `interface SecretsProxyFixtureOptions { seed?: Record<string,string>; allowedKeys: readonly string[] }`
|
||||
- `interface SecretsProxyFixture { ports, source, server, client, close() }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `InMemorySecrets.require` throws a real `SecretsError` carrying
|
||||
`SECRETS.SECRET_NOT_FOUND`; the main-side proxy then re-wraps it as
|
||||
`SECRETS_PROXY.KEY_NOT_FOUND` so the worker sees the transport-domain
|
||||
code.
|
||||
- `close()` tears down client first, then server, then ports —
|
||||
matches the documented teardown order in `api-worker-rpc`.
|
||||
- The frozen array returned by `list()` is a snapshot at the time of
|
||||
the call; mutations after that point are not reflected.
|
||||
@@ -0,0 +1,75 @@
|
||||
# `worker-side`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Worker-side `SecretsLike` proxy. Every method call is marshalled across
|
||||
the `api-worker-rpc` transport to the main-side server, and the proxy
|
||||
intentionally maintains NO cache of secret values — neither in module
|
||||
state nor in instance state. Two sequential `get("X")` calls produce
|
||||
two main-side invocations, so a rotated secret is picked up immediately
|
||||
on the next access, and a compromised worker cannot exfiltrate values
|
||||
from cached state because no such state exists.
|
||||
|
||||
## Features
|
||||
|
||||
- `createSecretsProxyClient(port, opts?)` returns a `SecretsProxyClient`
|
||||
that satisfies the canonical `SecretsLike` interface plus `close()`.
|
||||
- No value caching — every `get`/`require` round-trips to main-side.
|
||||
- `list()` is sync on the canonical contract but async on the wire, so
|
||||
the proxy resolves it via a frozen snapshot supplied either at
|
||||
construction (`initialList`, manifest-derived) or via the hidden
|
||||
`__refreshList()` accessor for advanced refresh-by-demand callers.
|
||||
- `close()` is idempotent; subsequent calls throw
|
||||
`SECRETS_PROXY.PROXY_DISPOSED`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createSecretsProxyClient } from "@suaveplan/api-secrets-worker-proxy/worker-side";
|
||||
|
||||
const secrets = createSecretsProxyClient(workerSidePort, {
|
||||
initialList: ["STRIPE_KEY"],
|
||||
});
|
||||
const key = await secrets.require("STRIPE_KEY");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createSecretsProxyClient } from "@suaveplan/api-secrets-worker-proxy/worker-side";
|
||||
|
||||
const secrets = createSecretsProxyClient(port, {
|
||||
initialList: manifest.secrets.map((d) => d.name),
|
||||
defaultTimeoutMs: 2_000,
|
||||
});
|
||||
|
||||
// Re-read after a rotation event: NO caching, so a fresh call sees
|
||||
// the rotated value with no manual cache-bust.
|
||||
async function refreshOnRotate(name: string): Promise<string> {
|
||||
return secrets.require(name);
|
||||
}
|
||||
|
||||
secrets.close("worker-shutdown");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createSecretsProxyClient(port: PortLike, opts?: CreateSecretsProxyClientOptions): SecretsProxyClient`
|
||||
- `interface CreateSecretsProxyClientOptions extends WorkerRpcClientOptions { initialList?: ReadonlyArray<string> }`
|
||||
- `interface SecretsProxyClient extends SecretsLike { close(reason?): void }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `disposed` is a closure flag; `guardDisposed` throws the
|
||||
`PROXY_DISPOSED` code rather than silently returning.
|
||||
- `get()` collapses `null` to `undefined` to align with the
|
||||
`SecretsLike` contract on the canonical surface — the wire allows
|
||||
null because that is what JSON-cloneable returns for an absent value.
|
||||
- `__refreshList()` mutates `listSnapshot` and returns the new frozen
|
||||
array; the sync `list()` accessor reads the latest snapshot.
|
||||
- Cache-free design is the explicit security invariant — see the
|
||||
package README for the threat model.
|
||||
Reference in New Issue
Block a user