Files
2026-05-27 23:29:53 +01:00

4.6 KiB

Capability Spec: api-secrets-worker-proxy

Package: @suaveplan/api-secrets-worker-proxy Status: SHIPPED (0.1.0)

Every requirement carries a **Verified by:** line.

Requirements

Requirement: Proxy preserves SecretsLike surface and forwards every call to main

The package SHALL expose createSecretsProxyClient(port) returning a SecretsLike-compatible proxy and createSecretsProxyServer(port, secrets, allowedKeys) returning a WorkerRpcServer. Every get and require call SHALL be marshalled to the main process — there MUST be no worker-side cache of secret values, neither in module state nor in client-instance state. Two sequential get("X") calls MUST produce TWO main-side RPC invocations (so a rotated secret is picked up immediately AND a compromised worker cannot exfiltrate cached values). list() is sync on the canonical contract and SHALL be served by a worker-side snapshot populated either at construction (initialList) or by a hidden __refreshList() accessor — name lists are manifest-pinned and not sensitive.

Scenario: two gets produce two main-side RPC calls

  • WHEN worker code calls ctx.secrets.get("X") twice in succession and the source value is mutated between calls
  • THEN the proxy observes the post-mutation value on the second call

Verified by: src/worker-side/worker-side.test.ts::"two get() calls produce two main-side invocations (no worker-side cache)"


Requirement: Non-permitted key rejected on main side with SECRETS_PROXY.KEY_NOT_PERMITTED

The MAIN-side server SHALL enforce a per-plugin allowed-keys whitelist BEFORE consulting the underlying source. Requests for non-permitted names SHALL throw SECRETS_PROXY.KEY_NOT_PERMITTED and the source SHALL NOT be invoked. Permitted keys whose value is absent in the source SHALL surface as SECRETS_PROXY.KEY_NOT_FOUND from require() — the two cases are distinct so the operator can tell "your plugin didn't declare this" from "your plugin declared it but the source is missing it."

Scenario: non-permitted key is rejected before source consulted

  • WHEN the whitelist is empty AND worker calls ctx.secrets.get("LEAKED") for a key the source DOES contain
  • THEN the call rejects with SECRETS_PROXY.KEY_NOT_PERMITTED and the source is not consulted

Scenario: permitted-but-absent key surfaces SECRETS_PROXY.KEY_NOT_FOUND

  • WHEN the whitelist permits DECLARED but the source has no value AND worker calls require("DECLARED")
  • THEN the call rejects with SECRETS_PROXY.KEY_NOT_FOUND

Verified by: src/main-side/main-side.test.ts::"non-permitted key rejects with KEY_NOT_PERMITTED before the source is consulted" AND src/main-side/main-side.test.ts::"require() on permitted-but-absent key rejects with KEY_NOT_FOUND"


Requirement: list() returns frozen allowedKeys snapshot, not the source's enumeration

The MAIN-side server's list handler SHALL return the FROZEN snapshot of allowedKeys supplied at server construction, NOT the underlying source's list() output. The worker SHALL be unable to enumerate names outside its manifest, regardless of source state.

Scenario: source contains extra names, list returns only allowed

  • WHEN the source contains ALLOWED and HIDDEN AND only ALLOWED is in allowedKeys AND the worker calls __refreshList()
  • THEN the snapshot is ["ALLOWED"]

Verified by: src/main-side/main-side.test.ts::"list() returns the frozen allowedKeys snapshot, not the source's list"


Requirement: close() transitions proxy to PROXY_DISPOSED for all methods

The worker-side proxy SHALL expose close(reason?). After close(), every subsequent get, require, sync list, and async __refreshList call SHALL throw SECRETS_PROXY.PROXY_DISPOSED. The close() operation SHALL be idempotent.

Scenario: post-close methods all reject

  • WHEN the worker calls close() and then invokes any proxy method
  • THEN every call throws SECRETS_PROXY.PROXY_DISPOSED

Verified by: src/worker-side/worker-side.test.ts::"close() makes subsequent calls reject with PROXY_DISPOSED"


Requirement: Conformance suite passes against the proxy surface

The package SHALL be runnable as the surface argument to runSecretsConformance from @suaveplan/api-secrets/testing. Every scenario MUST pass identically to a direct in-process adapter. This is the Stage-4 precondition gate.

Scenario: runSecretsConformance passes

  • WHEN runSecretsConformance({ label: "worker-proxy", factory }) runs against the proxy
  • THEN every scenario passes

Verified by: tests/conformance/run-secrets-conformance.test.ts