Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
# @suaveplan/api-secrets-worker-proxy — worker-side proxy to a main-process SecretsLike adapter. Marshals every ctx.secrets.get/require/list call across api-worker-rpc, enforces a per-plugin allowed-keys whitelist on the main side BEFORE the secret value crosses the wire, and provides no worker-side caching so a rotated secret is picked up immediately and a compromised worker cannot exfiltrate cached values.
Stage-3 worker → main `SecretsLike` proxy for the API plugin system.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Frozen registry of error codes thrown across the worker → main `SecretsLike` proxy boundary. The codes here are distinc…
- [error-codes](./src/error-codes/error-codes.md) — Frozen registry of error codes thrown across the worker → main `SecretsLike` proxy boundary. The codes here are distinc…
- [main-side](./src/main-side/main-side.md) — Mounts a concrete `SecretsLike` adapter onto a main-process `MessagePort` so a worker-isolated API plugin can request s…
- [main-side](./src/main-side/main-side.md) — Mounts a concrete `SecretsLike` adapter onto a main-process `MessagePort` so a worker-isolated API plugin can request s…
- [redaction](./src/redaction/redaction.md) — Re-exports the canonical `<redacted>` marker and `redactSecret` helper from `@suaveplan/api-secrets`, plus an additiona…
- [redaction](./src/redaction/redaction.md) — Re-exports the canonical `<redacted>` marker and `redactSecret` helper from `@suaveplan/api-secrets`, plus an additiona…
- [testing](./src/testing/testing.md) — Real, no-mock testing harness for the worker-side secrets proxy. Wraps a real `MessageChannel` (via `api-worker-rpc`'s…
- [testing](./src/testing/testing.md) — Real, no-mock testing harness for the worker-side secrets proxy. Wraps a real `MessageChannel` (via `api-worker-rpc`'s…
- [worker-side](./src/worker-side/worker-side.md) — Worker-side `SecretsLike` proxy. Every method call is marshalled across the `api-worker-rpc` transport to the main-side…
- [worker-side](./src/worker-side/worker-side.md) — Worker-side `SecretsLike` proxy. Every method call is marshalled across the `api-worker-rpc` transport to the main-side…
<!-- end:auto-docs -->
## What it does
- **Main side** vends a concrete `SecretsLike` over a `MessagePort` via
`api-worker-rpc`, enforcing a per-plugin allowed-keys whitelist
BEFORE the secret value crosses the wire.
- **Worker side** exposes a `SecretsLike` proxy. Every method call
round-trips to the main side; **there is no worker-side cache** of
values.
## Security invariants
1. A non-manifested key is rejected on the main side with
`SECRETS_PROXY.KEY_NOT_PERMITTED`. The underlying source is never
consulted, so a malicious worker cannot probe the source's state.
2. `list()` returns the FROZEN snapshot of `allowedKeys`, not the
source's `list()`. The worker cannot enumerate names outside its
manifest.
3. Two sequential `get("X")` calls produce TWO main-side invocations.
No caching means a rotated secret is picked up immediately AND a
compromised worker has no internal state to exfiltrate.
4. `close()` makes every subsequent method reject with
`SECRETS_PROXY.PROXY_DISPOSED`.
## Modules
- [`error-codes`](./src/error-codes/error-codes.md) — `SECRETS_PROXY.*` registry + `SecretsProxyError` class.
- [`main-side`](./src/main-side/main-side.md) — `createSecretsProxyServer(port, secrets, allowedKeys)`.
- [`worker-side`](./src/worker-side/worker-side.md) — `createSecretsProxyClient(port, opts?)`.
- [`redaction`](./src/redaction/redaction.md) — `<redacted>` marker + `redactSecretLine`.
- [`testing`](./src/testing/testing.md) — real `MessageChannel` + `InMemorySecrets` harness.
## Quick start
```ts
import { createSecretsProxyServer } from "@suaveplan/api-secrets-worker-proxy/main-side";
import { createSecretsProxyClient } from "@suaveplan/api-secrets-worker-proxy/worker-side";
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
const ports = createInProcessPortPair();
const server = createSecretsProxyServer(ports.serverPort, mySecrets, ["STRIPE_KEY"]);
const proxy = createSecretsProxyClient(ports.clientPort, { initialList: ["STRIPE_KEY"] });
const key = await proxy.require("STRIPE_KEY");
proxy.close();
server.close();
ports.close();
```
## Conformance
`tests/conformance/run-secrets-conformance.test.ts` runs
`runSecretsConformance` from `@suaveplan/api-secrets/testing` against the
worker-side proxy. This is The precondition gate — every scenario
that passes against a direct in-process adapter MUST also pass through the
proxy surface before Stage 4 can complete.
## Dependencies
- `@suaveplan/api-secrets` — canonical contract + redaction marker.
- `@suaveplan/api-types` — `SecretsLike` type.
- `@suaveplan/api-worker-rpc` — `MessagePort`-based RPC transport.
- `@suaveplan/error`, `tslib`, `zod`.

View File

@@ -0,0 +1,89 @@
{
"name": "@suaveplan/api-secrets-worker-proxy",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-3 worker-side proxy to a main-process SecretsLike adapter. Marshals every ctx.secrets.get/require/list call across api-worker-rpc, enforces a per-plugin allowed-keys whitelist on the main side BEFORE the secret value crosses the wire, and provides no worker-side caching so a rotated secret is picked up immediately and a compromised worker cannot exfiltrate cached values.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./main-side": {
"import": {
"types": "./dist/main-side/main-side.d.ts",
"default": "./dist/main-side/main-side.js"
}
},
"./worker-side": {
"import": {
"types": "./dist/worker-side/worker-side.d.ts",
"default": "./dist/worker-side/worker-side.js"
}
},
"./redaction": {
"import": {
"types": "./dist/redaction/redaction.d.ts",
"default": "./dist/redaction/redaction.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/testing.d.ts",
"default": "./dist/testing/testing.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-secrets": "workspace:^",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/api-worker-rpc": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

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

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SECRETS_PROXY_CODES,
SECRETS_PROXY_ERROR_NAME,
SecretsProxyError,
} from "./error-codes.js";
describe("error-codes registry", () => {
it("enumerates the three documented codes", { timeout: 5000 }, () => {
expect(SECRETS_PROXY_CODES.KEY_NOT_PERMITTED).toBe(
"SECRETS_PROXY.KEY_NOT_PERMITTED"
);
expect(SECRETS_PROXY_CODES.KEY_NOT_FOUND).toBe(
"SECRETS_PROXY.KEY_NOT_FOUND"
);
expect(SECRETS_PROXY_CODES.PROXY_DISPOSED).toBe(
"SECRETS_PROXY.PROXY_DISPOSED"
);
});
it("SecretsProxyError pins the canonical name and code", {
timeout: 5000,
}, () => {
const err = new SecretsProxyError(
SECRETS_PROXY_CODES.KEY_NOT_PERMITTED,
"error.key"
);
expect(err.name).toBe(SECRETS_PROXY_ERROR_NAME);
expect(err.code).toBe(SECRETS_PROXY_CODES.KEY_NOT_PERMITTED);
});
it("SecretsProxyError accepts options with cause + context", {
timeout: 5000,
}, () => {
const cause = new Error("upstream");
const err = new SecretsProxyError(
SECRETS_PROXY_CODES.KEY_NOT_FOUND,
"error.key",
{ cause, context: { name: "X" } }
);
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ name: "X" });
});
});

View File

@@ -0,0 +1,57 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Frozen registry of error codes thrown across the worker → main secrets
* proxy boundary. Distinct from `SECRETS.*` (owned by `@suaveplan/api-secrets`)
* so operators can immediately tell whether a failure originated in the
* proxy transport (whitelist rejection, disposed proxy, transport-side
* NOT_FOUND) versus the concrete source.
*
* - `KEY_NOT_PERMITTED` — the requested name is not in the per-plugin
* allowed-keys whitelist enforced by the MAIN-side server BEFORE the
* secret value crosses the wire. Security-critical: a worker reading
* a value it did not manifest is a sandbox-escape.
* - `KEY_NOT_FOUND` — the key IS permitted but the underlying source has
* no value for it. Surfaced from `require()`; `get()` returns
* `undefined` for parity with the `SecretsLike` contract.
* - `PROXY_DISPOSED` — a method was called on a worker-side proxy whose
* transport has been torn down.
*/
export const SECRETS_PROXY_CODES = defineCodes("SECRETS_PROXY", [
"KEY_NOT_PERMITTED",
"KEY_NOT_FOUND",
"PROXY_DISPOSED",
] as const);
/**
* Union of fully-qualified `SECRETS_PROXY.*` codes. Use as the `code`
* field type on a `SecretsProxyError`-typed throwable.
*/
export type SecretsProxyCode =
(typeof SECRETS_PROXY_CODES)[keyof typeof SECRETS_PROXY_CODES];
/**
* Stable `error.name` value for every throwable this package produces.
* Structured loggers group on `name` for cross-code aggregation.
*/
export const SECRETS_PROXY_ERROR_NAME = "SecretsProxyError" as const;
/**
* Concrete `SuaveplanError` subclass for proxy-boundary failures. Mirrors
* the shape of `SecretsError` so consumers can `instanceof`-check or
* code-match without parsing message strings.
*/
export class SecretsProxyError extends SuaveplanError<SecretsProxyCode> {
public constructor(
code: SecretsProxyCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = SECRETS_PROXY_ERROR_NAME;
}
}

View File

@@ -0,0 +1,6 @@
export {
SECRETS_PROXY_CODES,
SECRETS_PROXY_ERROR_NAME,
type SecretsProxyCode,
SecretsProxyError,
} from "./error-codes.js";

View File

@@ -0,0 +1,32 @@
/**
* `@suaveplan/api-secrets-worker-proxy` — root barrel.
*
* Stage-3 worker-isolation infrastructure. The main-side server enforces
* a per-plugin allowed-keys whitelist BEFORE secret values cross the
* wire; the worker-side client preserves the canonical `SecretsLike`
* surface with NO worker-side value caching.
*/
export {
SECRETS_PROXY_CODES,
SECRETS_PROXY_ERROR_NAME,
type SecretsProxyCode,
SecretsProxyError,
} from "./error-codes/index.js";
export { createSecretsProxyServer } from "./main-side/index.js";
export {
REDACTED_MARKER,
redactSecret,
redactSecretLine,
} from "./redaction/index.js";
export {
createSecretsProxyFixture,
InMemorySecrets,
type SecretsProxyFixture,
type SecretsProxyFixtureOptions,
} from "./testing/index.js";
export {
type CreateSecretsProxyClientOptions,
createSecretsProxyClient,
type SecretsProxyClient,
} from "./worker-side/index.js";

View File

@@ -0,0 +1 @@
export { createSecretsProxyServer } from "./main-side.js";

View File

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

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SECRETS_PROXY_CODES } from "../error-codes/error-codes.js";
import { createSecretsProxyFixture } from "../testing/testing.js";
describe("main-side: createSecretsProxyServer", () => {
it("permitted key returns the seeded value", {
timeout: 5000,
}, async () => {
const fx = createSecretsProxyFixture({
seed: { ALPHA: "one" },
allowedKeys: ["ALPHA"],
});
try {
await expect(fx.client.get("ALPHA")).resolves.toBe("one");
} finally {
fx.close();
}
});
it("non-permitted key rejects with KEY_NOT_PERMITTED before the source is consulted", {
timeout: 5000,
}, async () => {
const fx = createSecretsProxyFixture({
seed: { LEAKED: "value-must-not-cross" },
allowedKeys: [],
});
let caughtCode: string | undefined;
try {
try {
await fx.client.get("LEAKED");
} catch (err) {
caughtCode = (err as { code?: string }).code;
}
} finally {
fx.close();
}
expect(caughtCode).toBe(SECRETS_PROXY_CODES.KEY_NOT_PERMITTED);
});
it("list() returns the frozen allowedKeys snapshot, not the source's list", {
timeout: 5000,
}, async () => {
const fx = createSecretsProxyFixture({
seed: { ALLOWED: "v", HIDDEN: "secret" },
allowedKeys: ["ALLOWED"],
});
try {
// Force a list refresh over the wire to capture the
// main-side snapshot.
const snap = await (
fx.client as unknown as {
__refreshList(): Promise<readonly string[]>;
}
).__refreshList();
expect([...snap]).toEqual(["ALLOWED"]);
} finally {
fx.close();
}
});
it("require() on permitted-but-absent key rejects with KEY_NOT_FOUND", {
timeout: 5000,
}, async () => {
const fx = createSecretsProxyFixture({
seed: {},
allowedKeys: ["DECLARED"],
});
let caughtCode: string | undefined;
try {
try {
await fx.client.require("DECLARED");
} catch (err) {
caughtCode = (err as { code?: string }).code;
}
} finally {
fx.close();
}
expect(caughtCode).toBe(SECRETS_PROXY_CODES.KEY_NOT_FOUND);
});
it("rejects empty name on the wire via Zod validation", {
timeout: 5000,
}, async () => {
const fx = createSecretsProxyFixture({
seed: {},
allowedKeys: ["X"],
});
let threw = false;
try {
try {
await fx.client.get("");
} catch {
threw = true;
}
} finally {
fx.close();
}
expect(threw).toBe(true);
});
it("require() on non-permitted key rejects with KEY_NOT_PERMITTED before source", {
timeout: 5000,
}, async () => {
const fx = createSecretsProxyFixture({
seed: { X: "value" },
allowedKeys: [],
});
let caughtCode: string | undefined;
try {
try {
await fx.client.require("X");
} catch (err) {
caughtCode = (err as { code?: string }).code;
}
} finally {
fx.close();
}
expect(caughtCode).toBe(SECRETS_PROXY_CODES.KEY_NOT_PERMITTED);
});
});

View File

@@ -0,0 +1,71 @@
import type { SecretsLike } from "@suaveplan/api-types/secrets";
import {
type PortLike,
serveWorkerRpc,
type WorkerRpcServer,
} from "@suaveplan/api-worker-rpc";
import z from "zod";
import {
SECRETS_PROXY_CODES,
SecretsProxyError,
} from "../error-codes/error-codes.js";
/**
* Wire-side argument validation for `get`/`require`. Both take a single
* non-empty string. We re-parse with Zod even though the typed proxy
* delivers a string already — the boundary is a defence layer, not a
* trust layer.
*/
const nameArgsSchema = z.tuple([z.string().min(1)]);
/**
* Mount a {@link SecretsLike} on a main-side `MessagePort`, enforcing a
* per-plugin allowed-keys whitelist. The whitelist is checked BEFORE
* delegating to the underlying source — non-permitted keys throw
* `SECRETS_PROXY.KEY_NOT_PERMITTED` and never reach the source.
*
* `list()` is special-cased: it returns the FROZEN snapshot of
* `allowedKeys` so the worker can never enumerate names outside its
* manifest, regardless of what the underlying source's `list()` returns.
*
* Returned `WorkerRpcServer` lets the caller `close()` the transport
* during plugin teardown.
*/
export function createSecretsProxyServer(
port: PortLike,
secrets: SecretsLike,
allowedKeys: ReadonlyArray<string>
): WorkerRpcServer {
const allowSet = new Set(allowedKeys);
const frozenList = Object.freeze([...allowedKeys]);
const assertPermitted = (name: string): void => {
if (!allowSet.has(name)) {
throw new SecretsProxyError(
SECRETS_PROXY_CODES.KEY_NOT_PERMITTED,
"error.secrets_proxy.key_not_permitted",
{ context: { name } }
);
}
};
return serveWorkerRpc(port, {
get: async (params) => {
const [name] = nameArgsSchema.parse(params);
assertPermitted(name);
return secrets.get(name);
},
require: async (params) => {
const [name] = nameArgsSchema.parse(params);
assertPermitted(name);
try {
return await secrets.require(name);
} catch (cause) {
throw new SecretsProxyError(
SECRETS_PROXY_CODES.KEY_NOT_FOUND,
"error.secrets_proxy.key_not_found",
{ cause, context: { name } }
);
}
},
list: () => frozenList,
});
}

View File

@@ -0,0 +1,5 @@
export {
REDACTED_MARKER,
redactSecret,
redactSecretLine,
} from "./redaction.js";

View File

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

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
REDACTED_MARKER,
redactSecret,
redactSecretLine,
} from "./redaction.js";
describe("redaction", () => {
it("REDACTED_MARKER is the canonical token", { timeout: 5000 }, () => {
expect(REDACTED_MARKER).toBe("<redacted>");
});
it("redactSecret returns the marker", { timeout: 5000 }, () => {
expect(redactSecret("super-secret-value")).toBe(REDACTED_MARKER);
expect(redactSecret("")).toBe(REDACTED_MARKER);
});
it("redactSecretLine replaces value after `=` with the marker", {
timeout: 5000,
}, () => {
const line = "ENV STRIPE_KEY=sk_live_abc123 loaded";
expect(redactSecretLine(line, "STRIPE_KEY")).toBe(
`ENV STRIPE_KEY=${REDACTED_MARKER} loaded`
);
});
it("redactSecretLine replaces value after `:` with the marker", {
timeout: 5000,
}, () => {
const line = "config: API_TOKEN: deadbeef done";
expect(redactSecretLine(line, "API_TOKEN")).toBe(
`config: API_TOKEN=${REDACTED_MARKER} done`
);
});
it("redactSecretLine escapes regex meta-characters in the name", {
timeout: 5000,
}, () => {
const line = "FOO.BAR=value-here ok";
expect(redactSecretLine(line, "FOO.BAR")).toBe(
`FOO.BAR=${REDACTED_MARKER} ok`
);
});
it("redactSecretLine is a no-op for empty name", { timeout: 5000 }, () => {
const line = "anything goes here";
expect(redactSecretLine(line, "")).toBe(line);
});
it("redactSecretLine leaves unrelated text alone", {
timeout: 5000,
}, () => {
expect(redactSecretLine("hello world", "STRIPE_KEY")).toBe(
"hello world"
);
});
});

View File

@@ -0,0 +1,30 @@
import { REDACTED_MARKER, redactSecret } from "@suaveplan/api-secrets";
/**
* Re-export of the canonical `<redacted>` marker, exposed here so worker
* code can scrub log lines without taking a direct dependency on
* `@suaveplan/api-secrets`. The literal token is the same one every
* observability sink in the stack grep-matches.
*/
export { REDACTED_MARKER, redactSecret };
/**
* Worker-side paranoid wrapper that ALSO redacts the key name and any
* surrounding whitespace. Plugins running under sandbox isolation log
* to their own pipe; this helper makes it trivial to redact both name +
* value before the line crosses out. Returns the redacted line.
*/
export function redactSecretLine(line: string, name: string): string {
if (name.length === 0) {
return line;
}
// Replace every occurrence of the literal name AND any
// following `=` / `:` / whitespace + the value-shaped token. The
// value-shape is conservative: any non-whitespace run after the
// delimiter. The name itself is preserved (so the operator can tell
// which secret was logged) but the value is replaced with the
// canonical marker.
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`${escaped}\\s*[=:]\\s*\\S+`, "g");
return line.replace(pattern, `${name}=${REDACTED_MARKER}`);
}

View File

@@ -0,0 +1,6 @@
export {
createSecretsProxyFixture,
InMemorySecrets,
type SecretsProxyFixture,
type SecretsProxyFixtureOptions,
} from "./testing.js";

View File

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

View File

@@ -0,0 +1,50 @@
import { SECRETS_CODES } from "@suaveplan/api-secrets";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createSecretsProxyFixture, InMemorySecrets } from "./testing.js";
describe("InMemorySecrets adapter", () => {
it("get/require/list round-trip", { timeout: 5000 }, async () => {
const s = new InMemorySecrets({ A: "1", B: "2" });
await expect(s.get("A")).resolves.toBe("1");
await expect(s.get("MISSING")).resolves.toBeUndefined();
await expect(s.require("B")).resolves.toBe("2");
expect([...s.list()].sort()).toEqual(["A", "B"]);
});
it("require throws SECRET_NOT_FOUND for missing names", {
timeout: 5000,
}, async () => {
const s = new InMemorySecrets({});
let caughtCode: string | undefined;
try {
await s.require("MISSING");
} catch (err) {
caughtCode = (err as { code?: string }).code;
}
expect(caughtCode).toBe(SECRETS_CODES.SECRET_NOT_FOUND);
});
it("set/delete mutate the store", { timeout: 5000 }, async () => {
const s = new InMemorySecrets();
s.set("X", "value");
await expect(s.get("X")).resolves.toBe("value");
s.delete("X");
await expect(s.get("X")).resolves.toBeUndefined();
});
it("constructs with empty initial map", { timeout: 5000 }, () => {
const s = new InMemorySecrets();
expect([...s.list()]).toEqual([]);
});
it("createSecretsProxyFixture without seed uses empty source", {
timeout: 5000,
}, async () => {
const fx = createSecretsProxyFixture({ allowedKeys: ["A"] });
try {
await expect(fx.client.get("A")).resolves.toBeUndefined();
} finally {
fx.close();
}
});
});

View File

@@ -0,0 +1,105 @@
import { SECRETS_CODES, SecretsError } from "@suaveplan/api-secrets";
import type { SecretsLike } from "@suaveplan/api-types/secrets";
import {
createInProcessPortPair,
type InProcessPortPair,
type WorkerRpcServer,
} from "@suaveplan/api-worker-rpc";
import { createSecretsProxyServer } from "../main-side/main-side.js";
import {
createSecretsProxyClient,
type SecretsProxyClient,
} from "../worker-side/worker-side.js";
/**
* Real in-memory `SecretsLike` adapter used by this package's tests and
* by the conformance harness. NOT a mock: `Map`-backed real storage,
* real `require()` throwing `SECRETS.SECRET_NOT_FOUND`. Stage-4 sources
* conform to the same observable contract.
*/
export class InMemorySecrets implements SecretsLike {
private readonly store = new Map<string, string>();
public constructor(initial: Readonly<Record<string, string>> = {}) {
for (const [k, v] of Object.entries(initial)) {
this.store.set(k, v);
}
}
public async get(name: string): Promise<string | undefined> {
return this.store.get(name);
}
public async require(name: string): Promise<string> {
const value = this.store.get(name);
if (value === undefined) {
throw new SecretsError(
SECRETS_CODES.SECRET_NOT_FOUND,
"error.secrets.not_found",
{ context: { name } }
);
}
return value;
}
public list(): readonly string[] {
return Object.freeze([...this.store.keys()]);
}
public set(name: string, value: string): void {
this.store.set(name, value);
}
public delete(name: string): void {
this.store.delete(name);
}
}
/**
* Paired fixture: a fresh `MessageChannel`, an `InMemorySecrets` source,
* a main-side server enforcing the supplied `allowedKeys`, and a
* worker-side proxy. `close()` tears down all three.
*/
export interface SecretsProxyFixture {
readonly ports: InProcessPortPair;
readonly source: InMemorySecrets;
readonly server: WorkerRpcServer;
readonly client: SecretsProxyClient;
close(): void;
}
export interface SecretsProxyFixtureOptions {
readonly seed?: Readonly<Record<string, string>>;
readonly allowedKeys: ReadonlyArray<string>;
}
/**
* Build a `SecretsProxyFixture` over a real `MessageChannel`. No mocks.
* Every test in this package and downstream conformance suites uses
* this factory to materialise the client + server pair.
*/
export function createSecretsProxyFixture(
opts: SecretsProxyFixtureOptions
): SecretsProxyFixture {
const ports = createInProcessPortPair();
const source = new InMemorySecrets(opts.seed ?? {});
const server = createSecretsProxyServer(
ports.serverPort,
source,
opts.allowedKeys
);
const client = createSecretsProxyClient(ports.clientPort, {
initialList: opts.allowedKeys,
});
return {
ports,
source,
server,
client,
close: () => {
client.close();
server.close();
ports.close();
},
};
}

View File

@@ -0,0 +1,5 @@
export {
type CreateSecretsProxyClientOptions,
createSecretsProxyClient,
type SecretsProxyClient,
} from "./worker-side.js";

View File

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

View File

@@ -0,0 +1,180 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SECRETS_PROXY_CODES } from "../error-codes/error-codes.js";
import { createSecretsProxyFixture } from "../testing/testing.js";
describe("worker-side: createSecretsProxyClient", () => {
it("get() resolves to undefined for permitted-but-missing keys", {
timeout: 5000,
}, async () => {
const fx = createSecretsProxyFixture({
seed: {},
allowedKeys: ["DECLARED"],
});
try {
await expect(fx.client.get("DECLARED")).resolves.toBeUndefined();
} finally {
fx.close();
}
});
it("two get() calls produce two main-side invocations (no worker-side cache)", {
timeout: 5000,
}, async () => {
const fx = createSecretsProxyFixture({
seed: { K: "v1" },
allowedKeys: ["K"],
});
try {
await expect(fx.client.get("K")).resolves.toBe("v1");
// Mutate the source between calls. If the proxy cached,
// the second call would still return "v1".
fx.source.set("K", "v2");
await expect(fx.client.get("K")).resolves.toBe("v2");
} finally {
fx.close();
}
});
it("list() returns the initialList snapshot synchronously", {
timeout: 5000,
}, async () => {
const fx = createSecretsProxyFixture({
seed: {},
allowedKeys: ["A", "B"],
});
try {
expect([...fx.client.list()]).toEqual(["A", "B"]);
} finally {
fx.close();
}
});
it("list() defaults to empty array when no initialList is provided", {
timeout: 5000,
}, async () => {
// Use lower-level construction to bypass fixture's initialList.
const { createInProcessPortPair } = await import(
"@suaveplan/api-worker-rpc"
);
const { createSecretsProxyServer } = await import(
"../main-side/main-side.js"
);
const { createSecretsProxyClient } = await import("./worker-side.js");
const { InMemorySecrets } = await import("../testing/testing.js");
const ports = createInProcessPortPair();
const source = new InMemorySecrets({});
const server = createSecretsProxyServer(ports.serverPort, source, [
"X",
]);
const client = createSecretsProxyClient(ports.clientPort);
try {
expect([...client.list()]).toEqual([]);
} finally {
client.close();
server.close();
ports.close();
}
});
it("close() makes subsequent calls reject with PROXY_DISPOSED", {
timeout: 5000,
}, async () => {
const fx = createSecretsProxyFixture({
seed: { K: "v" },
allowedKeys: ["K"],
});
fx.client.close();
let getCode: string | undefined;
try {
await fx.client.get("K");
} catch (err) {
getCode = (err as { code?: string }).code;
}
expect(getCode).toBe(SECRETS_PROXY_CODES.PROXY_DISPOSED);
let requireCode: string | undefined;
try {
await fx.client.require("K");
} catch (err) {
requireCode = (err as { code?: string }).code;
}
expect(requireCode).toBe(SECRETS_PROXY_CODES.PROXY_DISPOSED);
let listThrown = false;
try {
fx.client.list();
} catch (err) {
listThrown =
(err as { code?: string }).code ===
SECRETS_PROXY_CODES.PROXY_DISPOSED;
}
expect(listThrown).toBe(true);
let refreshThrown = false;
try {
await (
fx.client as unknown as {
__refreshList(): Promise<readonly string[]>;
}
).__refreshList();
} catch (err) {
refreshThrown =
(err as { code?: string }).code ===
SECRETS_PROXY_CODES.PROXY_DISPOSED;
}
expect(refreshThrown).toBe(true);
// close() is idempotent.
fx.client.close();
fx.server.close();
fx.ports.close();
});
it("__refreshList updates the snapshot returned by sync list()", {
timeout: 5000,
}, async () => {
const fx = createSecretsProxyFixture({
seed: {},
allowedKeys: ["A"],
});
try {
expect([...fx.client.list()]).toEqual(["A"]);
const snap = await (
fx.client as unknown as {
__refreshList(): Promise<readonly string[]>;
}
).__refreshList();
expect([...snap]).toEqual(["A"]);
expect([...fx.client.list()]).toEqual(["A"]);
} finally {
fx.close();
}
});
it("get() returns undefined when main-side returns null", {
timeout: 5000,
}, async () => {
// Force the source to return null for a key. Use a direct
// SecretsLike implementation that yields null.
const { createInProcessPortPair } = await import(
"@suaveplan/api-worker-rpc"
);
const { createSecretsProxyServer } = await import(
"../main-side/main-side.js"
);
const { createSecretsProxyClient } = await import("./worker-side.js");
const ports = createInProcessPortPair();
const nullSource = {
get: async (_name: string) => undefined,
require: async (_name: string) => "",
list: () => Object.freeze(["X"] as const),
};
const server = createSecretsProxyServer(ports.serverPort, nullSource, [
"X",
]);
const client = createSecretsProxyClient(ports.clientPort);
try {
await expect(client.get("X")).resolves.toBeUndefined();
} finally {
client.close();
server.close();
ports.close();
}
});
});

View File

@@ -0,0 +1,113 @@
import type { SecretsLike } from "@suaveplan/api-types/secrets";
import {
createRawWorkerRpcClient,
type PortLike,
type WorkerRpcClient,
type WorkerRpcClientOptions,
} from "@suaveplan/api-worker-rpc";
import {
SECRETS_PROXY_CODES,
SecretsProxyError,
} from "../error-codes/error-codes.js";
/**
* Worker-side handle returned by {@link createSecretsProxyClient}. Extends
* the canonical {@link SecretsLike} contract with proxy-only lifecycle:
* `close()` tears down the underlying RPC transport, after which every
* subsequent method call rejects with `SECRETS_PROXY.PROXY_DISPOSED`.
*/
export interface SecretsProxyClient extends SecretsLike {
/** Tear down the underlying RPC transport. Idempotent. */
close(reason?: string): void;
}
/**
* Build a worker-side `SecretsLike` proxy over a `MessagePort`. Every
* `get`/`require`/`list` call is marshalled to the main side; there is
* NO worker-side cache of values or of `list()` snapshots — this is the
* security-critical invariant the package exists to provide.
*
* `list()` is sync on the canonical contract but async on the wire, so
* we cache the frozen array returned by the FIRST call's resolution.
* However the surface itself never returns secret VALUES sync; the
* canonical `list()` returns names only, which the manifest already
* pins, so name-list caching is safe and required to satisfy the sync
* signature.
*
* A best-effort first-call pre-fetch is NOT issued here — `list()` is
* intended to be called rarely (manifest-validation time only), and
* eagerness would couple proxy construction to a round trip. Callers
* that need synchronous list semantics MUST `await proxy.require(x)` or
* similar at least once first, OR pass an explicit `initialList`.
*/
export interface CreateSecretsProxyClientOptions
extends WorkerRpcClientOptions {
/**
* Optional manifest-known name list to populate the sync `list()`
* accessor without a round trip. When omitted, `list()` returns the
* snapshot from the most-recent async `__refreshList()` call, or an
* empty array before any refresh has happened.
*/
readonly initialList?: ReadonlyArray<string>;
}
interface RefreshableProxy extends SecretsProxyClient {
/**
* Hidden async refresh of the name snapshot used by sync `list()`.
* Exposed for tests + advanced callers; consumers should treat
* `list()` as a manifest-derived accessor.
*/
__refreshList(): Promise<ReadonlyArray<string>>;
/** Underlying raw RPC client, for advanced/transport-level access. */
readonly __raw__: WorkerRpcClient;
}
export function createSecretsProxyClient(
port: PortLike,
opts: CreateSecretsProxyClientOptions = {}
): SecretsProxyClient {
const raw = createRawWorkerRpcClient(port, opts);
let disposed = false;
let listSnapshot: ReadonlyArray<string> = opts.initialList
? Object.freeze([...opts.initialList])
: Object.freeze<string[]>([]);
const guardDisposed = (): void => {
if (disposed) {
throw new SecretsProxyError(
SECRETS_PROXY_CODES.PROXY_DISPOSED,
"error.secrets_proxy.proxy_disposed"
);
}
};
const proxy: RefreshableProxy = {
__raw__: raw,
async get(name: string): Promise<string | undefined> {
guardDisposed();
return (
(await raw.call<string | undefined>("get", [name])) ?? undefined
);
},
async require(name: string): Promise<string> {
guardDisposed();
return raw.call<string>("require", [name]);
},
list(): readonly string[] {
guardDisposed();
return listSnapshot;
},
async __refreshList(): Promise<ReadonlyArray<string>> {
guardDisposed();
const names = await raw.call<ReadonlyArray<string>>("list", []);
listSnapshot = Object.freeze([...names]);
return listSnapshot;
},
close(reason = "closed"): void {
if (disposed) return;
disposed = true;
raw.close(reason);
},
};
return proxy;
}

View File

@@ -0,0 +1,37 @@
/**
* §16 conformance proof — the worker-proxy package consumes the
* `@suaveplan/api-worker-rpc` contract directly (client + server +
* MessagePort transport). This test exercises the canonical
* `runWorkerRpcConformance` harness with a minimal handler set to
* prove the consumer is wire-compatible end-to-end (request frame,
* response frame, abort propagation) using the same port transport
* the package builds on top of in `src/main-side` / `src/worker-side`.
*/
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { runWorkerRpcConformance } from "../../../api-worker-rpc/src/testing/conformance.js";
const handles: Array<{ dispose(): void }> = [];
afterEach(() => {
while (handles.length > 0) handles.pop()?.dispose();
});
describe("api-worker-rpc contract conformance (consumer-side)", () => {
it("round-trips a request through the canonical harness", {
timeout: 5000,
}, async () => {
const h = runWorkerRpcConformance(() => ({
echo: (params: ReadonlyArray<unknown>) => params[0],
}));
handles.push(h);
const result = await h.client.call<number>("echo", [42]);
expect(result).toBe(42);
});
it("surfaces UNKNOWN_METHOD from the server", {
timeout: 5000,
}, async () => {
const h = runWorkerRpcConformance(() => ({}));
handles.push(h);
await expect(h.client.call("missing", [])).rejects.toThrow();
});
});

View File

@@ -0,0 +1,49 @@
/**
* Stage-4 precondition gate: the worker-side proxy surface MUST pass
* `runSecretsConformance` from `@suaveplan/api-secrets/testing`
* identically to a direct in-process adapter. This test is the
* cross-package contract proof.
*/
// NOTE: import from source not dist — api-secrets's dist bundles vitest
// internals (a pre-existing build-config bug; tracked in main-side
// integration). Source import goes through workspace TS resolution and
// uses the local vitest instance.
import {
runSecretsConformance,
type SecretsConformanceFixture,
} from "../../../api-secrets/src/testing/conformance.js";
import {
createSecretsProxyFixture,
type SecretsProxyFixture,
} from "../../src/testing/testing.js";
runSecretsConformance({
label: "worker-proxy",
factory: (seed): SecretsConformanceFixture => {
// The harness's `seed` carries the names the test will exercise;
// they MUST be in `allowedKeys` for the proxy to permit them.
// For the not-found scenario (seed is empty), also permit
// "MISSING" so the source-not-found path is exercised rather
// than the whitelist-rejection path. For the list scenario the
// seed is non-empty so we don't pollute the list.
const seeded = Object.keys(seed);
const allowed = seeded.length === 0 ? ["MISSING"] : seeded;
const fx: SecretsProxyFixture = createSecretsProxyFixture({
seed,
allowedKeys: allowed,
});
return {
secrets: fx.client,
teardown: () => {
fx.close();
},
};
},
// The proxy surfaces source-not-found as KEY_NOT_FOUND, not
// SECRET_NOT_FOUND. Override the predicate accordingly.
isNotFound: (err: unknown) =>
typeof err === "object" &&
err !== null &&
"code" in err &&
(err as { code: unknown }).code === "SECRETS_PROXY.KEY_NOT_FOUND",
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-secrets-worker-proxy",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [/^@suaveplan\//, /^node:/, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});