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>
verifyJwt gates its maxAge check on iat being a number, so a token forged
without iat was honoured at any maxAgeSeconds - the same shape as the
audience and exp fail-opens closed in the previous round. A future-dated iat
did the same via a negative age. Both refused now.
The import side never checked aud was a single string, and verifyJwt compares
with includes(), so a multi-audience token verified at several apps. The
mint-side guard's invariant now holds where it is enforced.
ctx.tenant present with a null id minted an authenticated credential with no
tenant claim, which the callee reads as global. Absent ctx.tenant means
untenanted; a present tenant with an unusable id is an error.
Adds six tests pinning behaviours that mutation testing showed were free to
delete without any test noticing: no-exp, no-iat, the 300s default max age,
an array audience on import, a non-string tenant claim on import, and a null
tenant id at mint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- importSubjectContext now rejects a non-string/empty selfApp before
verifying. verifyJwt skips the audience check entirely when audience
is undefined, so an unvalidated selfApp (the natural shape of
currentAppName(): string | undefined) accepted every token from every
app for every audience.
- exportSubjectContext now rejects a non-string/empty targetApp, so an
array can no longer mint one token valid at multiple apps.
- Both directions now reject a present-but-non-string tenant id instead
of silently dropping it (was: callee reads missing tenantId as
global/unscoped -> cross-tenant exposure).
- importSubjectContext now requires exp to be present and independently
bounds accepted token age via a new maxAge/ImportOptions.maxAgeSeconds
(default 300s), so a caller cannot mint a long-lived token via a huge
ttlSeconds and have it honoured indefinitely.
- SubjectContext.callerApp doc now states it is self-asserted (the
signing secret is workspace-wide) and must never be an authz input.
- index.ts also exports the new ImportOptions type.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- isRetryableStatus now fails closed for out-of-range values (600+, negative,
NaN) by bounding the 5xx check on both sides (>= 500 && <= 599), instead of
an unbounded >= 500 that classified garbage statuses like 1000 as retryable.
- 408 Request Timeout is now retryable, matching the RPC_TRANSPORT doc
comment (connection, timeout, 5xx) — a timeout surfaced as 408 is no longer
treated differently from the same timeout surfaced as 504.
- Add RPC_MALFORMED: the callee answered, but not with a ServiceResult (HTML
error page, truncated body, unexpected shape). Distinct from RPC_TRANSPORT
since something DID respond; non-retryable via the existing retryableFor,
no new branch needed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Drop the redundant eslint-disable on AnyProcedures; no-explicit-any
is off repo-wide so the directive itself was the warning. Doc
comment now explains why none is needed.
- Rename test's schema binding to _schema per the lint config's
underscore-prefix rule for read-only-as-type bindings.
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>
guardPermission's getResource catch returned 403 directly, never reaching
decideFor -> decide -> finish, so the audit sink never saw it — an attacker
probing ids that make the resource loader throw got a clean 403 stream
invisible to the audit trail. The audit sink is now stashed on the
per-request RequestAuthz object (authzMiddleware already receives it via
AuthzResolverOptions), and the catch records an "allowed: false" event with
an opaque reason before returning the 403.
Also: the explicit-deny check sat outside decide()'s try/catch, and
deniedBy() guarded on denies.length rather than Array.isArray(denies). A
store returning denies as a bare string let new Set(denies) iterate
characters instead of the permission, so the deny matched nothing and was
silently discarded; a store omitting denies entirely threw straight out of
decide(). Both are now validated and handled inside the try, denying via the
same "Authorization store unavailable" path as any other store failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
createProductionHandlers called setAuthzCatalog unconditionally, so a caller
using client.ts's documented escape hatch (setAuthzCatalog(catalog) before
importing anything that reads it) had that catalog silently wiped to empty
whenever opts.authz was omitted. Now only sets when opts.authz has entries to
contribute, or when nothing has been set yet; a non-empty opts.authz still
always sets and still throws on a genuine conflict.
Also removes RuntimeDeps.authz: nothing read it, and its doc comment
described a consumer that doesn't exist. The real wiring is
getAuthzCatalog()/setAuthzCatalog(), including the HMR hot-update path, which
is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 15 of the authz permissions plan: proves db store + cache + catalog +
middleware + audit compose correctly, wires a real (non-dangling) example
into auth-showcase, and documents the declaration/registration/precedence
surface in the package README.
Fix round 2 for Task 14, addressing a critical review finding reproduced on
a real built server.
C1 (critical): the generated production entry set the authz catalog inside
createProductionServer's BODY, but ES modules evaluate every static import
(including app middleware, emitted as a static import) before the importing
module's body runs. Middleware reading getAuthzCatalog() at module scope —
the same eager shape authzMiddleware({ catalog, ... }) itself requires, and
the pattern app/middleware/logger.ts's `export default requestLogger({...})`
already uses — saw an unset catalog and crashed the whole process at import
time, after every other gate (typecheck/lint/tests/a plain `bun run build`)
stayed green.
Fix: packages/cli/src/build.ts now emits a small side-effecting
`.authz-setup.ts` module containing the static imports of every
app/authz/*.ts declaration plus a call to the new
applyAuthzManifestEarly(entries) (packages/dev-server/src/prod.ts), and
imports THAT MODULE FIRST in the generated entry — before pages, api,
realtime, middleware, components, and layouts. applyAuthzManifestEarly is
deliberately silent (no missing-default-export warnings, though a genuine
conflict still throws and fails the boot at import time); createProductionHandlers
keeps its own unconditional merge+set as an idempotent, always-warning second
pass, so an adapter that bypasses the generated entry and calls it directly
still gets a correctly merged, validated catalog, and so the function stays
independently testable.
I3: corrected packages/authz/src/client.ts's WRN-AUTHZ-SETUP message, which
claimed prod always sets the catalog before middleware runs — true again for
the generated entry after the C1 fix, but not for a custom entry that calls
createProductionHandlers directly.
I2: dev HMR editing app/authz/*.ts reloaded the page while the OLD catalog
stayed authoritative (watch.ts classifies any non-CSS change as "server";
hotUpdate had no authz/ branch) — a false security signal, since tightening
or removing a permission looked like it took effect but didn't until a
restart. Added the branch (packages/dev-server/src/index.ts), and gave
loadAppAuthzCatalog (authz-boot.ts) an injectable importer: a raw import()
would have silently no-op'd on the re-import (Bun caches local TS/JS modules
by filesystem path and ignores query strings), so the hot path routes through
loadModule (pipeline.ts) instead, which copies the edited file to a versioned
sibling specifically to defeat that cache.
I4: added direct createProductionHandlers/applyAuthzManifestEarly tests
(packages/dev-server/test/authz-prod.test.ts: conflict throws naming both
files, missing default export warns and skips, empty array yields an empty
catalog, a second call re-validates rather than trusting a stale singleton)
and the regression test that matters most
(packages/cli/test/authz-prod-coldstart.test.ts): a real `runBuild` + a real
`bun dist/server.js` boot, with a middleware module reading
getAuthzCatalog() at module scope, asserting it actually serves a request.
M5: startServer built its own router once, then loadAppAuthzCatalog built a
second one from scratch on every dev boot and every authz/ hot reload.
loadAppAuthzCatalog now accepts either an appDir (still used standalone, e.g.
by the test suite) or an already-built Router, and both call sites in
index.ts now pass the router they already have.
Every fix in this round was verified non-vacuous by sabotaging it and
confirming the corresponding test fails, then reverting.
Fix round 1 for Task 14 — closes the gap flagged in the last report:
loadAppAuthzCatalog existed but nothing called it.
- packages/authz/src/client.ts (new): setAuthzCatalog/getAuthzCatalog/
hasAuthzCatalog, mirroring @wrnexus/db's client.ts. App middleware runs
at module-eval time and needs the catalog then, so ctx cannot carry it;
getAuthzCatalog() throws a setup error naming the fix, like getDb() does.
Exported from packages/authz/src/index.ts.
- packages/dev-server/src/index.ts: startServer calls loadAppAuthzCatalog +
setAuthzCatalog before middleware is resolved (schemasJs precedent),
and populates the new RuntimeDeps.authz field.
- packages/dev-server/src/runtime.ts: RuntimeDeps gains authz?: AuthzCatalog.
- packages/cli/src/build.ts: emits static imports of each app/authz/*.ts
file into the generated entry (components/layouts precedent) and passes
{ source, module } pairs through ProdOptions.authz — the catalog holds
policy functions, so it cannot be JSON-baked like schemasJs.
- packages/dev-server/src/prod.ts: createProductionHandlers merges those
declarations and calls setAuthzCatalog before the server accepts
traffic, so a conflict fails the boot instead of surfacing on the first
request. Runs for every deployment adapter, not only Bun.serve.
The framework never installs authzMiddleware itself; the app still
registers it with its own store.
Verified end-to-end: added a temporary app/authz declaration to
examples/basic-app, ran `bun run build`, inspected the generated entry's
static import + authz array, and booted dist/server.js to confirm the
merge/setAuthzCatalog call succeeds against real bundled code (reverted
before commit).
Adds loadAppAuthzCatalog(appDir) to @wrnexus/dev-server: discovers
app/authz/*.ts declarations via buildRouter, imports and merges them
into an AuthzCatalog, returning an empty catalog when the app has no
declarations. A declaration with no default export is skipped with a
warning; a genuine conflict between two declarations throws
WRN-AUTHZ-CONFLICT naming both source files.
Declared the missing @wrnexus/authz workspace dependency in
dev-server's package.json.
Round-1 review fixes for Task 13:
- packages/cli/package.json was missing @wrnexus/authz, and
packages/authz/package.json was missing @wrnexus/core despite importing
its types in index.ts/middleware.ts/advanced.ts. Both only worked
in-repo because bare "@wrnexus/*" specifiers resolve through the root
tsconfig.json paths map; a standalone install of @wrnexus/cli or
@wrnexus/authz would fail at runtime.
- authz.ts's unknown/missing-subcommand and bad --dialect paths now
console.error + process.exit(1), matching db.ts's convention, instead
of throwing — index.ts's top-level catch previously printed those as a
raw stack trace. Added a subprocess-level test that spawns the real CLI
and asserts stderr has the usage line with no stack frame.
- nextMigrationNumber now extracts the leading-digit run the same way
db/migrate.ts's nextNumber does, instead of a fixed slice(0, 4) that
would have undercounted once a migration number passed 9999.
Introspects the merged authz catalog, emits app/authz/permissions.gen.ts
type unions, and scaffolds the assignment-table migration. init validates
--dialect explicitly (unrecognised values reject rather than silently
falling back to sqlite) and joins authzMigrationSql's up/down statement
lists with terminators instead of interpolating the arrays.
Test scaffolding for dynamically-imported app/authz declarations must
live inside the repo tree (not os.tmpdir()) for the "@wrnexus/*" bare
specifier to resolve via tsconfig paths; .gitignore excludes the scratch
dirs this produces.
Emits sorted TS unions from the merged catalog so a typo in
can(ctx, "post:wrtie") is a compile-time error. Uses JSON.stringify
for string-literal escaping (not manual backslash/quote replace) so
role names containing raw newlines still produce valid TypeScript;
role names are not regex-validated like permission ids, so this
matters for the raw mergeCatalogs path.
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>
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>
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>
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.
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>
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.
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.
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.
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.
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.
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.
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.
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>
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>
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.
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.
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.
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>