Skip to content

fix(appapi): an ownership 403 is not an access problem — stop printing the Apps-author remedy - #522

Merged
ZacxDev merged 6 commits into
mainfrom
zach/listing-not-owned-403
Sep 4, 2026
Merged

fix(appapi): an ownership 403 is not an access problem — stop printing the Apps-author remedy#522
ZacxDev merged 6 commits into
mainfrom
zach/listing-not-owned-403

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

The bug

listingError's 403 arm ends in a catch-all that appends "— managing store listings needs Apps-author access (invite-only beta)" to every non-scope, non-takedown 403. One of those is the server's NOT_OWNED refusal, where the caller's Apps-author access is fine and is not what failed.

Third instance of the wrong-subject class this package already catalogs: #374/#391 on the 400 arm, #509 on the takedown arm, ownership here. The comment above the fallback already names the class — this is the case it did not yet cover.

Why it is definitely not an access problem

Measured 2026-09-03 against a real listing. The refusing account was a moderator and the publish request's submitted_by_user_id, and was still refused:

  • ownership resolves kind-aware to the OAuth client's owner (resolveListingAccessappBlock.app.userId) — not the listing's denormalized user_id, and not app_block_publish_requests.submitted_by_user_id;
  • there is deliberately no moderator bypass on that gate;
  • the same account managed a listing it did own in the same minutes — one account, two apps, opposite results.

So no grant, re-login or cohort invite changes the outcome, and the remedy the CLI printed was unreachable.

Why the classifier reads the message

The wire carries no distinct code: mapOffsiteError (app-listings.router.ts:359) collapses NOT_OWNED and FORBIDDEN onto the same TRPCError code, so the discriminator is gone before the CLI sees it. That is the identical constraint isInsufficientScopeMsg and isModeratorTakedownMsg already document, and isNotOwnedMsg is written in their style.

Matched on the core shared by all three reachable spellings — they share no noun and no verb:

source message
offsite-listing.service.ts:2467 you can only manage your own listings
:1323 you can only edit your own listings
:1811 you can only submit your own revision

A fourth, :582 you can only withdraw your own publish requests, is unreachable here — its own route (api/v1/blocks/withdraw.ts:229) maps NOT_OWNED to a 404 whose body is identical to NOT_FOUND, deliberately, so it never arrives as a 403.

Tests

Four rows added to TestListingForbiddenTakedownDoesNotBlameTheAccountred at bf4db4b (all four fail on wantAccessClaim, the five pre-existing rows still pass), green here.

A mutation sweep then found something worth fixing in the tests themselves: the predicate's two clauses were each individually redundant — dropping either one left every row green. Two further rows were added to pin the narrowness, built from real shipped near-miss strings rather than invented ones. Each mutant is now killed by exactly its own row, and no other:

mutant killed by
drop the case fold invariant: the not-owned core is matched case-insensitively
drop "you can only " invariant: a non-ownership 'your own' refusal is NOT not-owned (You cannot purchase access to your own chapter.)
drop "your own" invariant: a non-ownership 'you can only' refusal is NOT not-owned (You can only request 2 email changes per day.)
predicate always false the four not-owned rows, on this guard's own "still blames the caller's Apps-author access" assertion

The near-miss rows are labelled invariant guards, not regression coverage — neither string can reach this arm today. What they pin is that a widening to a bare "you can only" prefix would start telling a rate-limited user their listing belongs to someone else.

One fixture retargeted

TestFallbackFailuresKeepTheirOwnError's 403 row used "you can only manage your own listings" incidentally — its own comment says wantMsg is "a fragment of the FALLBACK's own error, taken from listingError's arm for that status". That string now has its own arm, so the fixture is changed to a cohort refusal, which is the arm its wantMsg names. The row's subject — the fallback's error surviving instead of inheriting the submission's not-found — is unchanged, and the assertion string is untouched. The not-owned routing is pinned by the dedicated test instead.

Verification

  • red at bf4db4b, green at HEAD for the four regression rows.
  • Full suite: 21/21 packages ok, byte-matching the baseline's 21/21 (the intermediate state was 20/21 — that regression is what surfaced the fixture above).
  • gofmt -l clean, go vet ./... clean.
  • Exit code unchanged: 403 stays ErrUnauthorized → exit 3, asserted with errors.Is per AGENTS.md item 7, never on text.

…g the Apps-author remedy

The listing 403 arm's catch-all appends "managing store listings needs Apps-author
access (invite-only beta)" to every non-scope, non-takedown 403. One of those is
the server's NOT_OWNED refusal, where the caller's Apps-author access is fine and
is not what failed.

This is the third instance of the wrong-subject class this package already
catalogs: #374/#391 on the 400 arm, #509 on the takedown arm, ownership here.

Measured 2026-09-03 against a real listing: the account was a moderator AND the
publish request's submitted_by_user_id, and was still refused — ownership resolves
kind-aware to the OAuth client's owner (resolveListingAccess -> appBlock.app.userId),
not the listing's denormalized user_id and not submitted_by_user_id, and there is
deliberately no moderator bypass on that gate. The same account managed a listing
it owned in the same minutes. So no grant, re-login or cohort invite changes the
outcome, and the remedy the CLI named was unreachable.

isNotOwnedMsg classifies on the message because the wire carries no distinct code:
mapOffsiteError (app-listings.router.ts:359) collapses NOT_OWNED and FORBIDDEN onto
the same TRPCError code, so the discriminator is gone by the time the CLI sees it —
the same constraint isInsufficientScopeMsg and isModeratorTakedownMsg document.

Matched on the core shared by all three reachable spellings (offsite-listing.service.ts
:2467 manage, :1323 edit, :1811 revision), which share no noun and no verb. The fourth
(:582 withdraw) is unreachable here — its route maps NOT_OWNED to a 404 whose body is
identical to NOT_FOUND, deliberately.

Tests: four rows in TestListingForbiddenTakedownDoesNotBlameTheAccount, red at
bf4db4b and green here. A mutation sweep then found the predicate's two clauses were
each individually redundant — dropping either left every row green — so two further
invariant rows were added, built from real shipped near-miss strings rather than
invented ones ("You can only request 2 email changes per day", "You cannot purchase
access to your own chapter"). Each of the four mutants is now killed by exactly its
own row: no-case-fold, drop-"you can only ", drop-"your own", always-false.

Also retargets one fixture in TestFallbackFailuresKeepTheirOwnError: its 403 row used
"you can only manage your own listings" incidentally, and that string now has its own
arm. Its subject (the fallback's error survives instead of inheriting the submission's
not-found) is unchanged; the fixture is now a cohort refusal, which is the arm its
wantMsg names. Full suite 21/21 packages ok, matching the baseline exactly.
…ent, and add the README row

Adversarial audit of this PR returned "safe to merge" with three 🟡s. None changed
runtime behaviour; two were my own comments asserting things that are false, which
is the reasoning a future maintainer inherits.

1. README Troubleshooting index (AGENTS.md: a behaviour change is incomplete until
   README moves; #509, the class precedent this PR cites, shipped code + row in one
   commit). Adds the `belongs to another account (403)` row, and fixes the catch-all
   row, which said the takedown message was in "the row below" — there are now two
   siblings, not one.

2. The unreachability argument for the fourth spelling named the WRONG route. The
   conclusion holds; the mechanism did not. :582 is raised by withdrawExternalRequest,
   whose proc wraps every failure as BAD_REQUEST (400) and which the CLI never calls.
   The REST withdraw route handles a byte-identical TWIN thrown by a different class
   in publish-request.service.ts — not this string. Also records that safety here is
   route-population containment rather than a property of the string, names the two
   further unreachable offsite-moderation spellings, and flags that consolidating
   withdrawExternalRequest onto mapOffsiteError would turn :582 into a real 403.

3. The ownership claim gave only the ONSITE branch and named the offsite authority as
   the thing that is NOT the authority — backwards for exactly the case these throws
   serve. app-access.service.ts:283-292 returns blockOwnerUserId ?? listingUserId for
   onsite and listingUserId for offsite. Both branches now stated.

Also, from the two 🟢s: the remedy now names the collaborator case (the gate is
resolveListingRole === null, so an invited developer who has not accepted the seat
lands here and "sign in as the owner" is the wrong step for them), and the accepted
imprecision is recorded — the server reports NOT_OWNED for a listing deleted between
its two reads, so this sentence is confidently wrong about a listing that no longer
exists. Not a regression; the catch-all was equally false there.

Re-verified after the fixes, because an audit fix resets the gate: gofmt -s -l clean,
go vet clean, full suite 21/21 packages matching baseline, and the mutation sweep
re-run because the assertions read the message I changed — identity control silent,
and each of the four mutants still killed by exactly its own row.
@ZacxDev

ZacxDev commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Round 1 audit findings addressed in e319cff (verdict was "safe to merge"; all three 🟡s were documentation/evidence defects, no runtime behaviour change).

1. 🟡-1 README Troubleshooting index: added the `belongs to another account (403)` row, and rewrote the catch-all row, which claimed the takedown message was in "the row below" when there are now two siblings.
2. 🟡-2 The unreachability argument for the fourth spelling (:582) named the wrong route; replaced with the measured mechanism (withdrawExternalRequest wraps failures as BAD_REQUEST/400 and the CLI never calls that proc; the REST withdraw route handles a byte-identical twin from publish-request.service.ts, a different class).
3. 🟡-2b Recorded that safety here is route-population containment, not a property of the string; named the two further unreachable offsite-moderation spellings (:1668, :2226); flagged that consolidating withdrawExternalRequest onto mapOffsiteError would turn :582 into a real 403 this predicate matches.
4. 🟡-3 The ownership claim stated only the ONSITE branch and named the offsite authority as the thing that is NOT the authority; both branches of app-access.service.ts:283-292 are now stated, with the offsite case called out as the one these throws serve.
5. 🟢-4 The printed remedy now names the collaborator case (gate is resolveListingRole === null, so an invited developer who has not accepted the seat lands here).
6. 🟢-5 Recorded the accepted imprecision: the server reports NOT_OWNED for a listing deleted between its two reads, so the new sentence is confidently wrong about a listing that no longer exists (not a regression; the catch-all was equally false there).
7. Re-verified after the fixes: gofmt -s -l clean, go vet clean, full suite 21/21 packages matching baseline, and the mutation sweep re-run because the assertions read the message that changed — identity control silent, each of the four mutants still killed by exactly its own row.

…orrected claim survived in two more copies, and the remedy was unpinned

Delta re-audit of round 1's fixes. Verdict was again "safe to merge", and again the
findings were the fix round's own — which is why the round ran.

F1 (payload). Round 1 replaced a wrong MECHANISM with a wrong INVARIANT. It claimed
"only appListings.* procs reach listingError"; measured, listingError has three call
sites and the third is MintImageUpload, passing ImageUploadPath — the REST endpoint
/api/v1/image-upload, not a proc. False the day it was written. Worse, the trigger it
prescribed ("if the CLI routes a NON-LISTING proc through listingError") is a
different predicate from its own premise and would not fire for image-upload, since
image-upload IS a listing route. Now stated as the listingRoute block with a
mechanical trigger — re-run the enumeration when a route is ADDED TO THAT BLOCK — and
the enumeration gains apps-shared.router.ts:611 ("you can only edit your own
submissions"), the family closest to this CLI's own surface. Records that containment
at /api/v1/image-upload is luck: its only ownership refusal is "You do not own this
image", which does not match the core.

F2 (payload + scaffolding). Round 1 corrected ONE of THREE copies of the onsite-only
ownership claim. The two survivors are fixed here, including the test-file copy, which
annotated the row it was most misleading for — getMyListingForApp is dual-kind, and on
an offsite listing appBlockId is null, so that comment sent a debugger to a null
column.

F3 (scaffolding). The remedy this PR shipped was pinned by nothing: deleting BOTH the
collaborator and moderator clauses left the entire suite green. Adds wantRemedy to the
table, populated on the three not-owned rows. Watched red: the exact revert now fails
all three.

F4 (payload). The new README row linked to #listing-media-requirements — image formats
and byte caps. The repo's own row-link ledger records that judgement verbatim for a
different row, and the sibling takedown row added by this same PR points at
#exit-code-3, which is the section that actually discusses "not every 403 here is
about your credential". Retargeted.

F5 (payload). Two citations named ranges a few lines off the code they describe:
:653-659 -> :660-664 for the BAD_REQUEST wrap, and 2454-2461 -> 2453-2460 for the
deleted-between-reads race. Both already landed in the right function.

Re-verified: gofmt -s -l clean, go vet clean, 21/21 packages.
@ZacxDev

ZacxDev commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Round 2 (delta) findings addressed in 466e6d2. Verdict was "safe to merge; merge after F2 and F3"; all findings were round 1's own fixes.

1. F1 the containment premise was FALSE when written — listingError has three call sites and the third is MintImageUpload passing ImageUploadPath (/api/v1/image-upload, a REST endpoint, not an appListings.* proc). Restated as the listingRoute block, with a mechanical trigger (re-run when a route is ADDED TO THAT BLOCK) that matches its own premise.
2. F1b the prescribed trigger was a DIFFERENT predicate from the premise and would not have fired for image-upload, since image-upload is itself a listing route. Replaced.
3. F1c the enumeration omitted apps-shared.router.ts:611 ("you can only edit your own submissions"), the family closest to this CLI's surface. Added, plus a note that containment at /api/v1/image-upload is luck (its refusal, "You do not own this image", does not match the core).
4. F2 the onsite-only ownership claim was corrected in ONE of THREE copies; the two survivors (listing.go and listing_forbidden_test.go) are corrected here, the test copy being the one attached to the dual-kind row it most misled.
5. F3 the remedy shipped unpinned — deleting both the collaborator and moderator clauses left the whole suite green. Added `wantRemedy` to the table on the three not-owned rows; the exact revert now fails all three (watched red).
6. F4 the new README row pointed at #listing-media-requirements (image formats and byte caps); retargeted to #exit-code-3, matching the sibling takedown row this PR added.
7. F5 two citations were a few lines off: :653-659 -> :660-664 (the BAD_REQUEST wrap) and 2454-2461 -> 2453-2460 (the deleted-between-reads race).
8. Re-verified after the fixes: gofmt -s -l clean, go vet clean, 21/21 packages.

…the trigger missed its own hazard, and wantRemedy pinned 2 of 3 clauses

Third delta round. Same shape as the previous two: every finding was authored by the
round before it.

🟡-1 (payload). Round 2 replaced round 1's false claim with a different false claim.
It said /api/v1/image-upload's "only ownership refusal" is "You do not own this image"
at block-image-upload.service.ts:360. Measured: that route is
src/pages/api/v1/image-upload/index.ts, it performs NO ownership check at all and can
only answer 401 or 200 — containment there is currently TOTAL, not luck. The cited
string lives in gateBlockUploadImage, whose only caller is a tRPC proc
(block-image-upload.router.ts:48) this CLI never calls. Corrected, and the mistake is
recorded rather than quietly overwritten, because it is the third instance of the same
class in three rounds.

🟡-2 (payload). The trigger was keyed on a LOCATION ("added to that block at the top of
listing.go") rather than on the property, and it missed the hazard its own paragraph
named. Two arms now: (1) a listingRoute declared ANYWHERE IN THIS PACKAGE — the
package's own ledger test says so in its own words, "a route declared in a sibling file
of the same package is exactly as reachable"; (2) a refusal CHANGING behind a route
already in the set, which no addition-shaped trigger can see and which is the likelier
of the two. The premise sentence above it is widened to match.

🟡-3 (scaffolding). wantRemedy's docstring claimed "every clause of the arm's REMEDY"
while pinning two of three. The unpinned one was the PRIMARY remedy — "sign in as the
app's owner (`civitai whoami` shows who you are)" — which README.md:3567 also repeats
verbatim, so a reword would have staled both with no gate. Watched red: deleting only
that clause now fails all three not-owned rows; identity control still green.

Re-verified: gofmt -s -l clean (the appblocks.go reflow is gofmt's doc-comment list
indentation, no content change), go vet clean, 21/21 packages.
@ZacxDev

ZacxDev commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Round 3 (delta) findings addressed in 5e617ee. Verdict was "safe to merge after fixing 🟡-1"; again every finding was authored by the previous round.

1. 🟡-1 round 2's image-upload sentence was a NEW false claim: /api/v1/image-upload is src/pages/api/v1/image-upload/index.ts, performs NO ownership check and can only answer 401 or 200, so containment there is TOTAL rather than luck; the cited "You do not own this image" lives in gateBlockUploadImage, whose only caller is a tRPC proc (block-image-upload.router.ts:48) the CLI never calls. Corrected, and the error is recorded rather than overwritten.
2. 🟡-2a the premise named a LOCATION ("the block at the top of listing.go"); widened to every listingRoute DECLARED IN THIS PACKAGE, citing listing_op_test.go's own words that a route in a sibling file is exactly as reachable.
3. 🟡-2b the trigger fired only on route ADDITION and so could not see the hazard its own paragraph named. Second arm added: a refusal CHANGING behind a route already in the set.
4. 🟡-3 wantRemedy's docstring claimed "every clause" while pinning 2 of 3; the unpinned one was the PRIMARY remedy ("sign in as the app's owner", "civitai whoami"), which README.md:3567 repeats verbatim. Now pinned on all three not-owned rows — watched red on that clause alone, identity control still green.
5. Re-verified: gofmt -s -l clean (the appblocks.go reflow is gofmt's doc-comment list indentation, no content change), go vet clean, 21/21 packages.
6. Ledger note: this round's fix changed ZERO payload lines — comments in appblocks.go plus test literals. Round 2's fix changed 1 (a README cell). A second consecutive zero-payload round trips the attribution gate.

…laim was still one step short

Round 4 of the audit ladder. Its only finding, and the last one.

wantRemedy's docstring said "every clause of the arm's REMEDY". Round 3 wrote that
while pinning two of three steps, and round 4 measured the survivor: deleting
`, or ask the owner to make the change` left the FULL suite green (21/21), as did
dropping the diagnosis `your account's access is not the problem`.

The miscount is the interesting part: round 3 counted "being a moderator does not
help" as one of the remedy STEPS. It is a negation of a non-remedy, so treating it as
a step made the list look complete while a real step was unlisted. There are three
steps — sign in / accept an invite / ask the owner — plus the diagnosis and that
negation. All five are now pinned on all three not-owned rows, and the docstring is
rewritten to be exactly as wide as what it pins, with the two-round history recorded
so the next person does not re-narrow it.

That third step matters most for the largest sub-population of this 403: a moderator,
a publish-request submitter, or any third party who is neither the owner nor an
invitable collaborator. For them the other two steps are both impossible and "ask the
owner" is the only one that works — and README.md:3567 repeats it verbatim, so a
reword would have staled the published contract too, with no gate.

Watched red, each clause dying to its OWN named assertion: dropping
"ask the owner to make the change" fails all three rows naming that clause; dropping
"your account's access is not the problem" fails all three naming that one. Identity
control green, full suite 21/21, gofmt -s -l clean, go vet clean.

Ladder stops here on the auditor's own recommendation. Neither mechanical stop
condition fired — this round was not clean, and it was the FIRST zero-payload round,
not the second — but the fix is three test literals with zero payload consequence,
and payload has run 3 -> 1 -> 0 modified lines across the fix rounds while every
finding since round 1 has been about the comments and the test table rather than the
shipped change. A round 5 over two added string literals would be the ladder auditing
itself.
@ZacxDev

ZacxDev commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Round 4 (delta) finding addressed in b80ff72. The ladder stops here, on the auditor's own recommendation — reasoning recorded below rather than left implicit.

1. 🟡-A wantRemedy's docstring claimed "every clause of the arm's REMEDY" while pinning two of three steps. Round 4 measured the survivors: dropping `, or ask the owner to make the change` left the full suite green (21/21), as did dropping the diagnosis `your account's access is not the problem`. Both now pinned on all three not-owned rows, watched red, each dying to its OWN named clause.
2. 🟡-A root cause recorded: round 3 counted "being a moderator does not help" — a negation of a non-remedy — as one of the remedy STEPS, which made the list look complete while a real step went unlisted. The docstring is rewritten to be exactly as wide as what it pins, with the two-round history in it so the next reader does not re-narrow it.
3. Re-verified: identity control green, full suite 21/21, gofmt -s -l clean, go vet clean.

Why the ladder stops, stated rather than implied. Neither mechanical stop condition fired: this round was not clean, and it was the FIRST zero-payload fix round, not the second (round 2's fix changed 1 payload line, round 3's changed 0 — an earlier framing of mine said otherwise and the round-4 auditor correctly refused to carry it). It stops because the remaining fix was three test literals with zero payload consequence, payload has run 3 → 1 → 0 modified lines across the fix rounds, and every finding since round 1 has been about the doc comments and the test table rather than the shipped change. A round 5 over two added string literals would be the ladder auditing itself.

Recorded as open, not absent — two over-statements round 4 measured and deliberately did not file, so a later reader knows they were seen: appblocks.go's "can only answer 401 or 200" is not exhaustive (an unhandled throw yields a 500, which cannot reach the 403 arm), and arm 2's "with the wrong noun" is false for the 14 appListings.* routes, where "this listing" is the right noun. Also unverified: the depth-1 CI tier (scripts/ci-shallow.sh) was not run locally; this change touches no history-reading test.

@ZacxDev
ZacxDev merged commit d9b4e29 into main Sep 4, 2026
13 checks passed
@ZacxDev
ZacxDev deleted the zach/listing-not-owned-403 branch September 4, 2026 19:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant