Initial Commit
This commit is contained in:
82
packages/api-stack/api/api-secrets-worker-proxy/README.md
Normal file
82
packages/api-stack/api/api-secrets-worker-proxy/README.md
Normal 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`.
|
||||
|
||||
89
packages/api-stack/api/api-secrets-worker-proxy/package.json
Normal file
89
packages/api-stack/api/api-secrets-worker-proxy/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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,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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
SECRETS_PROXY_CODES,
|
||||
SECRETS_PROXY_ERROR_NAME,
|
||||
type SecretsProxyCode,
|
||||
SecretsProxyError,
|
||||
} from "./error-codes.js";
|
||||
32
packages/api-stack/api/api-secrets-worker-proxy/src/index.ts
Normal file
32
packages/api-stack/api/api-secrets-worker-proxy/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1 @@
|
||||
export { createSecretsProxyServer } from "./main-side.js";
|
||||
@@ -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,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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
REDACTED_MARKER,
|
||||
redactSecret,
|
||||
redactSecretLine,
|
||||
} from "./redaction.js";
|
||||
@@ -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,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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createSecretsProxyFixture,
|
||||
InMemorySecrets,
|
||||
type SecretsProxyFixture,
|
||||
type SecretsProxyFixtureOptions,
|
||||
} from "./testing.js";
|
||||
@@ -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,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type CreateSecretsProxyClientOptions,
|
||||
createSecretsProxyClient,
|
||||
type SecretsProxyClient,
|
||||
} from "./worker-side.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user