server.ts looked procedures up with plain property indexing, so every
Object.prototype member resolved as truthy. A prototype member carries no
`permission`, so the permission gate was skipped entirely.
Verified: with a contract whose only procedure declares a permission and a
checkPermission that always denies, invoke("add") correctly returns
RPC_DENIED, while invoke("constructor") returns {"ok":true,"value":{"a":2}}
and the gate never runs.
Reachable over the wire as POST /__wrnexus/rpc/<service>/constructor by
anything that clears the internal-caller check - i.e. any workspace app.
Fixed at both layers: Object.hasOwn for the procedure and handler lookups,
and a character-class guard on the path segments before they are used as
lookup keys.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings the uncommitted body of work under version control so it cannot be
lost. Gates are green: 152 tests pass across rpc/router/dev-server,
typecheck, lint, format and check:public-api all clean.
NOT YET REVIEWED. None of Tasks 5-11 has had an independent task review, and
Task 4's second fix round was never re-reviewed either.
Known gaps against the plan, recorded here rather than discovered later:
- packages/rpc/test/{transport,server,client}.test.ts are ABSENT. The plan
required a test file for each. server.ts holds the fail-closed identity and
permission checks and currently has no direct coverage at all.
- rpc-endpoint.test.ts has 3 tests where the plan specified 9. Missing:
unknown service, non-POST, malformed body, non-rpc passthrough, and the
isInternalCaller sweep. This is the task where a reachable
/__wrnexus/rpc/* makes every permission check in the workspace bypassable.
- http.test.ts has 3 of 7; integration.test.ts 2 of 3;
services-discovery.test.ts 1 of 4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The round-1 fix required exp and passed maxAge, but verifyJwt gates its age
check on iat being a number - the identical shape to the two fail-opens that
round closed. A token minted without iat defeats the age bound at ANY
maxAgeSeconds, and a future-dated iat yields a negative age and does the
same. Both refused now, so maxAge means what ImportOptions says it means.
The mint side refused an array targetApp, but the import side never checked
that aud was a single string, and verifyJwt compares with includes(). So a
multi-audience token still verified at several apps - the invariant was true
only where it was not enforced. Now checked at the callee.
ctx.tenant present with a null id was treated as untenanted, silently
widening scope to global while still issuing an authenticated credential.
Absent ctx.tenant means global; a present tenant with an unusable id is an
error.
Also exports ImportOptions, which the append snippet omitted although the
Produces line names it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CRITICAL: importSubjectContext never validated selfApp, and verifyJwt skips
the audience check entirely when audience is undefined. So an undefined
selfApp disabled the only cross-app binding in the system and accepted every
token from every app for every audience. Not hypothetical - the natural feed
is helpers' currentAppName(), which returns string | undefined. The mint side
already hard-fails on a missing app name; the import side did not.
A non-string tenant id was silently dropped at both ends. A numeric tenant id
is the common DB-backed case, and a callee reading a missing tenantId as
"global" is a cross-tenant exposure. Now refused, symmetric with the subject
check.
Token lifetime was unbounded: verifyJwt only checks exp when present, so a
token minted without one never expired, and a caller passing a large
ttlSeconds produced a long-lived impersonation credential the callee
honoured. exp is now required and age is bounded by maxAge independently.
targetApp was unvalidated, so passing an array minted one token valid at
several apps - exactly what the audience binding exists to prevent.
Also documents callerApp as self-asserted rather than authenticated
provenance, since the signing secret is workspace-wide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
defineService froze the procedures map but not each procedure inside it, so a
ProcedureDef built by hand rather than through procedure.build() stayed
mutable: svc.procedures.foo.permission = 'hacked' silently succeeded. The
contract is shared between two apps as a single source of truth, and the
guarantee rested on every call site remembering to use the builder.
Same class as the authz catalog's frozenMap, which froze the Map's mutators
but not the values it handed out.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The builder's .input() did not typecheck as written (TS2345). The phantom
__input/__output markers make ProcedureDef invariant, which is exactly why
.output<T>() already carried a cast - .input() needed the analogous one and
did not have it.
Caught by the Task 3 implementer, who also verified via @ts-expect-error that
InferProcedureInput/InferProcedureOutput genuinely reject wrong shapes, so
the phantom markers are carrying real type information rather than silently
widening.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
isRetryableStatus used an unbounded status >= 500, so a garbage status like
1000 landed in the retryable bucket. This function is the sole gate the
client and HTTP transport trust for retry safety, and an out-of-range value
must fail closed. Bounded on both sides.
408 Request Timeout was non-retryable while the same file documented
transport as covering "connection, timeout, 5xx" - a genuine timeout
surfaced as 408 was classified differently from the identical timeout
surfaced as 504. Now retryable.
There was no code for "the callee answered but not with a ServiceResult" - a
proxy's HTML error page, a truncated body. Task 8 was already papering over
it by hand-setting retryable: false beside a transport code that
retryableFor says is always retryable, which is exactly how the two drift
apart. Added RPC_MALFORMED and made that path use failure() so retryability
is derived from the code rather than written next to it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
no-explicit-any is off repo-wide in eslint.config.js, so the disable comment
the plan mandated is itself an unused-directive warning. The test's schema
binding also needs the _ prefix the lint config requires for a value read
only via typeof.
Separately: bun test strips type-only imports before resolution, so the
red-first step does not reproduce for type-only tests. Recorded so later
implementers do not chase it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eleven TDD tasks covering phase 1: contract and immutable procedure builder,
error classification, the signed subject-context token, the Transport seam
with an in-process transport for tests, implement() with fail-closed identity
and permission checks, the typed client proxy, the HTTP transport, router
discovery of app/services, and the mounted endpoint with its two independent
external-access guards.
Phases 2-4 (retry and circuit breaking, app-to-app streaming, identity for
pubsub and queue) are documented as deferred with the reason each needs its
own design pass.
Task 10 is called out as the highest-risk: if /__wrnexus/rpc/* is reachable
from the public internet, every permission check in the workspace is
bypassable, so the plan requires the gateway block and the app-side check to
be verified as working independently of each other.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>