Commit Graph
179 Commits
Author SHA1 Message Date
ClintchizandClaude Opus 5 91e5c6e0c5 fix(authz): guard scopeKey's tenantId type, add deterministic C1/C2 guard
N1: scopeKey guarded the empty-string VALUE but not the TYPE. A
non-string tenantId (null, 0, false, an object) flowed through
un-normalised, and the adapters disagreed about the result - db
rejects null on NOT NULL, memory accepts it as an unreachable row; 0
and false stringify differently and could collide. Now
`typeof tenantId !== "string" || tenantId === ""` is refused with the
same WRN-AUTHZ-SCOPE error. Added a conformance case covering
null/0/false/{}.

N2: nothing failed if grant() were re-wrapped in db.tx, reintroducing
the shared-connection rollback from C1/C2 - timing-based tests can't
reliably prove a transaction is never opened. Added
db-no-transaction.test.ts: a fake Db with a spied driver.transaction
and statement-recording all/exec, driving every PermissionStore method
and asserting zero transaction calls and no "BEGIN" in any recorded
statement. Verified it fails when grant() is temporarily re-wrapped in
db.tx, then restored.

Also documents two things in db.ts as comments only: the UNIQUE
constraints are now load-bearing for ON CONFLICT/ON DUPLICATE KEY
target inference, and MySQL's VALUES(effect) upsert syntax is
deprecated since 8.0.20 (no MySQL server in CI to catch its removal).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:06: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 1cc0b97a72 fix(authz): replace vacuous concurrency test, validate effect in memory store
The fix-round-1 test "a concurrent write is not lost to another
method's failure" was vacuous: a single-process Promise.all cannot
reliably land a bare write inside another method's open transaction,
so it passed against both the fixed and the (previously) defective
grant() implementation. The shared-connection rollback hazard it was
meant to catch is real (confirmed separately by forcing the
transaction open before the write), but this specific test could
never reach that state and gave false assurance either way.

Replaced it with "a rejected write leaves unrelated state intact",
which asserts a grant() call with an invalid effect is refused without
disturbing the subject's existing roles/grants, plus a NOTE
documenting that the rollback hazard is now prevented structurally (no
transactions) rather than by a dedicated concurrency test.

memoryPermissionStore.grant() had no effect validation, so it failed
the new test; added a guard mirroring the db adapter's CHECK
constraint so both adapters agree on rejecting anything other than
"allow"/"deny".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:52:18 +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 205f4e2d4c fix(authz): close fail-open db store defects from review round 1
C1/C2: grant() wrapped its delete+insert in db.tx on a shared,
unserialized sqlite connection, so a concurrent bare write from another
method (e.g. revokeRole) got swept into the open transaction and
discarded on rollback - a revoke could report success while the
privilege survived. Also broke concurrent grants on distinct keys
("cannot start a transaction within a transaction"). Replaced with
single-statement upserts (ON CONFLICT / ON DUPLICATE KEY UPDATE),
atomic without a transaction.

I1: assignRole's check-then-act SELECT lost 19/20 concurrent identical
calls to a UNIQUE violation; switched to ON CONFLICT DO NOTHING.

I2: an unrecognised `effect` value was dropped from both the grant and
deny buckets on read. Added a CHECK constraint and made anything not
literally "allow" count as a deny (fail closed).

I3: ensureAuthzTables defaulted to sqlite instead of the Db's own
dialect. I4: scopeKey now refuses an explicitly empty tenantId rather
than treating it as global (shared with the memory adapter). I5: added
migrations.test.ts asserting the generated DDL per dialect, including
MySQL's binary collation on identity columns. M1: DDL is now a
statement list instead of a blob split on a formatting-dependent
separator. M3: declared @wrnexus/db as a workspace dependency.

Extends the conformance suite with four concurrency/empty-scope tests
(23 total, up from 19) that all three adapters now pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:46:48 +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
Clintchiz 3fa3fce5df feat(authz): add database-backed PermissionStore
Adds dbPermissionStore/ensureAuthzTables/authzMigrationSql, backed by
_wrn_authz_assignment and _wrn_authz_grant tables, plus a ./db subpath
export. Passes the identical 19-test store-conformance suite the memory
adapter passes, including tenant-scope isolation.
2026-08-04 20:22:31 +05:30
ClintchizandClaude Opus 5 218f5e2dd6 chore: add .gitattributes enforcing LF
The repo had none, and core.autocrlf=true is the usual Git-on-Windows
setting, so a clone, checkout, or stash pop silently rewrites every text file
to CRLF. That fails format:check against prettier's endOfLine: lf - it
already turned the gate red once mid-branch, after a stash round-trip
reintroduced CRLF into files that had been committed clean.

Verified: no tracked file currently carries a CR byte at HEAD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:17:39 +05:30
ClintchizandClaude Opus 5 e136fbc56a fix(router): quietly skip permissions.gen.{ts,js} in authz scan
Task 10 fix round 1: the coordinator's plan doc (41fb82b9) recorded that
generated authz type files should be skipped before the isSafeIslandName
check, but the code change never landed. isSafeIslandName rejects the dot
in the stripped basename "permissions.gen", so every app running Task 12's
codegen would warn on every boot.

Add a quiet skip for *.gen.ts / *.gen.js immediately after the extension
guard, before the name check. Add tests: a .gen.ts file is skipped without
a console.warn (spied), and a .gen.js file is skipped the same way while a
legitimately named .js declaration is still discovered.

Also corrects the scanDir extraExtensions doc comment, which incorrectly
implied app/schemas passes it too (only app/authz does).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:09:16 +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
Clintchiz e7743cdbb5 feat(router): discover app/authz declarations
Scan app/authz/<name>.{ts,js} the same way app/schemas is scanned,
exposing Router.authz: ComponentRef[]. Also update the two other
literal Router construction sites (prod runtime, dev-server test
fixture) that now need the new required field.

scanDir gains an optional extraExtensions parameter (default []) so
the authz scan can accept .js files without widening the extension
allow-list used by route scanning (app/pages, app/api, app/realtime),
which would otherwise leak .js into generated route URLs via
fileToRoute.
2026-08-04 19:52:37 +05:30
Clintchiz 703baa1ead fix(authz): strengthen permissionMatches warning, complete export coverage
Move the "don't gate on permissionsFor() with permissionMatches" warning
onto permissionMatches itself so it's visible via autocomplete, not just
on AuthzResolver.permissionsFor. Round out exports.test.ts to cover
scopeKey, safeRecord, and AUTHZ_LOCALS_KEY, closing the gap where
dropping either export from index.ts would not fail the test.
2026-08-04 19:38:07 +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
Clintchiz 6f3a53b9ff feat(authz): export registry, store, engine, and middleware surface
Appends the Task 1-8 modules (defineAuthz, catalog merge helpers,
permission stores, audit sinks, resolver engine, and authzMiddleware/
can/guards) to the public @wrnexus/authz surface, and regenerates the
public-api-0.8.json baseline to match.
2026-08-04 19:29:08 +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
Clintchiz e15422ed8d fix(authz): stop authorizeDecision leaking policy names in 403 bodies 2026-08-04 19:17:48 +05:30
Clintchiz 3f1fcd0d2d fix(authz): stop encodeURI from double-encoding a percent-escaped redirectTo
Fix round 3 for Task 7 (N5, minor-to-important): fix round 2's
encodeURI(options.redirectTo) fixed the non-ASCII crash but broke the
most common real use of redirectTo -- a return-path query param that's
already percent-encoded (e.g. /login?next=%2Fdash) -- because encodeURI
also escapes "%", double-encoding it to %252Fdash. Replaced with
headerSafePath(), a codepoint loop that encodes only codepoints above
0x7f (matching isLocalPath's style: no regex, no source escapes) and
leaves "%" alone.

Added tests: an already-percent-encoded target round-trips unchanged;
a non-ASCII target still 303s without throwing and the location is
ASCII-only; a plain ASCII target passes through byte-identical.
2026-08-04 19:10:09 +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
Clintchiz 77b9e49bf2 fix(authz): fold subject into the memo key, fix symbol/-0 and redirect issues
Fix round 2 for Task 7 (plan amendment 9e3624e5):

- N1 (Important): the memo key carried scope and permission but not the
  subject, so a request that reassigns ctx.user mid-flight (impersonation,
  step-up auth, session revocation, or an authz-before-auth middleware
  ordering mistake) could be served the previous principal's cached
  verdict. subjectId (typeof + String, matching the existing scope/value
  encoding style) is now folded into every memo key.
- N2 (Minor): the primitive-value memo key used String(resource), which
  collapses distinct Symbol("row") values into one slot and maps -0 onto
  0's slot. Added a dedicated bySymbol identity memo (WeakMap-style, but a
  plain Map since symbols aren't valid WeakMap keys pre-registry symbols
  and the memo is request-scoped anyway) and special-cased Object.is(x,-0)
  to render as "-0".
- N3 (Minor): the rejected-redirect console.error interpolated
  redirectTo directly, exactly the value most likely to carry CR/LF in
  that branch. Switched to JSON.stringify(redirectTo) for the log line.
- N4 (Minor): a non-ASCII (but otherwise valid, local) redirectTo passed
  isLocalPath and then threw inside `new Response` building the Location
  header. Wrapped it in encodeURI().

Added 5 regression tests: subject swap re-evaluates, clearing ctx.user
denies, two same-description symbols get separate verdicts, 0 vs -0 get
separate verdicts, non-ASCII redirectTo 303s with an encoded location
instead of throwing. N1 revert-checked: temporarily restored the
two-element (no-subject) key and confirmed both subject-swap tests fail
against it before restoring the fix.
2026-08-04 18:59:56 +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
Clintchiz b7f3507b59 fix(authz): close memo cross-authorization and guard hardening gaps
Fix round 1 for Task 7, addressing review findings against the brief's
own memoKey design (now superseded per plan amendment cc8085bc):

- C1: memoKey's String(id) + JSON.stringify-with-catch cross-authorized
  distinct resources whenever their ids stringified the same (numeric
  vs string ids, object-shaped ids) or whenever JSON.stringify threw
  (circular references, BigInt fields, throwing getters all shared one
  "<unserialisable>" bucket, so the first verdict computed for any of
  them became the cached verdict for all of them in that request).
- C2: filterCan inherited the same bypass, returning rows the subject
  could not act on.
- Replaced serialisation-based memoization with identity-based
  memoization: object resources are memoised in a WeakMap keyed by the
  resource reference itself (never serialised), primitives/absent
  resources in a Map keyed by [scope, permission, typeof, String(value)]
  so 7 and "7" can never collide.
- I1: scope is now read from ctx.tenant at decision time (currentScope),
  not captured once at middleware-install time, so a tenant switch
  mid-request is honoured on the next check.
- I2/M1: guardPermission's redirectTo now only fires for non-JSON/API
  requests (replicated wantsJson check, since authz may only import
  core as types) and only for a validated local path (isLocalPath),
  closing an open-redirect and a JSON-caller-follows-303 gap.
- I3: getResource is now wrapped in try/catch; a throw denies with the
  standard opaque 403 body instead of propagating the loader's error
  (e.g. a SQL string) to the client.
- Added cache-control: private, no-store to both the 303 and 403
  responses.

Added 11 regression tests. C1/C2 revert-checked: temporarily restored
the old memoKey design and confirmed the four collision tests fail
against it before restoring the fix.
2026-08-04 18:41:18 +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
Clintchiz 984c6236d3 feat(authz): add request middleware, can(), and guardPermission
Installs a per-request authz resolver via authzMiddleware and exposes
can()/decideFor()/guardPermission()/filterCan() as free functions (not
Context members, so @wrnexus/core stays free of an authz dependency).
All four route through resolver.decide(), never permissionsFor(), so
resource-scoped policy denials can't be bypassed via the coarse
permission set. Per-request results are memoised keyed on (permission,
resource) to avoid re-hitting the store within a request without
leaking one resource's verdict onto another.
2026-08-04 18:24:40 +05:30
ClintchizandClaude Opus 5 cd82bec414 fix(authz): fix perf, doc, and fail-open gaps found in second review
Re-review of Task 6's fix round 1 (plan amendment d6a2d054) found three
items in that diff plus one adjacent pre-existing issue that C1 made
reachable:

- Important (perf): permissionsFor() rebuilt the deny Set on every
  entry in the granted set (O(grants x denies) allocations on a
  per-request path). Hoisted to build the Set once. Measured
  4000x4000: 665.92ms before, 3.90ms after.
- Important (contract accuracy): permissionsFor() only half-agrees
  with decide() — a narrow deny under a broad grant (e.g. role editor's
  "post:*" plus a deny on "post:delete") can't be represented in a flat
  Set, so the set still contains "post:*" while decide() correctly
  refuses "post:delete". Documented as NOT authoritative on the
  AuthzResolver interface, and pinned with a regression test asserting
  the divergence is deliberate.
- Minor: subject.id === "" was audited as subjectId: "" instead of
  omitted, so consoleAuditSink printed a blank subject= rather than
  subject=anonymous. Reused the same non-empty-string guard as the
  decide() path.
- Important (adjacent, advanced.ts): owner() compared subject[key] to
  resource[key] with Object.is without checking either side was
  present, so two absent ids (Object.is(undefined, undefined) ===
  true) satisfied ownership. Unreachable before this task, but C1 now
  runs bound policies for anonymous/empty subjects, putting this on a
  live path. Fixed to deny whenever either side is undefined or null.

Every fix's regression test was verified by reverting the fix and
confirming the test fails against the pre-fix code before restoring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 18:15:34 +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 ae37c9b57a fix(authz): close fail-open engine gaps found in review
Coordinator review of Task 6's resolution engine (plan amendment
86b3dc1e) found two critical and four important defects, all inherited
from the brief's original engine snippet:

- C1: anonymous callers on a public permission returned allow before
  running bound policies, so the least-trusted caller got the weakest
  evaluation. Policies now run for anonymous subjects too.
- C2: the policy verdict check was a truthiness test (`!verdict.allowed`),
  so a policy returning `{allowed: "yes"}` granted access. Now requires
  `verdict?.allowed === true` exactly, and no longer spreads the raw
  verdict into the decision (which leaked arbitrary policy fields).
- I1: a binding naming a policy the catalog doesn't have was silently
  `continue`d, granting whatever the policy was meant to guard. Now
  denies with "Policy unavailable".
- I3: denies were checked by exact string equality, so a wildcard deny
  (e.g. "post:*") was accepted and silently did nothing. Denies now go
  through the same depth-aware wildcard matching as grants, via the new
  exported `deniedBy()`.
- I2: `permissionsFor` now subtracts denied entries so it agrees with
  `decide()` — needed for Task 7's UI gating to compose correctly.
- I4: non-string/empty `subject.id` (0, "", 123, {}) no longer silently
  falls back to anonymous; it denies with "Invalid subject". `subject:
  null` (no subject at all) remains genuinely anonymous.

Added six regression tests, each verified by reverting its fix and
confirming the test fails against the old code before restoring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:58:28 +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
Clintchiz 6d8b6daba9 feat(authz): add resolution engine with deny-wins precedence and fail-closed errors 2026-08-04 17:40:19 +05:30
Clintchiz d7509421c7 fix(authz): widen logSafe to strip NEL and Unicode line separators
U+0085 (NEL), U+2028 (LINE SEPARATOR), and U+2029 (PARAGRAPH SEPARATOR)
are treated as line terminators by some log shippers and by JS's own
lexical grammar (and are not escaped by JSON.stringify by default), so
they could still be used to forge audit log entries even after the
initial C0/DEL fix. logSafe now strips all five categories.
2026-08-04 17:29:46 +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
Clintchiz e710756baf fix(authz): sanitize control characters in console audit sink
Prevents audit log injection: subjectId, tenantId, and reason trace back
to request input, so an unsanitized newline could forge a second,
fully-formed audit line indistinguishable from a real entry. Adds
logSafe() to strip control characters before interpolation and logs the
previously-missing policy field.
2026-08-04 17:21:09 +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
Clintchiz f033197850 feat(authz): add pluggable authorization audit sink 2026-08-04 17:10:03 +05:30
Clintchiz dc0771308a fix(authz): eliminate cache-key collision in cachedPermissionStore
The scope-prefix concatenation cacheKey used a bare U+FFFD separator with
no escaping, so an adversarial subject/tenant id containing that character
could collide with a different subject/tenant pair and leak cached roles
across tenants. Switch to JSON.stringify([scopeKey, subjectId]) for an
unambiguous key.

Also replace the untested key.endsWith() substring sweep used to
invalidate a subject across all tenants on a global write with an
explicit bySubject index, and add test coverage for both the collision
and the cross-tenant invalidation sweep.
2026-08-04 17:04:19 +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
Clintchiz 4362d49770 feat(authz): add caching decorator for PermissionStore 2026-08-04 16:47:50 +05:30
Clintchiz a01b7bc99e fix(authz): cover grant/deny scope isolation and revoke scope-isolation in conformance suite 2026-08-04 16:43:08 +05:30
ClintchizandClaude Opus 5 9b6b970cae chore: exclude the SDD scratch workspace from prettier
.superpowers/ holds git-ignored controller artifacts (briefs, reports,
review packages). Prettier still walked it, so format:check — and with it
check:production — failed on scratch markdown.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:38:24 +05:30
Clintchiz 1849213ce4 feat(authz): add PermissionStore contract with memory adapter and conformance suite 2026-08-04 16:36:57 +05:30
Clintchiz d694dda320 feat(authz): merge declaration modules into a frozen catalog 2026-08-04 16:30:41 +05:30
Clintchiz 212fdaa5b5 feat(authz): add defineAuthz declaration registry 2026-08-04 16:26:17 +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
ClintchizandClaude Opus 5 c64434a131 fix(security): close SSRF, credential-leak, and auth bypass findings in 0.8.4
Audit of 0.8.4 found the repo's own gates green, so these came from manual
review; each is covered by a new regression test.

security/fetch.ts
- safeFetch re-attached Authorization/Cookie on a same-origin redirect that
  followed a cross-origin hop (a -> b -> b), handing credentials to the second
  host. Compare against the origin the caller trusted, not the previous hop.
- The private-network guard resolved the host, approved it, then let fetch
  resolve again, so a low-TTL record could answer public for the check and
  private for the connection. Pin the connection to the validated address,
  preserving Host and TLS serverName. Opt out with pinDns: false.
- 0:0:0:0:0:ffff:127.0.0.1, ::ffff:7f00:1 and fec0::1 were not treated as
  private. Add uncompressed IPv4-mapped forms, site-local IPv6, 198.18/15
  and 192.0.0/24.

security/url.ts
- sanitizeUrl returned "//evil.com" verbatim via the relative-path fast path,
  bypassing the host checks it had just run; in an href that navigates
  cross-origin. Resolve protocol-relative input instead.

dev-server/gateway.ts
- Malformed base64 in an Authorization header threw out of checkAuth on an
  unauthenticated path. Fail closed.
- split(":", 2) truncated passwords at the first colon, so a password
  containing ":" could never authenticate.
- The credential compare short-circuited on length mismatch, leaking length
  by timing. Extracted as verifyBasicAuth so it is testable.

authz/index.ts
- Namespace wildcards only matched the first segment, so "post:comment:*"
  did not grant "post:comment:delete". Match at every depth.

uploader/operations.ts
- Validate transcoder dimensions and bitrate rather than trusting the declared
  type, and reject ".." path segments.

package.json
- The brace-expansion override pinned 5.0.8, which is inside the advisory
  range >=4.0.0 <5.0.9. Bump to 5.0.9; bun audit is now clean.

Verified: check:production passes (typecheck, lint, 1033 tests, format,
ASVS, public-API baseline, editor checks).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:57:22 +05:30
Clintchiz 72e4d3eceb release: WRNexusJS 0.8.0
Quality / quality (ubuntu-latest) (push) Failing after 12m19s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-04 12:19:09 +05:30
Clintchiz 4cebacadfe release: WRNexusJS 0.8.3
Quality / quality (ubuntu-latest) (push) Failing after 12m9s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 19:47:30 +05:30
Clintchiz e8f630f12d fix: format generated docs before release verification
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 02:32:11 +05:30
Clintchiz 4550a11460 release: WRNexusJS 0.8.2
Quality / quality (ubuntu-latest) (push) Failing after 22s
Quality / quality (windows-latest) (push) Canceled after 0s
2026-08-03 02:14:54 +05:30