79 lines
4.6 KiB
Markdown
79 lines
4.6 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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`
|