Commit Graph
26 Commits
Author SHA1 Message Date
ClintchizandClaude Opus 5 a4c7d7b298 docs: drop a redundant eslint directive and note a Bun test quirk
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>
2026-08-05 09:54:08 +05:30
ClintchizandClaude Opus 5 63e6148cdb docs: implementation plan for the inter-app communication system
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>
2026-08-05 09:37:39 +05:30
ClintchizandClaude Opus 5 87d33a2ce1 docs: design for the inter-app communication system
Typed request/response between workspace apps over HTTP, behind a Transport
seam so gRPC stays additive rather than a rewrite. Contracts live in the
workspace's shared package and are imported by both sides, so types flow
through a normal import with no code generator.

Consumes the exportSubjectContext/importSubjectContext seam the permissions
system reserved, with one improvement on what that seam implied: the token
carries sub and tenant only, never roles. Every app shares the
PermissionStore, so the callee resolves roles itself - a stale or forged
roles claim becomes impossible by construction and there is no path to
injecting privileges through a claim. The token authenticates; it never
authorizes.

Records two properties that are easy to get wrong and expensive to discover:
/__wrnexus/rpc/* must be unreachable from the public internet, blocked at the
gateway AND verified at the app, or every permission check in the workspace
is bypassable; and only procedures explicitly marked idempotent may be
retried, because retrying a slow createInvoice is how a customer gets billed
twice.

Deliberately does not wrap pubsub or queue - they work, and an abstraction
over working code leaks and needs keeping in sync. They gain identity
propagation instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 09:27:43 +05:30
ClintchizandClaude Opus 5 2c339bee15 docs: record the adjudicated non-blocking authz findings
Findings from the task and whole-branch reviews that were ruled non-blocking,
plus the behaviour changes that need release notes. None is an authorization
bypass. Recorded in the repo because the review workspace is scratch and git
history does not carry the reasoning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 02:20:39 +05:30
ClintchizandClaude Opus 5 41b6e2ed2b fix(authz): freeze catalog values after boot; correct compile-time-check claims
frozenMap only blocked the Map's own mutators, so
catalog.roles.get("editor").push("*") escalated a role to a full wildcard
past an error string claiming the catalog is frozen after boot; the same
applied to permission/attribute metadata objects and binding arrays.
mergeCatalogs now stores frozen copies of each, so the original declaring
module's objects are never mutated either.

Also corrects two docstrings (codegen.ts, the design doc) that claimed
`wrnexus authz generate`'s output makes a permission typo a type error —
can(), guardPermission(), and decideFor() all take a bare string and nothing
consumes the generated union automatically. Documents what it actually is:
a Permission/Role union to type your own helpers/constants against. Also
adds a README note on the subject.id contract (must be a non-empty string;
owner() compares with Object.is).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 02:10:24 +05:30
ClintchizandClaude Opus 5 e5d0654d2a docs: join the DDL statement lists in the Task 13 init command
authzMigrationSql was changed in Task 11 to return statement arrays rather
than one blob, but Task 13's init still interpolated them straight into the
migration file, which would comma-join two CREATE TABLE statements into one
unparseable line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:26:10 +05:30
ClintchizandClaude Opus 5 3ef353de83 docs: use JSON.stringify for codegen escaping in the Task 12 plan
The plan's union helper hand-rolled escaping for backslash and double quote
only. Role names reach the emitter through the raw mergeCatalogs path, which
does not apply the registry's permission-id regex, so a value containing a
newline was emitted verbatim and the generated file failed to compile with
TS1002 Unterminated string literal.

Caught by the Task 12 implementer actually running tsc over the generated
output rather than eyeballing the string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:20:11 +05:30
ClintchizandClaude Opus 5 2fbf059c00 docs: type-guard tenantId and add a no-transaction regression guard
Two gaps the Task 11 re-review left open.

scopeKey guarded the empty-string tenantId but not its type, so null, 0,
false or an object flowed through un-normalised and the adapters diverged -
the db rejects on NOT NULL while memory accepts an unreachable row. The whole
premise of the empty-string guard was a caller who controls the tenant id,
and that caller can just as easily hand over a null from a JSON body.

The vacuous concurrency test was removed for good reason, but that left
nothing failing if someone re-wraps grant() in db.tx and reintroduces the
shared-connection rollback. A spy over driver.transaction discriminates that
deterministically, with no timing dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:01:26 +05:30
ClintchizandClaude Opus 5 134c5fa4bc docs: replace a vacuous conformance test with an honest one
I added "a concurrent write is not lost to another method's failure" to the
conformance suite to guard the fail-open the Task 11 review demonstrated. The
implementer reported they could not make it fail against the reverted code,
across 600 stress iterations. They were right.

I reproduced the underlying defect directly - forcing the transaction to open
before the bare write gives "revoke resolved without error: true" with the
role still present - so the mechanism is real. But the test cannot reach it:
Promise.all in one process does not reliably land the bare write inside the
open transaction, and grant() never fails on its own. The test passed against
the defective implementation, which is exactly the false assurance this suite
exists to prevent.

Replaced with a property that is actually guaranteed and adapter-agnostic: a
rejected write leaves unrelated state intact. The rollback hazard itself is
prevented structurally, by the store using no transactions, and that is now
stated in a comment rather than pretended to be under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:48:38 +05:30
ClintchizandClaude Opus 5 f19462dff0 docs: fix fail-open concurrency and effect handling in the Task 11 plan
The database store review found two Critical defects and several Important
ones, all reachable in production.

grant() was wrapped in db.tx for atomicity. The sqlite driver runs a bare
BEGIN on one shared connection with no serialization, so an open transaction
swallows any concurrent write from another method and discards it on
rollback. Demonstrated: revokeRole resolved with no error while the role
survived - a security-critical revoke reporting success with the privilege
retained. Concurrent grants also rejected outright with "cannot start a
transaction within a transaction". Replaced with single-statement upserts,
which are atomic without a transaction; assignRole likewise drops its
check-then-act SELECT for ON CONFLICT DO NOTHING, which was rejecting 19 of
20 concurrent identical calls.

effect had no CHECK constraint and assignmentsFor classified by exact
equality, so a mis-cased or corrupted value was dropped from BOTH buckets -
a deny row that silently stopped denying. Added the constraint and made
anything that is not literally "allow" count as a deny.

scopeKey now refuses an explicitly empty tenantId rather than treating it as
global, which otherwise let a caller who controls the tenant id read and
write global assignments.

Also: ensureAuthzTables takes the dialect from db.driver.dialect instead of
defaulting to sqlite; the DDL is a list of statements rather than a blob
split on a formatting-dependent separator; MySQL identity columns get a
binary collation so tenant "T1" cannot match "t1"; and postgres placeholders
are numbered.

Adds four conformance tests for the concurrency and empty-scope cases. The
suite was entirely sequential and structurally could not catch any of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:33:45 +05:30
ClintchizandClaude Opus 5 41fb82b9e9 docs: skip generated type files in the Task 10 authz scan
The brief asserted permissions.gen.ts would be discovered as an entry named
permissions.gen and filtered by a later task. It is not: isSafeIslandName
rejects the dot in the stripped basename, so it takes the warn-and-skip path
and would print a warning on every boot of any app that ran the codegen,
while Task 14's name-based filter for it was dead code.

The scan now skips *.gen.ts / *.gen.js quietly, before the name check. Also
records the extraExtensions argument the implementer added to scanDir, which
keeps .js out of the route-scanning allow-list where it would otherwise leak
into generated route URLs via fileToRoute.

Caught by the Task 10 implementer testing the claim rather than trusting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:55:33 +05:30
ClintchizandClaude Opus 5 e05ddc7aa5 docs: warn against the permissionMatches + permissionsFor composition
permissionsFor carries a caveat that its Set cannot represent a narrow deny
under a broad grant, so callers must gate with decide(). Now that
permissionMatches is also public, the wrong composition is directly reachable
and looks idiomatic - and the warning lived only on the other half of it.
Adds the pointer to permissionMatches, and covers scopeKey and safeRecord in
the exports test, which the brief omitted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:36:24 +05:30
ClintchizandClaude Opus 5 13859ce7dc docs: add deniedBy to the Task 9 export list
deniedBy was introduced in Task 6's fix round to make wildcard denies work,
but the plan's export block and its exports test were never updated, so
Task 9 would have shipped it module-private.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:25:40 +05:30
ClintchizandClaude Opus 5 798f56734a docs: stop double-encoding redirectTo in the Task 7 plan snippet
The previous fix used encodeURI to keep a non-ASCII redirect target from
throwing inside new Response. But encodeURI also escapes "%", so an
already-percent-encoded target is corrupted: /login?next=%2Fdash becomes
/login?next=%252Fdash, which single-decodes to the literal "%2Fdash" rather
than the intended path. That is the most common real use of redirectTo -
"send them to login, then bounce back".

Replaced with headerSafePath, a codepoint loop that encodes only what cannot
be sent in a Latin-1 header and leaves existing escapes and reserved ASCII
untouched. My prescription, my defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:07:15 +05:30
ClintchizandClaude Opus 5 9e3624e584 docs: put the subject in the memo key in the Task 7 plan snippet
The re-review closed all six earlier findings but surfaced the same bug class
one level over: the memo key carried the scope but not the subject, so
reassigning ctx.user mid-request served the previous principal's verdict.
Demonstrated - u1 allowed, then ctx.user = u2 still returned true, and
clearing ctx.user entirely revoked nothing. Triggered by impersonation or
"view as user" middleware, step-up auth, session revocation mid-request, or
simply registering an auth middleware after authzMiddleware.

Also: symbols now memo by identity (String() collapsed two distinct symbols
sharing a description into one slot), -0 stays distinct from 0, the
rejected-redirect log no longer echoes CR/LF verbatim into the log stream,
and a non-ASCII redirect target is encodeURI'd rather than throwing out of
the Response constructor and 500ing on a denial path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:54:30 +05:30
ClintchizandClaude Opus 5 cc8085bcfa docs: fix memo-key cross-authorization in the Task 7 plan snippet
The middleware's per-request memo keyed resources by String(resource.id) with
an unserialisable fallback that shared one bucket. Six demonstrated cases
cross-authorized: {id:1} vs the primitive 1; {id:7} vs {id:"7"}; object ids;
and every circular / BigInt / throwing-getter row collapsing together so the
first verdict in a request became the verdict for all of them. filterCan
returned 3 of 3 rows where 1 was permitted - it leaked, rather than denied.

Object resources now memo by identity through a WeakMap; primitives key on
JSON-encoded [scope, permission, typeof, value] so 7 and "7" stay distinct
and a tenant id containing the separator cannot collide.

Scope is also read at decision time rather than frozen when the middleware
runs, and is part of the memo key, so switching tenant mid-request no longer
returns the previous tenant's verdict.

guardPermission additionally: denies instead of 500ing when getResource
throws (and no longer leaks the loader's message), skips redirectTo for API
requests using the same rule requireAuth applies, refuses a non-local
redirect target, and sets cache-control: private, no-store.

Adds eleven regression tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:35:05 +05:30
ClintchizandClaude Opus 5 d6a2d05407 docs: hoist the deny set and document permissionsFor's limits
Two issues the Task 6 re-review raised against the fix diff.

permissionsFor rebuilt the deny Set inside its loop over granted entries,
making it O(grants x denies) allocations on a per-request path. Measured
632ms at 4000x4000, ~100% of it in repeated Set construction. Hoisted.

permissionsFor also only half-delivers on "the obvious composition agrees
with decide()". A narrow deny beneath a broad grant is not representable in
a Set of strings - the set keeps post:* while decide() correctly refuses
post:delete - so callers that match against the set would offer actions the
server rejects. Documented the limit on the interface and pointed callers at
decide()/can()/filterCan() for per-action gating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:09:34 +05:30
ClintchizandClaude Opus 5 86b3dc1e6a docs: close two auth bypasses and four fail-open paths in the Task 6 engine snippet
The plan's engine had a genuine authorization bypass and several fail-open
branches. Task 7 builds can() on this, so the source of truth is fixed before
that lands.

CRITICAL - anonymous callers bypassed every bound policy on a public:true
permission: the anonymous branch returned allow before the policy loop. A
permission marked "public, but not when embargoed" was fully open to
unauthenticated traffic, and the least-trusted caller got the weakest
evaluation. Policies now run on the anonymous path too; public relaxes the
identity requirement, never the policy requirement.

CRITICAL - the policy verdict check was truthiness-based, not an identity
check, so a policy returning {allowed: "yes"} or {allowed: 1} granted access.
It now compares against true.

A binding naming a policy the catalog lacks was skipped, granting whatever
the policy guarded; it now denies. Falsy and non-string subject ids fell
through to the anonymous path - {id: 0} became anonymous and {id: 123} reached
the store as a lookup key; only a non-empty string now identifies a subject.

Two design forks, ruled by the human: denies honour wildcards, so denying
"post:*" blocks post:delete instead of being accepted and doing nothing; and
permissionsFor subtracts denies, so composing it with permissionMatches
agrees with decide() rather than silently losing deny precedence.

Adds deniedBy() and six regression tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:52:32 +05:30
ClintchizandClaude Opus 5 c499f136fd docs: fix self-contradictory audit test in the Task 6 plan snippet
The 'denials are audited' test assigned role editor, which holds post:*, so
decide(post:delete) was legitimately an ALLOW under the wildcard rule the
same task specifies. The test then asserted one audited denial and got zero.
Switched to moderator (post:comment:*), which genuinely lacks post:delete.

Caught by the Task 6 implementer running the transcribed test against the
transcribed implementation. Plan-origin defect, fixed under standing
authority.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:41:11 +05:30
ClintchizandClaude Opus 5 d609a41222 docs: widen logSafe to Unicode line separators in the Task 5 plan snippet
The re-review confirmed the log-injection fix works for C0 and DEL, but
U+0085 (NEL) and U+2028/U+2029 pass through. Those are line terminators to
some log shippers and to JavaScript's own lexical grammar, so they can still
split a record downstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:26:02 +05:30
ClintchizandClaude Opus 5 ba83038d8d docs: fix audit-log injection in the Task 5 plan snippet
consoleAuditSink interpolated subjectId, tenantId and reason straight into
the log line. A newline in any of them forges a second entry that reads as a
genuine audit record - the reviewer produced a fake
'[wrnexus:authz] allow admin:everything subject=root' line. Those values
trace back to request input.

Interpolated fields now go through logSafe(), which replaces control
characters. Adds the missing coverage the review flagged: consoleAuditSink
injection, malformed-sink handling, and memoryAuditSink.clear().

Plan-origin defect, fixed under standing authority to amend the plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:19:05 +05:30
ClintchizandClaude Opus 5 83f2951035 docs: fix cache-key collision in the Task 4 plan snippet
The plan's cachedPermissionStore used scopeKey + U+FFFD + subjectId as a
cache key with no escaping, so ('a', 'b<sep>c') and ('a<sep>b', 'c') collide
and one subject is served another's permissions. Subject and tenant ids are
unconstrained strings, so nothing prevented it.

Key is now JSON-encoded, and the global-write sweep tracks keys per subject
instead of substring-matching. Adds the two regression tests that were
missing: cross-tenant invalidation on a global write, and key collision.

Ruled by the human as plan-mandated; source of truth amended so a re-run of
the plan does not reintroduce the defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:00:35 +05:30
ClintchizandClaude Opus 5 0ac648bc26 docs: resolve two pre-flight conflicts in the authz plan
- Global Constraints said the change was additive while Task 8 changed
  authorizeDecision's 403 body. Ruled: the security fix governs; the
  constraint now names it as the one approved exception.
- Task 6 defined permissionsFor and then re-implemented it inline in
  decide. Both now call a single loadEffective helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:24:07 +05:30
ClintchizandClaude Opus 5 10da210b0a docs: implementation plan for the authz permissions system
Fifteen TDD tasks covering phases 1-3 of the approved design: registry,
catalog merge, PermissionStore with a shared conformance suite, caching
decorator, audit sink, resolution engine, request middleware and guards,
router discovery, database adapter, codegen, and the wrnexus authz CLI.

Phases 4 (.wrn view can()) and 5 (admin UI) are documented as deferred with
the reason each needs its own design pass.

Also folds in the authorizeDecision disclosure fix as Task 8, since the new
guards share its 403 shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:10:37 +05:30
ClintchizandClaude Opus 5 b209936f86 docs: design for the authz permissions system
Separates declaration (what permissions, roles, policies and attributes exist)
from assignment (who holds what), building on the decision primitives already
in advanced.ts rather than replacing them.

Covers the registry and app/authz discovery, the PermissionStore interface
with memory and db adapters, tenant-scoped assignments meeting the existing
TenantMembership, deny-wins precedence, fail-closed behaviour, the audit sink,
codegen and CLI introspection, and the seam for propagating subject context to
the inter-app communication system.

Records two decisions worth keeping: cross-app sharing needs no runtime
catalog distribution (declarations are static code in the shared package;
only assignments are shared, via the database), and can() stays off Context
to avoid a core -> authz dependency cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:57:22 +05:30
Clintchiz ee98026cc5 first commit 2026-07-12 15:55:18 +05:30