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_PERMITTEDand the source is not consulted
Scenario: permitted-but-absent key surfaces SECRETS_PROXY.KEY_NOT_FOUND
- WHEN the whitelist permits
DECLAREDbut the source has no value AND worker callsrequire("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
ALLOWEDandHIDDENAND onlyALLOWEDis inallowedKeysAND 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