Files
civitai__civitai/scripts
Zachary Lowden 4aab099c91 fix(app-blocks): a no-user flag eval returns the flag's BASE value, not a deny (#4807)
* fix(app-blocks): a no-user flag eval returns the flag's BASE value, not a deny

Several docblocks in app-blocks-flag.ts derived a security property from an
inference that does not hold:

    no user -> a global eval that can never match a segment -> fail-closed

The premise is true. A no-user call reaches Flipt as entityId 'global' with an
empty context, and every identity/tier/cohort segment we have is a
STRING_COMPARISON_TYPE constraint that reads the context, so none can match. The
conclusion does not follow from it: when no rollout matches, Flipt answers with
the flag's own base 'enabled' value. The denial came from the BASE being false,
not from the segment miss — so a base-true widening of app-blocks-author or
app-blocks-enabled turns every no-user branch from a deny into a pass.

MEASURED against the real @flipt-io/flipt-client-js wasm engine over a real
evaluation snapshot (new test app-blocks-flag.base-enabled-flip.test.ts):

  base enabled:true  + a non-matching SEGMENT_ROLLOUT, no entityId/context -> true
  base enabled:false + the same rollout,                no entityId/context -> false
  unknown flag key,                                     no entityId/context -> false

Both flags are base-false with segment rollouts today (civitai/flipt-state,
civitai-app/default/features.yaml), so nothing is exposed now. This is the latent
gate closed before any base-true flip, not a live defect.

Code:
- isAppBlocksAuthorEnabled: an undefined user now returns false structurally
  instead of falling through to a global eval. Enumerated: all 10 call sites pass
  { user }; none wants a global eval of this key.
- blocks.router assertAppBlocksEnabledForTokenUser / assertViewerIsAppDeveloper:
  refuse an unhydratable token subject before consulting the flag, with a distinct
  message. Same shape apps.router.ts already uses.
- apps-shared.router resolveSharedContext: separate a VANISHED subject (refuse)
  from an ANON token (keep the global eval — that widening is intended). The
  read ops list/get had no second belt behind the flag.
- isAppBlocksEnabled's no-user branch is deliberately KEPT: it has a real caller
  (pages/api/v1/developer/block-manifests.ts) that wants the base value.

Comments: every fail-closed / fail-safe claim in app-blocks-flag.ts swept and
given an accurate statement of what makes it closed; a new GLOBAL-EVAL SEMANTICS
block records the mechanism and the measurement once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(app-blocks): make the compiler the guard — require a subject on the author capability

Round-1 review: the structural guard I said needed type-level nullability analysis
is analysis this repo already runs on every PR (tekton / typecheck, App unit tests
+ typecheck). Measured, then implemented.

isAppBlocksAuthorEnabled's parameter is now REQUIRED and non-nullable:
  (opts?: { user?: SessionUser })  ->  (opts: { user: SessionUser })

Making it required errored at exactly 2 of the 10 call sites — both bare
middleware(...) whose ctx.user is not narrowed by the protectedProcedure they are
attached to (app-listings.router.ts, app-collaborators.router.ts). Each now refuses
explicitly instead of handing a possibly-undefined subject to an authz gate. The
other 8 already held a non-null subject. The runtime `if (!user) return false`
branch this makes dead is DELETED rather than kept as defence in depth, and the two
runtime tests that pinned it are deleted with it: they could only be re-added behind
an `as never` cast, i.e. testing a path the type system forbids while reading as
coverage. What replaces them is the typecheck gate, plus one test pinning the
residual the docblock now states — a cast-defeated call THROWS, never returns true.

isAppBlocksEnabled keeps its optional overload. That asymmetry is now justified on
SEMANTICS (a kill-switch answers "is the feature on at all", which a subject-less
machine path may legitimately ask and which the base value is; a capability answers
"may THIS subject", unanswerable without one) rather than on its sole no-arg caller
existing — block-manifests.ts is dormant, so that justification is the half that can
vanish.

Also:
- Delete fixtures/flipt-base-enabled-flip.snapshot.json (152 lines). Derived at
  runtime from the sibling fixture by re-key + enabled flip. A checked-in twin cannot
  track the original — re-capturing the source means re-anonymising it, so the copy
  silently keeps the old segment shape while claiming production fidelity, and it had
  already lagged a segment.
- Consolidate ~60 lines of duplicated harness into fixtures/flipt-fixture-server.ts,
  shared with the pre-existing real-flipt-client integration suite. Only the snapshot
  differs between them; the instrument is the same.
- Stop enumerating live flag state in the GLOBAL-EVAL SEMANTICS header — that is the
  same rot class the paragraph warns about. Keep the imperative, point at flipt-state.
- apps-shared: say READ_OPS rather than naming two of its four members.
- Replace a rejects.not.toMatchObject(...) positive control, which passes on any
  other rejection, with the specific NOT_FOUND / 'Block install not found' outcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): watchlist the two new refusals — the type guard cannot see a bundler

Round-1 review, 2 x yellow. Both taken.

WATCHLIST (the important one). My stated defence for deleting the helper's runtime
branch — "deleting it is a type error rather than a silent re-opening" — is a claim
about SOURCE. Release 5.1.18 is the case where the source is correct and the emitted
artefact is not: the bundler dropped two of three returns from a function in
app-blocks-flag.ts and served the whole App-store catalog to anonymous callers
(civitai#3983). That is why scripts/compiled-branch-watchlist.mjs exists, enforced at
Dockerfile:103, and two of the three refusals this PR adds are pure runtime branches
whose loss silently restores the exposure it closes. Both are now listed:

  shared-storage-subject-refusal  apps-shared.router.ts   — lost, every READ_OPS op
                                   serves shared rows to a vanished subject
  block-token-subject-refusal     blocks.router.ts        — lost, the kill-switch is
                                   evaluated with no subject on 16 runtime procs

assertViewerIsAppDeveloper's guard is deliberately NOT listed, and that is measured:
isAppBlocksAuthorEnabled takes a non-nullable subject and dereferences it at once, so
losing that guard throws rather than passes.

VERIFIED AGAINST A REAL BUILD, not just added. Full `next build` (green), then the
gate exactly as the Dockerfile runs it:
  positive   26,600 maps scanned; both new entries report OK with their control
             anchors mapped (an unmapped control is exit 2, not a pass)
  negative   required anchors repointed at unmapped comment lines in the same
             functions -> exit 1 naming both entry ids, controls still mapping
  deletion   the required line removed in a throwaway source copy, one entry at a
             time -> exit 2 naming that entry and its anchor

The two refusal messages were identical strings under different codes, so neither was
anchorable and "separable in a log" was not true. The author-gate message is now
'app-authoring subject could not be resolved'.

ROT (the second yellow). The isAppBlocksAgenticReviewEnabled docblock I wrote for this
rot class had the rot: it called the absent-flag half "load-bearing while this flag
does not exist in Flipt", but app-blocks-agentic-review has existed since 2026-07-21
(live: base false, `moderators` segment). No live exposure — all three call sites
check isModerator first — but it is a false claim in the one paragraph my own sweep
rewrote. Re-derived every app-blocks flag's live state rather than fixing only the
reported line, and found a second instance I had authored: the dev-tunnel docblock
offered "absent" as a live unconditional closure for a flag that also exists. Both
corrected; the only flag in this file genuinely absent from Flipt is
app-blocks-backpay-enabled.

Nits: assertAppBlocksEnabledForTokenUser has 16 call sites, not 17 (17 is the
parseSubjectUserId count; the substantive claim was right). deriveSnapshotFromFlagShape
now rejects an EMPTY rollouts array, which its error text already claimed to reject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(app-blocks): resolve the two docblocks that answer the reader twice, oppositely

Follow-on from the same re-check that found the agentic-review rot. Eleven docblocks
in this file carry an inherited "the flag does NOT exist in Flipt at merge time"
sentence; the new header frames all of them as as-merged history, which is the
deliberate treatment (substituting a fresher enumeration would be the same rot one
generation on).

But in exactly TWO docblocks that inherited sentence sits in the SAME block as a
live-state sentence I wrote, so the reader gets opposite answers eight lines apart:

  isAppListingsEnabled          "does NOT exist"  vs  "Both are base-`false` today"
  APP_BLOCKS_SHARED_STORAGE_FLAG "does NOT exist" vs  "Closed today because the base
                                                       is `false`"

Both inherited sentences moved to past tense and marked as as-merged notes. Nothing
else changed: the other eleven are untouched, because they carry no competing claim.

Criterion for the split, so it can be re-applied: fix where a paragraph contradicts
itself; leave where the header already governs.

Re-verified after the edit rather than carrying the previous measurement over — these
are comments, but they shift line numbers in a WATCHLISTED module, and source/artefact
correspondence is exactly what this PR stopped assuming. Fresh `next build` (green),
then the compiled-branch gate: positive OK on all three entries (26,600 maps; controls
mapped), negative control still exit 1 naming both new entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(app-blocks): say at each watchlisted refusal that it is watchlisted

The compiled-branch gate names the entry, module, line and reason when it fires — but
only at Docker build time, after someone has already deleted the branch and pushed. The
two refusals this PR puts on the watchlist carried no signal in the file itself, so a
reader deciding whether the guard is load-bearing had nothing to go on. Two comments,
one at each site, naming the entry id and what a bundler can do to a pure runtime
branch.

Deliberately scoped: moving or rewording the throw is fine (the gate resolves its
anchor from source at run time), deleting it is not.

Re-verified after the edit, for the same reason as the previous commit: comments shift
line numbers in watchlisted modules. Fresh `next build` (green), gate positive OK on
all three entries, negative control still exit 1 naming both new entries. That is the
third independent repetition of both controls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): re-anchor the watchlist on format-robust text, and stop telling people to reword it

Round 2. Five defects, all introduced by this PR's own later commits.

F1 (the important one). The comment I added in 1f3fd96 said "Moving or rewording the
throw below is fine". Moving is fine; rewording is NOT, and neither is reformatting —
the anchor is an exact substring, a miss is die(2), and exit 2 is deliberately not
downgraded by --warn-only, so the first red is the production image build. Worse, both
anchors baked in the FORMATTING (a trailing `});` and a trailing `,`), and the
apps-shared line was 95 chars at printWidth 100 — so adding a `cause` or a metrics
counter, which apps.router.ts already has, reflows it and reds the build.

Fixed at the anchor, not just in the prose:
  shared-storage  required -> `if (userId != null && !subjectUser) {`  (the CONDITION)
                  control  -> `isAppBlocksSharedStorageEnabled(`       (stops at the paren)
  block-token     required -> `'runtime block token subject could not be resolved'`
                  control  -> `await isAppBlocksEnabled({ user })`

Measured, resolution phase isolated (it runs before any map is read):
  OLD anchors + old source                      resolution OK    (control)
  OLD anchors + the apps-shared reflow          RESOLUTION FAILED
  NEW anchors + the same apps-shared reflow     resolution OK
  NEW anchors + apps-shared message reword      resolution OK  (anchor is the condition)
  NEW anchors + blocks.router property-add      resolution OK
  NEW anchors + blocks.router message reword    RESOLUTION FAILED  <- inherent, documented
  NEW anchors + unmodified copy                 resolution OK  (control)

The last row cannot be fixed by choosing better text: `if (!user) {` is not unique in
blocks.router.ts (the author gate uses the same condition), so that entry must anchor a
literal. It is sound only because the literal is now unique APP-WIDE — nothing can
intern it from elsewhere — and that constraint is written into the entry, the site
comment and a new anchor-authoring rule in the watchlist header.

F5, which is the same edit. The kill-switch refusal was byte-identical in code AND
message to apps.router.ts:148, so an operator could not tell which gate refused.
Renamed to 'runtime block token subject could not be resolved' — the message is new in
this PR, so no existing consumer sees the change — which clears the collision and
supplies the unique literal F1 needs.

F2. 220fdd8e0 created a new "answers the reader twice, oppositely" by its own criterion:
the dev-tunnel docblock lost the absent-flag case three lines above an untouched
sentence that relies on it. That sentence is a live-state claim, so the file header does
not govern it. Made state-independent, matching the treatment its sibling edit in the
same commit already used.

F3. The nit fix swapped one wrong count for another. There are 16 parseSubjectUserId
sites, not 17: the site at :5674 guards BOTH gates, so the two sets overlap and must not
be added. (19 raw occurrences = 16 calls + the import + two comments.)

F4. The fixture guard checked only that some rollout exists while its message and the
consuming test's title both claim a SEGMENT rollout — so a re-capture with a threshold
rollout would pass, and a 100% threshold would sail past the base-false backstop too.
Now checks SEGMENT_ROLLOUT_TYPE, and a new test drives all three refusal arms plus a
positive control, so the guard is reachable rather than merely present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): round 3 — stop writing the count down at all, and widen the fixture guard to its own docstring

Round 3 returned no 🔴, two 🟡 and two 🟢. This fixes both 🟡 and the 🟢 that is a
false claim in a doc governing how anchors are authored. Each was re-measured here
before being accepted; the auditor was right on all three.

🟡-1. The previous commit's "fix by FORM" was self-falsifying. It replaced a wrong
total with `grep -c 'await assertAppBlocksEnabledForTokenUser'` -> 16, and that line
CONTAINS the pattern, so it matches itself:

    31d768fba2 (base)   16
    5ad903c5c1          16
    origin/main         16
    7f029a6947 (merge)  17   <- the comment counted itself

Run without a path constraint it is 18, because apps.router.ts:139 declares a
DIFFERENT function of the same name. So a maintainer reading "16 such parse sites"
and running the very next line would get 17, conclude the doc was stale by one, and
reinstate the off-by-one that round 1 introduced and round 2 wrote the paragraph to
kill — the fourth consecutive wrong count in one parenthetical.

No number and no command now. The paragraph says why both are unsafe here and tells
the reader to enumerate. It also records that it has been wrong four rounds running,
each time by writing a figure down, so the next editor knows the cost before reaching
for a fifth.

Note for the record: the merge commit's own justification for that rewrite was wrong
in two specifics — it named `assertAppBlocksEnabledForTokenUser` where the count is
about `parseSubjectUserId`, and said the "import" did not exist when it does (line
15). Not force-pushed; the correction lives here beside it.

🟡-2. `flipt-fixture-server.ts` checked `.some(r => r.type === 'SEGMENT_ROLLOUT_TYPE')`
while its own comment names the hazard as a THRESHOLD rollout. `.some()` does not
cover a threshold rollout captured ALONGSIDE the segment one: the segment rollout is
still present, the guard passes, and `base-false-control` then evaluates true for
every subject — the exact attribution failure the fixture exists to prevent, restored
by a routine snapshot re-capture with a green suite. A guard whose description was
wider than its implementation.

Now requires a segment rollout AND no threshold rollout, with DISTINCT text per arm so
neither can die to the other's error. The test grew the arm that was uncovered, and
each arm now carries its own expected message rather than sharing one regex.

🔴 The first cut of this fix was itself wrong and an existing test caught it: keying
the message on `hasThreshold` alone made a THRESHOLD-ONLY template announce a rollout
"alongside its segment rollout" that was not there. `hasSegment &&` is load-bearing;
the comment says so.

Mutation-tested, each dying for its OWN reason on a DIFFERENT arm:
  M1  drop the threshold arm (pre-fix behaviour)  -> red: "expected [Function] to throw an error"
  M2  key the message on hasThreshold alone       -> red: message mismatch on the threshold-only arm
M1 is red at the pre-change code, so the new arm is regression coverage rather than an
invariant guard.

🟢-3. `compiled-branch-watchlist.mjs` asserted "comment churn above it cannot break
it", full stop. False: `resolveAnchor` scans every line including comments and >1
match is a hard error, so a comment merely CONTAINING the anchor text breaks
resolution exactly as a reword does — and rule 1's "prefer the shortest fragment"
makes a collision likelier, not less. This sentence had already misled a reader into
treating a docblock edit in a watchlisted module as free. Corrected, and it now says
the first red is this gate's own unit suite rather than the image build, which is the
only reason it is not deploy-blocking.

NOT fixed, deliberately: 🟢-4, that F5's new message is a strict SUPERSET of the
sibling it was renamed to be separable from, so a Loki grep for the shorter string
still returns both. The fix is another reword of a WATCHLISTED anchor plus its two
consumers, which is not a change to make at the tail of a ladder to close a 🟢.
Recorded as a ranked item instead.

Verified: 5 suites / 163 tests green (assert-compiled-branches, app-blocks-flag,
app-blocks-flag.base-enabled-flip, blocks.router.flag-gate-hydrate,
apps-shared.router); `pnpm typecheck` 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F9mHaY8iuJYDNfgYeSeWNj

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 23:12:42 -05:00
..
2026-06-04 15:58:33 -06:00