Uh oh!
There was an error while loading. Please reload this page.
fix(rest): meta app by-name answers a permission-denied envelope, not absence (#8013) - #8135
Conversation
… absence `GET /api/v1/meta/app/<name>` collapsed three different refusals into one 404-equivalent, so an app the session may never open and an app that does not exist were byte-identical on the wire. The console has nothing to branch on and renders its only copy for an absent app -- "it may still be publishing" -- over a permanent authorization denial. Measured cost on objectui#4252: two acceptance-test batches chasing a "platform defect" that was a missing permission-set binding. `filterAppForUser` now delegates to `filterAppForUserWithReason`, which reports WHICH gate fired. Exactly one of them converts: an app that EXISTS and whose `requiredPermissions` the session lacks answers 403 `PERMISSION_DENIED` (ADR-0112 standard catalog) in the declared envelope, written through the shared `sendError` from `@objectstack/types`. Everything else keeps answering absence, and the reason is taken from the branch that fired rather than inferred from `null` at the call site: - `_unpublished` -- ADR-0045 section 3 makes it externally unobservable, and a 403 confirms existence (#4829 pinned 404-over-403); - `requiresService` -- ADR-0057 D10 capability absence is a deployment fact, not a denial to this caller; - a nonexistent name -- converting it would make every app name on the platform enumerable, a different and unruled change; - the list route `GET /meta/apps` stays filtered exactly as-is, with no `authorized: false` leakage. All four acceptance criteria pinned in meta-app-publish-gate.test.ts on `status` AND `code`, plus the two partition cases. Five reversals confirm each pin goes red for its own reason; the naive "any null => 403" reddens four at once, including #4829's own unpublished-app pin. Fixes#8013 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
hotlong
commented
Aug 12, 2026
PM review — |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8013
Backend half of the ruling of 2026-08-12 (「接受你的全部建议。」). The console half is objectstack-ai/objectui#4252 and is blocked on this.
The code to branch on
code: "PERMISSION_DENIED"·status: 403, in the declared envelope:{ "success": false, "error": { "code": "PERMISSION_DENIED", "message": "You do not have permission to open the 'finance' app." } }PERMISSION_DENIEDis the ADR-0112 standard catalog member for a generic authorization refusal — the ledger's own rule is that a generic condition takes the catalog rather than registering a synonym — and it is whatstandardErrorCodeForHttpStatus(403)answers. objectui#4252 readsbody.error.code, which is the same accessor as the neighbouring absence answer, so the console needs one access path and not two.What changed
GET /api/v1/meta/app/{name}collapsed three different refusals into one 404-equivalent, so an app the session may never open and an app that does not exist were byte-identical on the wire. The console has nothing to branch on, so it renders its only copy for an absent app — "it may still be publishing" — over a permanent authorization denial. Measured cost on objectui#4252: two acceptance-test batches spent chasing a "platform defect" that was a missing permission-set binding.filterAppForUserreturned a barenullfor all three. It now delegates tofilterAppForUserWithReason, which reports which gate fired;filterAppForUseris an unchanged thin wrapper over it, so the list route and the existing unit pins are untouched.Exactly one of the three converts:
requiredPermissionsnot heldPERMISSION_DENIED_unpublished(ADR-0045 §3)requiresServiceabsent (ADR-0057 D10)GET /meta/appsThe reason comes from the branch that fired, never inferred from
app == nullat the call site. That distinction is the security boundary of the card:_unpublishedstays absent because ADR-0045 §3 makes an unpublished app externally unobservable, and a 403 confirms existence.meta-app-publish-gate.test.tshas pinned the 404-over-403 choice since rest: filterAppForUser treats app hidden flag as builder-only access gate — built-in account app returns 404 for all normal users #4829.requiresServicestays absent because an absent optional service is a deployment fact about the platform, not a statement about this caller — nothing was denied to the session.Ordering is load-bearing for the same reason:
_unpublishedis judged first, so an app that is both unpublished and permission-gated reports absence.Landing — confirmed as the card predicted
packages/rest/src/rest-server.ts, located by symbol:filterAppForUserand the by-name handler's app branch. The card asked to report a corrected landing if the by-name path resolved inmetadata-protocolinstead — it does not. The gate is re-derived inrest-server.ts, and the cached read path is excluded forisAppType, so every app-typed by-name request already takes the uncached branch that holds this gate. Nometadata-protocolchange, and no second file edited "to be safe".Only the meta-apps region is touched, so this does not collide with #8071 (
/security/suggested-bindings) in the same file. Rebased ontomainbefore push.Acceptance criteria
All four are pinned in
packages/rest/src/meta-app-publish-gate.test.ts, which already owned this route's response-body facts. Every case assertsstatusandcode(ADR-0112) — never "an error came back", since both answers under test are errors one apart.PERMISSION_DENIED, no document and no leaked object names.undefined, and the producer's declaredRESOURCE_NOT_FOUND/ 404 rejection thatmetadata-protocolactually throws.authorizedkey, noPERMISSION_DENIED, no trace of the withheld app; and the holder still receives it.Plus two partition pins: unpublished-and-permission-gated stays 404, and absent-service stays 404.
Reverse verification
Five reversals, each confirming the pin it targets goes red for the expected reason:
expected 404 to be 403visibleguard, infer the reason fromnullexpected 403 not to be 403expected 403 to be 404authorized: falseinstead of droppingexpected [ 'account', 'crm', 'finance', …(1) ] to not include 'finance'expected 403 to be 200null⇒ 403"expected 403 to be 404×3,expected 403 not to be 403R5 is the one that matters most: the naive implementation reddens four pins at once, including #4829's own
SINGLE ITEM: the unpublished app 404s for a non-builder.Criteria 2, 3 and 4 stay green under R1, and that is the correct direction rather than a gap — they pin behavior the fix deliberately leaves unchanged, so removing the fix cannot break them. Each has its own reversal above (R4, R2/R2b, R3).
Restored afterwards and confirmed byte-identical (
git diffmatches the saved patch exactly).Gates
All run on the rebased tree (post-#8071, post-#8088), with the dependency closure built first.
pnpm --filter @objectstack/rest test— 101 files, 1678 tests, all passing.pnpm --filter @objectstack/rest typecheck— clean.pnpm check:type-check-debt— OK, "none above its recorded number";@objectstack/reststays at its recorded 155 and is absent from the surplus list, i.e. measured exactly at the ceiling. The ledger is not raised.pnpm check:route-envelope— green including--self-test;rest-server.tsdialect counts unmoved atstringError 44 / siblingCode 75(75 is fix(rest): one error envelope across the three /security/suggested-bindings routes (#7981) #8071's freshly-lowered baseline, which this change does not disturb). The refusal is written through the sharedsendError(@objectstack/types), imported assendEnvelopeErrorbecause this module has a localsendErrorof its own — the sanitizing responder for thrown errors, which is a different thing. The body literal lives in the pinnedSHARED_BUILDER, so no write site is added here.node scripts/check-nul-bytes.mjs— OK, plus a wider self-scan of the changed files.⛔ Out of scope, as the card's Links section requires: #7912 (
filterAppForUsernever consultsenable.apiEnabled) is not folded in.Generated by Claude Code