fix(plugin-security): PermissionDeniedError carries status beside statusCode, so a share-link permission refusal answers 403 at both doors - #21429
Conversation
…atusCode The class declared statusCode alone, the only error class in errors.ts that did, so plugin-sharing's share-link route door (which reads status alone) answered a plain member's permission refusal as 500 while the runtime dispatcher answered the same throw with 403. It now carries both spellings with equal values. Pins: an enumeration over every error class errors.ts exports (both spellings, equal values), and a showcase-boot dogfood pin driving one refusal through both share-link doors for create and list. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…rmission-denied-status Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…r's list is self-scoped The self-scoped list from origin/main answers a plain member's list 200 through both doors, so no list request reaches the refusal any more; the pin keeps the create refusal, and the class comment and changeset name it. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…he dispatcher domain The dogfood pin reached the dispatcher door only by importing runtime source, which check:test-source-alias refuses (four new unaliased artifact imports into dogfood). The runtime package imports both doors already, so the two-door create pin moves beside the dispatcher's own #6649 block: one engine with the real SecurityPlugin middleware, one ShareLinkService, one envelope, and both production door entries. The shared-catch case keeps a statusCode-only throw now that the production class carries status too. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
… statusCode PermissionDeniedError now carries both spellings with equal values, so the example dump of the thrown error lists both. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
The example is a partial dump of the thrown error (it lists no developerMessage either), so it states nothing false without status. The docs sweep finds no sentence this change makes false. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5e4907ad9ec2ff2305ea15c042e52a4c5d14656f && git checkout 5e4907ad9ec2ff2305ea15c042e52a4c5d14656f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5 01bb1de91b7b468df9c63ee2d0017fcf07b65cca && git checkout -B drift-repro 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5 && git merge --no-ff 01bb1de91b7b468df9c63ee2d0017fcf07b65cca
node scripts/docs-audit/affected-docs.mjs --json 39a912ea73ddff7fc85ebb3379a8ce74ff1343f5
|
Fixes #21405
Clause-②: no
What was wrong
PermissionDeniedError(plugin-security/src/errors.ts) declaredstatusCode = 403and nostatus. It was the only error class in that module that did. A door that readsstatusalone derived no status from it.plugin-sharing's share-link route door readserr?.status ?? 500, and that door serves/api/v1/share-linkson the standalone server.Measured on a showcase boot (
@objectstack/verify, with the app's own default profile), as a plain member, atmain6d67ad5:/share-linksdomainPOST /share-linkson ashowcase_client_briefrecord the member cannot readPERMISSION_DENIEDPERMISSION_DENIEDGET /share-linksPERMISSION_DENIEDPERMISSION_DENIEDThe dispatcher door was driven in-process, the way
@objectstack/verify's own handle drives it: anHttpDispatcherover the same booted kernel, with the same bearer token.The change (the triage ruling on the card)
PermissionDeniedErrorcarriesreadonly status = 403besidestatusCode = 403. The code, the message,details,developerMessageandstatusCodeare unchanged. No door is edited, and no status helper is added anywhere.statusandstatusCode" note now names the readers as they stand today. The share-link route door and the sandbox boundary's passthrough readstatusalone.errorFromThrownandmapDataErrorread both spellings. The note used to saymapDataErrorreadsstatusalone, which stopped being true when it learned both spellings.Pins
plugin-security/src/errors.test.ts). EveryErrorsubclass thaterrors.tsexports is constructed, and each must carry a numericstatusandstatusCodewith equal values. The population is read from the module's exports, so a class added later is checked without being listed. A floor names the eight classes exported today, so the enumeration cannot pass over nothing.runtime/src/domains/share-links-enforcement-context.test.ts, the new[#21405]block). The harness has one engine double with the wholeSecurityPluginmiddleware booted on it, oneShareLinkServiceand one envelope. It drives both production entries:registerShareLinkRoutesmounted on a route recorder, andhandleShareLinksRequestover the dispatcher's ownerrorFromThrown. A create by a caller with noallowReadon the object answers 403PERMISSION_DENIEDthrough both doors, under thesingleand thegroupposture, and writes no link.GET /share-linksanswers 200 through both doors, with no filter, with the Share dialog's object and record filter, and withincludeRevoked. No list request reaches the refusal any more, so no list case is pinned. Before the merge, a list case was written, and it went red under the ablation below.[#6649]shared-catch case drove the production class to reach the dispatcher catch'sstatusCodechannel. The class now carriesstatusas well, so the case adds astatusCode-only throw beside it, and that channel stays pinned.Tests (head 01bb1de unless noted)
share-links-enforcement-context,data-permission-denied-envelope,permission-denied-error-parityandshare-links-internal-hash-probegive 4 files, 39 passed.pnpm --filter @objectstack/runtime typecheckis OK. Its test layer holds 27 files / 190 errors / 68 signatures in its ledger, unchanged.pnpm --filter @objectstack/plugin-security typecheckis OK.errors.test.tsis in thetsconfig.test.jsonprogram (--listFiles: 1 hit).pnpm --filter @objectstack/plugin-security test: 161 files, 3502 passed, 33 skipped.pnpm --filter @objectstack/plugin-sharing test: 38 files, 928 passed. Both ran at 46b09ea. Since then, the only change in either package is a comment inerrors.test.ts.resttest files that import@objectstack/plugin-securitygive 209 passed. The dogfood share-link files (share-links-self-list,showcase-client-liaison-fixtures,audit-log-internal-fields) pass. Both ran at 46b09ea.dispatch-gates --commandsat 01bb1de derives 67 commands, and all 67 exit 0. The--ranreconciliation reports 67 derived, 67 run and 0 NOT-MEASURED, derived from the recorded exit codes. At an earlier head,check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET: 8 unbuilt packages). Those were built, and every later run exits 0.eslint --no-inline-config --format jsonover the 3 changed TS files reports 3 files, 0 errors and 0 warnings, and the config resolves for each file.eslint.config.mjsenables no type-aware linting (its own note: noparserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file. The fullpnpm lintis CI's.Ablations
Each leg ran from a committed head through
scripts/ablation-replace.mjs: the anchor hit, the blob changed, and the restore was proven by an emptygit diff HEAD.plugin-securityresolves fromdist/in the runtime and dogfood suites, so each of those legs rebuilt it, andscripts/ablation-dist-preflight.mjsproved the marker indist/(4 files) and then absent (all 6 files, tree clean).errors.tsstatusrenamed offPermissionDeniedError(status_ablated_21405)share-links-enforcement-context(head d9f9aab)[#21405]postures, plugin doorexpected 500 to be 403[#6649]dispatcher case included (that door readsstatusCode)errors.test.tsPermissionDeniedError: status=undefined statusCode=403) and the 403 caseexport class AblatedStatusCodeOnlyError extends Error { readonly statusCode = 418; }errors.test.tsAblatedStatusCodeOnlyError: status=undefined statusCode=418 — declare bothstatus = 404onPermissionDeniedErrorerrors.test.tsstatus 404 !== statusCode 403and the 403 caseThe restore legs pass: runtime 20/20 and
errors.test.ts3/3. The first attempt at the planted-class leg did not run.ablation-replacerefused it before any test, because its replacement contained the anchor, so the anchor count did not fall. It was redone with an anchor the replacement does not contain.Docs
content/docs/**(outsidereleases/) andskills/**hold no sentence this change makes false. The status table inprotocol/kernel/error-handling.mdxgives 403 for insufficient permissions, and that is now true at the share-link door.permissions/field-level-security.mdxshows a partial dump of the thrown error withoutstatus. The dump states nothing false, so it is left alone (an edit was made on this branch and reverted).Acceptance notes
packages/qa/dogfood/test/or beside the plugin. A dogfood version came first: a showcase boot, both doors, create and list. It passed, and it went red under ablation. It reached the dispatcher door by importingruntime/src/http-dispatcher.ts, andcheck:test-source-aliasrefused that (exit 1): four new unaliased artifact imports into dogfood (metadata-protocol,observability,rest,service-datasource). Fixing that means aliasing them in dogfood's vitest config, which changes every dogfood boot. The plugin packages cannot import runtime, because of the dependency direction. The runtime package already imports both doors, so the pin moved there, beside the dispatcher's own[#6649]block.statusalone:plugin-sharing/src/share-link-routes.ts, five catches (create, list, revoke, resolve, messages). Create and list are measured above. Resolve and messages read under the system context, so they never meet this refusal. Revoke is not measured.SANDBOX_ERROR_PASSTHROUGHinruntime/src/sandbox/quickjs-runner.tscarriescode,fields,statusanduserMessage, but notstatusCode. APermissionDeniedErrorfrom a host call inside a sandboxed body now crosses with its 403. Not measured.metadata-protocol/src/protocol.ts, thedeleteMetaItemcatch (e.status = err?.status ?? 500). Not measured.service-analytics/src/analytics-service.ts,hasDeclaredErrorEnvelope(a numericstatusplus acode). APermissionDeniedErrornow counts as declared and is re-thrown before the missing-source heuristic. Not measured.runtime/src/domains/actions.ts, thesetActionActivecatch (status, else 503). Not measured, and this refusal is unlikely there.rest'sresolveErrorResponsepassthrough readsstatusalone. APermissionDeniedErrorused to fall through tomapDataError, which answered 403PERMISSION_DENIEDwith the message andobject. It now takes the passthrough arm, with the same status, code andobject. The message passes the 500-character client bound and the declared-code-prefix strip, and neither changes a message inside those limits. This is read from the code; the 13resttest files above pass.rest-server's analytics envelope reader ① now answers this refusal where ①b answered it, with the same status and code. Read from the code, not measured.plugin-auth'screateOAuthClientcatch readsstatusalone, but it only meets better-auth errors.runtime/src/domains/share-links.ts(the catch's docblock) says the enforcement refusals "carrystatusCode, notstatus". That is no longer true ofPermissionDeniedError. Carrier: whoever next touches that file; no carrier is named.plugin-security/src/packaged-permission-set-lock.tsholds two more 403 classes outsideerrors.ts. Both already carry both spellings. As ruled, the enumeration coverserrors.tsexports only.createLinkrefusals) is not addressed here. Triage orders it after this card, and its pins can now read either door.Generated by Claude Code