fix(app-blocks): make /api/v1/blocks/me and blocks.getMyViewer agree on authorization (#4950)

* fix(app-blocks): make /api/v1/blocks/me and blocks.getMyViewer agree on authorization

Two front doors to one capability disagreed about who may read viewer identity,
and the disagreement was masked by the Flipt audience rather than absent.

GET /api/v1/blocks/me carried a hardcoded isModerator -> 403 ("Phase 2: App
Blocks is moderator-only until GA"), no App-Blocks flag gate and no rate
limiter. Its tRPC twin blocks.getMyViewer had the flag gate and the rate
limiter, no moderator literal, and a docblock claiming it mirrored me.ts
EXACTLY. The live app-blocks-enabled audience is mostly moderators, for whom the
literal refused nobody the flag would have admitted -- but it also holds
hand-allowlisted non-moderators, and for every one of them the REST door 403'd
while the bridge returned 200. Widening the audience makes that the general
case.

The decision (operator, 2026-09-18) is that Flipt is the gate; a code-level
availability:['mod'] is documented as a Flipt-DOWN fallback only. So:

- Drop the moderator literal from me.ts, and the isModerator column from its
  select.
- Move assertAppBlocksEnabledForTokenUser out of blocks.router.ts into
  src/server/services/blocks/block-token-access.service.ts so both doors run ONE
  implementation. A Next API route cannot import the tRPC router, and a second
  copy of the predicate is how the two came to disagree.
- Give me.ts that gate plus checkBlockCatalogRateLimit, same bucket, same
  position (before the primary read) as getMyViewer.
- Replace the false "mirrors EXACTLY" docblock with explicit SHARED and
  NOT-SHARED lists, where SHARED is exactly what the parity test exercises and
  NOT-SHARED names the three places the doors genuinely differ -- including two
  pre-existing bridge-side divergences this change records rather than fixes.
- Repoint scripts/compiled-branch-watchlist.mjs's module: the watchlisted
  fail-closed branch block-token-subject-refusal moved with the function.

me.ts renders both kill-switch refusals with its own literal and does NOT echo
the gate's message: rest-error-envelope-ledger.test.ts blocks a REST route from
serialising a caught error's .message, and the unhydratable-subject message is a
compiled-branch anchor that must stay unique app-wide. Detection is duck-typed
on .code rather than instanceof, matching the sibling routes, because
instanceof fails across a duplicated @trpc/server instance in an API bundle.

Tests: blocks.router.me-parity.test.ts drives BOTH doors with one subject and
compares a normalised verdict. Measured in a CLEAN checkout of the base commit
with the HEAD test files dropped in -- at 0340f692bf, then re-measured at
d7038c5aa8 after the base moved, 7 failed / 3 passed both times: 7 of 10 FAIL, in
BOTH directions -- REST too strict for a non-moderator in the audience (allowed,
banned, muted), REST too permissive for a moderator outside it and for an
over-limit instance, one door not calling the gate at all, and a non-tRPC error
not being rethrown. The other 3 are labelled at their own assertions as invariant
or mutation guards and are NOT counted as regression coverage -- including one
that is green at base only because both doors answer 403 there by coincidence.

Mutation-tested rather than assumed. Adversarial review found three mutants that
SURVIVED an earlier revision of the suite and they are now killed by cases added
for them: `toHaveBeenCalledWith` is satisfied by EITHER door when both doors call
one mock, so keying a limiter on `jti` or calling the gate with a wrong subject
went unseen (both assertions now compare `mock.calls`); hardcoding the refusal
status instead of deriving it went unseen because every refusal the gate can
currently produce is UNAUTHORIZED (case H); and widening the duck-typed catch to
`if (true)` went unseen, which turned a plain Error into a 500 wearing a policy
refusal's body (case I).

Does NOT widen the Flipt segment; that is a separate change this one unblocks.

* test(app-blocks): pin the gate/limiter ORDER, label the third invariant guard, ledger the exported kill-switch

Audit round 1 on #4950 cleared the payload and found three gaps in the GUARDS
around it. All three are test/doc-side; no production behaviour changes here.

F1 (the one that matters) — A SURVIVING MUTANT, on exactly the defect this PR
exists to fix. Three docblocks claim the parity test pins the kill-switch and the
rate limiter in the same ORDER on both doors ("same position", "same placement",
asserted to be "exactly what the parity test EXERCISES"). It did not. Measured:
hoisting the limiter block above the kill-switch try/catch in me.ts left the
parity file 10/10 and 16 sibling suites 417/417 green. 13 of 14 audit mutants
died; this one lived.

The reason is that every existing case arms at most ONE of the two refusals, and
with one armed the order is unobservable -- whichever gate is armed answers,
wherever it sits. Position relative to the PRIMARY READ was already pinned (B and
D assert the db was never touched, which killed a gate-after-db mutant); position
relative to EACH OTHER was not.

Fixed by adding the case, not by narrowing the words: case J arms BOTH (flag
false AND limiter denied) and asserts the two doors return the same verdict --
401 "Apps are not enabled", the kill-switch, because it runs first -- plus the
positive half, that NEITHER door reaches the limiter at all. Re-running the exact
mutation now fails exactly case J, for its own reason (verdict inequality):
before 26/26 green, after 1 failed / 25 passed.

A docblock claiming coverage the test lacks is this PR's whole thesis. It would
have been one more instance of it, in the file arguing against it.

F2 — the parity header said its three green-at-base cases are "each labelled at
its own assertion". E was labelled in its title and H in its body; G was not, so
a reader landing on G from a failure got no in-place signal it is an invariant
guard rather than regression coverage. Now labelled in both.

F3 — `assertAppBlocksEnabledForTokenUser` became exported by this PR, and its
contract (the id MUST be the self-bound token subject) stopped being checkable by
reading one file. Nothing enumerated its callers: the bridge reachability guard
covers a different function. Adds a call-site ledger that pins the consumer SET
and per-consumer call counts, failing when the set grows, shrinks, or a new call
site appears inside an existing consumer.

Resolution is by IMPORT, never by name: apps.router.ts declares its own
same-named `(userId, op)` variant that is a deliberate documented divergence, and
a name-matching ledger would have counted it and invited someone to "reconcile"
two functions that are separate on purpose. That module is asserted as an
explicit negative control, alongside a positive control so a matching set cannot
be a wired-to-nothing zero.

The ledger states its own limit plainly: it does NOT verify self-binding. Proving
an argument descends from parseSubjectUserId textually is not something a regex
does honestly, and a structural check type-checks past a wrong argument anyway.
What it buys is that a new caller cannot land silently -- it lands in a diff next
to the contract. Watched to fail in all three directions before being trusted.
This commit is contained in:
Zachary Lowden
2026-09-18 17:23:11 -05:00
committed by GitHub
parent ad3134ccc6
commit 720ed3e087
9 changed files with 1264 additions and 149 deletions
+16 -3
View File
@@ -126,8 +126,21 @@ export const COMPILED_BRANCH_WATCHLIST = [
},
{
id: 'block-token-subject-refusal',
module: 'src/server/routers/blocks.router.ts',
why: "`assertAppBlocksEnabledForTokenUser` refuses an unhydratable token subject BEFORE consulting `app-blocks-enabled`. Lost, it falls through to `isAppBlocksEnabled`'s no-user branch — a deliberate global eval kept for the machine registrar — which returns the flag's BASE value. Under a base-`enabled: true` GA flip a token whose subject no longer resolves then passes the kill-switch on 16 block-token runtime procs. NB the sibling `assertViewerIsAppDeveloper` guard is deliberately NOT listed: `isAppBlocksAuthorEnabled` takes a non-nullable subject and dereferences it at once, so losing that one throws rather than passing.",
// MOVED 2026-09-18 out of `src/server/routers/blocks.router.ts`. The function is now
// shared with the REST route `src/pages/api/v1/blocks/me.ts`, which cannot import a
// tRPC router — so it lives in a service and BOTH doors call it. This field is a PIN:
// the move made both anchors below resolve to zero lines in the old module until it
// was updated here, which is the failure to expect if it is ever moved again.
//
// ⚠️ KNOWN LIMIT, WIDENED BY THAT MOVE. This gate unions mapped source lines across
// EVERY emitted chunk, so it answers "did this branch survive SOMEWHERE", not "did it
// survive in each consumer's chunk". The function now has two consumers — the tRPC
// router and the `/api/v1/blocks/me` API route — so a build that kept the branch in
// the router's chunk and dropped it from the API-route chunk would still pass. The
// union is pre-existing (one module inlines into ~200 chunks, as this file's header
// notes); recorded because the second consumer is new.
module: 'src/server/services/blocks/block-token-access.service.ts',
why: "`assertAppBlocksEnabledForTokenUser` refuses an unhydratable token subject BEFORE consulting `app-blocks-enabled`. Lost, it falls through to `isAppBlocksEnabled`'s no-user branch — a deliberate global eval kept for the machine registrar — which returns the flag's BASE value. Under a base-`enabled: true` GA flip a token whose subject no longer resolves then passes the kill-switch on every block-token runtime caller — the tRPC bridge procs AND the `/api/v1/blocks/me` REST route, which joined them when its hardcoded moderator literal was dropped. NB the sibling `assertViewerIsAppDeveloper` guard is deliberately NOT listed: `isAppBlocksAuthorEnabled` takes a non-nullable subject and dereferences it at once, so losing that one throws rather than passing.",
control: [
{
code: 'await isAppBlocksEnabled({ user })',
@@ -137,7 +150,7 @@ export const COMPILED_BRANCH_WATCHLIST = [
required: [
{
code: "'runtime block token subject could not be resolved'",
why: "the refusal's message literal. Lost, the gate evaluates the kill-switch with no subject and a base-true flag answers `true`. NB this anchors a literal INSIDE the branch rather than the branch's condition, because `if (!user) {` is not unique in this module (the author gate above uses the same condition). That is sound here only because the literal is unique ACROSS THE WHOLE APP: a minifier cannot intern it from another site, so a surviving mapping for this line means this site survived. Keep it unique — do not reuse this string elsewhere.",
why: "the refusal's message literal. Lost, the gate evaluates the kill-switch with no subject and a base-true flag answers `true`. NB this anchors a literal INSIDE the branch rather than the branch's condition. That was originally because `if (!user) {` was not unique in `blocks.router.ts`, where the function used to live alongside `assertViewerIsAppDeveloper`'s identical condition; since the 2026-09-18 move the condition IS unique in this module, so the literal is no longer the only option — it is kept because it remains the stronger anchor and re-pointing an anchor is itself a change worth not making idly. It is sound because the literal is unique ACROSS THE WHOLE APP: a minifier cannot intern it from another site, so a surviving mapping for this line means this site survived. Keep it unique — do not reuse this string elsewhere. 🔴 That uniqueness is why `/api/v1/blocks/me` renders this refusal with its OWN generic literal instead of echoing this message.",
},
],
},
+148 -12
View File
@@ -1,11 +1,15 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { withAxiom } from '@civitai/next-axiom';
import type { TRPCError } from '@trpc/server';
import { getHTTPStatusCodeFromError } from '@trpc/server/http';
import { dbWrite } from '~/server/db/client';
import {
parseSubjectUserId,
withBlockScope,
type BlockScopedNextApiRequest,
} from '~/server/middleware/block-scope.middleware';
import { assertAppBlocksEnabledForTokenUser } from '~/server/services/blocks/block-token-access.service';
import { checkBlockCatalogRateLimit } from '~/server/utils/block-catalog-rate-limit';
/**
* GET /api/v1/blocks/me
@@ -30,11 +34,74 @@ import {
* identity asks for both scopes in its manifest.
*
* CORS: handled in withBlockScope from BLOCK_ALLOWED_ORIGINS.
*
* ## THIS ROUTE AND `blocks.getMyViewer` ARE TWO FRONT DOORS TO ONE CAPABILITY
*
* `blocks.getMyViewer` (`blocks.router.ts`) is the host-mediated bridge twin of this
* route and backs the SDK `useViewer()` hook. The two are kept aligned deliberately, and
* `blocks.router.me-parity.test.ts` pins it as BEHAVIOUR rather than as a promise in
* prose — read that file's header for the full statement of what is and is not shared.
*
* 🔴 THIS ROUTE CARRIED A HARDCODED `if (!user.isModerator) → 403` UNTIL 2026-09-18 AND
* IT IS GONE ON PURPOSE. It was a pre-Flipt vestige, commented "App Blocks is
* moderator-only until GA"; `getMyViewer` never had it. The divergence stayed invisible
* because the live `app-blocks-enabled` audience is MOSTLY moderators, for whom the
* literal refused nobody the flag would have admitted — but it is not ONLY moderators:
* the audience also holds hand-allowlisted non-moderator userIds, and for every one of
* them this route already 403'd while the tRPC twin returned 200. ⚠️ THAT LAST FACT IS
* NOT VERIFIABLE FROM THIS REPO — it is a reading of the live Flipt segment taken
* 2026-09-18, and it is what makes the divergence present-tense rather than
* latent-until-GA. If the segment is ever mods-only, dropping the literal changes nothing
* that day and this is purely a GA-safety change. Re-read Flipt rather than trusting this
* sentence.
*
* The Flipt flag is the gate, so the literal was dropped and the flag gate `getMyViewer`
* already had was added here instead. DO NOT RE-ADD an `isModerator` literal to narrow
* the audience: narrow the Flipt segment.
*
* 🔴 THE PRICE OF THAT PARITY IS TWO NEW REFUSAL DEPENDENCIES:
*
* 1. THE AUTH HUB. The gate resolves the subject via `sessionClient.getSessionUserById`,
* and a `null` subject is a refusal here. That returns `null` on an unset
* `AUTH_INTERNAL_TOKEN` and on a hub failure — BOTH behind the shared
* `session:data2:<userId>` cache, which the ordinary cookie path also warms, so
* neither refuses a cache-warm viewer. Before this change the handler read `dbWrite`
* and nothing else, so this door had no hub leg at all.
* 2. THE `app-blocks-enabled` FLAG ITSELF — deleted, renamed, or its segment emptied, a
* moderator this route used to serve is refused, because `isAppBlocksEnabled` has NO
* moderator floor (its sibling `isAppBlocksAuthorEnabled` does; this one does not).
*
* Both are exactly what the tRPC twin already carries, and accepting them is the point of
* the change. What is worth knowing beyond the diff is that a refusal from (1) is
* BYTE-IDENTICAL on the wire to a policy refusal from (2) — both 401 `Apps are not
* enabled` — so neither this route's response nor its logs can tell an operator which
* fired. See the catch below for where the signal does live.
*
* ⚠️ DELIBERATELY NO RANKING, NO PERCENTAGES AND NO PROPAGATION TIMES HERE, because five
* consecutive review rounds found this paragraph asserting one. It has claimed, wrongly,
* that the route never consulted Flipt; that a hub outage refuses everyone; that the
* unset-token branch is unconditional; and that the flag has no cache in front of it and
* propagates instantly. Each was a plausible-sounding comparative that depended on cache
* and poll behaviour in three other packages, and each went stale or was wrong on first
* contact with the source. The couplings above are durable; their relative blast radii are
* not, and a route docblock is the wrong place to pin them. Read the caches (`readCachedUser`
* in `packages/civitai-auth`, the eval `TtlCache` in `packages/civitai-flipt`) at the time
* you need the answer.
*
* ⚠️ AN EARLIER REVISION OF THIS PARAGRAPH NAMED A THIRD COUPLING THAT IS NOT NEW, and
* got the mechanism wrong in the process: it said "before, this route never consulted
* Flipt at all", so a Flipt outage now 401s it. False. `withBlockScope` has always called
* `isAppBlocksRuntimeEnabled()` on every block-JWT request, and on an outage that
* resolves `false` and the wrapper treats a present token as ABSENT — the handler then
* 401s `Block token required` without this gate ever running. So the outage-401 predates
* this change and cannot be attributed to it; a responder following the old sentence
* would have looked past the hub leg above, which is the one that IS new.
*
* ⚠️ Do NOT reach for the `availability:['mod']` feature-flag fallback as a floor here:
* that mechanism serves `hasFeature` / `ctx.features` (it is what gives the MINT path its
* mod floor), not this gate. An earlier revision cited it as if it applied.
*/
const baseHandler = withAxiom(async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const baseHandler = withAxiom(async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') {
res.status(405).json({ error: 'Method not allowed' });
return;
@@ -59,6 +126,83 @@ const baseHandler = withAxiom(async function handler(
return;
}
// The App-Blocks kill-switch, evaluated against the TOKEN subject — the SAME shared
// gate `getMyViewer` calls, imported rather than re-spelled so the two front doors
// cannot drift. It is what replaced this route's hardcoded moderator literal, and it
// is also the defense-in-depth that literal was reaching for: block-token minting is
// gated on this same flag, but a token minted just before the subject leaves the
// audience stays valid for up to ~15min.
//
// The gate throws `TRPCError` because its other caller is a tRPC proc, so this is the
// one place the two doors' protocols have to be bridged. Two rules, both load-bearing:
//
// STATUS is DERIVED, never hand-written — `getHTTPStatusCodeFromError` is tRPC's OWN
// code→status map, so for THIS refusal the status is by construction the one the
// bridge answers. (The route's other statuses — 405/401/403/404/429 — are literals on
// both sides and agree by inspection, not by derivation; this sentence is about the
// gate only.) Detection is duck-typed on `.code` rather than `instanceof`, matching
// the four sibling routes in this directory: `instanceof` fails across a duplicated
// `@trpc/server` instance in an API-route bundle, which would rethrow into a 500.
//
// The MESSAGE is a LITERAL WE OWN, and the gate's own message is deliberately NOT
// echoed. Two independent reasons. (a) `rest-error-envelope-ledger.test.ts` blocks any
// REST route that serialises a caught error's `.message` into a body — the shape, not
// this instance, is the hazard, and restructuring to dodge that regex is called out
// there as the wrong direction. (b) The gate's two messages are deliberately distinct
// so an OPERATOR can separate them in a log; a third-party block iframe has no use for
// "the subject could not be resolved" and it is not ours to disclose. So both refusals
// render as one literal here while staying separable at the throw site. 🔴 The
// unhydratable-subject message is additionally a compiled-branch watchlist ANCHOR that
// must stay unique app-wide — re-spelling it here to win message parity would break
// that guard. The parity test therefore compares STATUS on this branch, not text, and
// says so.
// The SPELLING is the siblings' (`<binding> as TRPCError`, then
// `typeof trpcError?.code === 'string'`), deliberately, so a future `@trpc/server` shape
// change is greppable across all of them at once rather than across three phrasings.
// (Only the caught binding's name differs — `err` here, `error` in the siblings.)
// What differs here is the DISPOSITION: the siblings fall back to 500 and serialise
// the message, this one rethrows a non-TRPC error (matching `withBlockScope`'s own
// `catch`) and never serialises. 🔴 The catch is WIDER than `TRPCError` by
// construction — anything with a string `.code` thrown inside the gate (a hub/Redis
// `ECONNREFUSED`, a Prisma `P2xxx`) renders as this refusal. That is fail-closed
// (`getHTTPStatusCodeFromError` maps an unknown code to 500, never to a 2xx) but it
// is NOT observable here: a session-hub outage makes `getSessionUserById` return
// null, which is byte-identical on the wire to a policy refusal. The signal that
// separates them is out-of-band — the `identity-by-id` leg of `observeSessionLeg` in
// the session client — so look there, not in this route's logs. 🔴 LOOK AT THE WHOLE
// LEG, NOT AT ONE OUTCOME: a hub failure is only `error`/`timeout` when the hub is
// UNREACHABLE. When it ANSWERS non-ok — a 5xx, or a 401 from a wrong or rotated
// `AUTH_INTERNAL_TOKEN` — the leg records `miss`, which is also what a legitimately
// vanished user produces, and nothing on the series separates those two. An earlier
// revision named only `error`/`timeout` here, which would have cleared the hub during
// exactly the outage shapes an operator is most likely to hit. And one sub-case emits
// NOTHING: an UNSET `AUTH_INTERNAL_TOKEN` returns before the leg is instrumented at
// all, so it is invisible on the metric AND byte-identical on the wire — check the
// env, not the dashboard, for that one.
try {
await assertAppBlocksEnabledForTokenUser(userId);
} catch (err) {
const trpcError = err as TRPCError;
if (typeof trpcError?.code === 'string') {
res.status(getHTTPStatusCodeFromError(trpcError)).json({ error: 'Apps are not enabled' });
return;
}
throw err;
}
// Per-instance rate limit (the shared blocks catalog bucket, keyed on the stable
// `blockInstanceId`) — BEFORE the db read below, which hits the PRIMARY. Same bucket
// and same placement as `getMyViewer`. This route was one of three block REST routes
// with no limiter at all; the other two (`shared-storage/top.ts`,
// `collections/[id]/follow.ts`) still have none, so do not read this as "the surface is
// uniformly limited". Fail-open on a redis incident, by construction inside the helper.
const rateLimit = await checkBlockCatalogRateLimit(claims.blockInstanceId);
if (!rateLimit.allowed) {
res.setHeader('Retry-After', String(rateLimit.retryAfterSeconds));
res.status(429).json({ error: 'Rate limit exceeded, please retry shortly.' });
return;
}
// M1: dbWrite for ban/mute/deleted lookup. The token endpoint uses
// dbWrite for the same check; reading from the replica here lets a
// banned-during-replication-lag user surface to the block as active.
@@ -70,20 +214,12 @@ const baseHandler = withAxiom(async function handler(
bannedAt: true,
muted: true,
deletedAt: true,
isModerator: true,
},
});
if (!user || user.deletedAt) {
res.status(404).json({ error: 'User not found' });
return;
}
// Phase 2: App Blocks is moderator-only until GA. Block-token minting is
// mod-gated, but a token minted just before a demotion is valid for ~15min;
// re-assert the resolved viewer is a moderator as defense-in-depth.
if (!user.isModerator) {
res.status(403).json({ error: 'Apps are restricted to the Civitai team' });
return;
}
// M1+M6: a banned user with a still-valid session must NOT be surfaced
// to blocks as a real viewer. The token-issuance endpoint already gates
// on this, but a token minted just before a ban is still valid for up to
@@ -0,0 +1,589 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TRPCError } from '@trpc/server';
import { getHTTPStatusCodeFromError } from '@trpc/server/http';
import type { BlockTokenClaims } from '~/server/middleware/block-scope.middleware';
/**
* `GET /api/v1/blocks/me` and `blocks.getMyViewer` are TWO FRONT DOORS TO ONE
* CAPABILITY, and this file is the only thing that says so mechanically.
*
* ## The defect this exists to stop coming back
*
* `getMyViewer`'s docblock asserted it mirrored `me.ts` "EXACTLY". It did not. `me.ts`
* carried a hardcoded `if (!user.isModerator) → 403` ("App Blocks is moderator-only
* until GA") and NO App-Blocks flag gate; `getMyViewer` had the flag gate and no mod
* literal. Both sit behind the same `app-blocks-enabled` Flipt audience, which is
* base-false with a segment rollout whose members are MOSTLY moderators — so the literal
* refused almost nobody the flag would have admitted, and the divergence was invisible.
* It is not invisible for the hand-allowlisted NON-moderators already in that audience,
* and it would have surfaced for every user admitted by the GA widen: the identical
* capability succeeding over tRPC and 403-ing over REST.
*
* Resolved by dropping the literal — Flipt is the gate — and giving `me.ts` the flag
* gate and rate limiter `getMyViewer` already had.
*
* ## What is pinned, and why it is pinned as a RELATIONSHIP
*
* Each case drives BOTH doors with ONE subject and ONE set of mocked dependencies, then
* compares a normalised VERDICT — allow + body, or refuse + HTTP status + message — for
* equality. Two separately-green handler suites is exactly the shape that let these two
* disagree for months: every existing test was scoped to one surface, so none ever built
* the combined state. A per-door assertion cannot see a divergence; only the comparison
* can.
*
* Status codes are comparable across the protocols because `me.ts` derives its status
* for the kill-switch refusal from tRPC's OWN `getHTTPStatusCodeFromError`, and this file
* does the same to the thrown `TRPCError`. That is deliberate on both sides: a
* hand-written code→status switch in the route is where the two would drift apart again.
* (The route's other statuses — 405/401/403/404/429 — are literals on both sides, which
* this file compares directly; only the kill-switch status is derived.)
*
* MESSAGES are compared too, EXCEPT on the kill-switch branch, where `me.ts` renders its
* own generic literal instead of echoing the gate's. Case B says why at the assertion.
*
* ## Base-color matrix — MEASURED, not asserted from reading
*
* Method: a CLEAN checkout of the base commit (its own install, no shared node_modules),
* these test files dropped in, this suite run there. Measured for all 10 cases at base
* `0340f692bf`, then RE-measured at `d7038c5aa8` after the base moved under the branch:
* **7 failed | 3 passed** both times, same cases. (Quoting a matrix from a base the branch
* no longer sits on is a stale citation, so it is re-run rather than argued from.)
* Case J was added later, after an audit mutant. The suite was RE-RUN at `d7038c5aa8` with
* it included rather than its colour being reasoned about: **8 failed | 3 passed of 11**.
* J is red at base for the same reason B and D are — the base has neither gate.
*
* RED at base (7) — the regression coverage:
* A non-mod IN the audience REST 403 mod-literal / tRPC 200
* B moderator OUTSIDE the audience REST 200 no-flag-gate / tRPC 401
* C non-mod IN the audience, banned REST 403 'restricted…' / tRPC 403 'banned'
* D moderator, limiter refusing REST 200 no limiter / tRPC 429
* J flag AND limiter both refusing REST 200 neither gate / tRPC 401
* F non-mod muted viewer REST 403 mod-literal / tRPC 200 muted
* I non-tRPC error out of the gate base never calls the gate, so nothing throws
* and the call resolves instead of rejecting
* + the flag-sees-the-token-subject case: `[[77]]` vs `[[77],[77]]` — one door never
* calls the gate at all
*
* GREEN at base (3) — NOT regression coverage, and each labelled at its own assertion:
* E subject row absent (null) both 404 already
* G subject row soft-deleted both 404 already — the `deletedAt` branch
* precedes the old mod literal
* H non-401 refusal, same status both doors answer 403 at base BY COINCIDENCE
* (REST via the mod literal, the bridge via the
* injected FORBIDDEN), so it proves nothing
* about the old divergence. It is here purely as
* a MUTATION guard — the only case that kills a
* hardcoded refusal status.
*
* ⚠️ TWO LABELLING ERRORS ARE RECORDED HERE RATHER THAN QUIETLY CORRECTED, because both
* were written from what a case was ADDED FOR instead of from running it. F was called a
* green invariant guard and is red (its subject is a NON-moderator, so the old literal
* refused it before `status: 'muted'` was computed). H was expected to be red and is
* green. G, added in the same breath as F for the same reason, genuinely is green. Same
* origin, three different answers — which is why every line above is measured.
*
* The seven reds fail in BOTH directions (REST too strict in A/C/F, too permissive in
* B/D) across THREE distinct gates — the moderator literal (A, C, F), the missing flag
* gate (B, I, and the flag-subject case) and the missing limiter (D) — so no single
* mutation greens the set. ⚠️ Not "a different gate each": A, C and F all fail on the
* same literal, and an earlier revision of this line said otherwise.
*
* ## Deliberately NOT pinned here
*
* The belts that run before either handler body: token signature/expiry, per-instance
* revocation, backing-app approved-status, and the `user:read:self` scope check. They
* live in `withBlockScope` on the REST side and `authorizeBlockBridgeToken` on the tRPC
* side and are covered by `no-unguarded-block-rest-token.test.ts`,
* `no-unguarded-block-bridge-token.test.ts` and the middleware suites. Both are mocked
* to a pass here so a failure in this file is unambiguously about the gates that DID
* diverge. Also not pinned: `me.ts`'s 405 (non-GET) and 401 (absent claims), which have
* no tRPC counterpart to compare against.
*
* 🔴 THOSE EXCLUSIONS ARE NOT CLAIMS OF PARITY, AND TWO OF THEM ARE KNOWN TO DIVERGE.
* Stated here so nobody reads "not pinned" as "equal". Neither is an allow/deny
* inversion — both doors refuse — but the answers differ:
* - the scope refusal's TEXT (REST `missing required scope: user:read:self` from the
* wrapper; tRPC `block lacks user:read:self scope`), same 403 either way;
* - the ANON-subject refusal's STATUS: REST 403 / tRPC 401. On the REST side that
* refusal comes from `enforceContextBinding` inside the wrapper, not from the
* handler's own anon branch, which `requiredScope: 'user:read:self'` makes
* unreachable. ⚠️ UNMEASURED — read off the two code paths, not executed, and flagged
* as such because this header's own standard one section up is "MEASURED, not
* asserted from reading". Whether the case is even constructible depends on whether a
* token can carry `sub:'anon'` AND `user:read:self` at mint; nothing here tests it;
* - and `withBlockScope` runs `enforceContextBinding`, which the bridge has no
* equivalent of, so the REST door is STRICTER on a token carrying extra scopes.
*
* 🔴 A MALFORMED `sub` IS NOT ON THAT LIST, and an earlier revision of this header put it
* there — claiming the bridge answers 500 where REST answers 403. That was wrong.
* `verifyBlockToken` rejects any `sub` outside `anon` / `user:<1-12 digits>` before
* returning claims, and BOTH doors go through it, so both are 401 `invalid block token`
* and each handler's malformed-`sub` branch is unreachable defence-in-depth. Recorded
* because the error is the one this whole file exists to prevent, pointing the other way:
* a confident claim about the two doors that nobody had executed.
* Pinning those means driving the real wrapper rather than a passthrough, which is a
* different test from this one.
*/
const {
mockIsAppBlocksEnabled,
mockGetSessionUser,
mockCheckRateLimit,
mockAuthorizeBridgeToken,
mockGetUserById,
mockGetUserBuzzAccounts,
} = vi.hoisted(() => ({
mockIsAppBlocksEnabled: vi.fn(),
mockGetSessionUser: vi.fn(),
mockCheckRateLimit: vi.fn(),
mockAuthorizeBridgeToken: vi.fn(),
mockGetUserById: vi.fn(),
mockGetUserBuzzAccounts: vi.fn(),
}));
class ForbiddenError extends Error {
readonly status = 403 as const;
}
// The claims BOTH doors resolve to. One object, so the two cannot be handed different
// subjects, scopes or instance ids by accident — that sharing is the point of the file.
const claimsBox: { claims: BlockTokenClaims | undefined } = { claims: undefined };
// SHARED by both doors. withBlockScope is a passthrough that stamps req.blockClaims
// (mirroring what the real wrapper does once its own belts pass); parseSubjectUserId is
// a faithful re-implementation of the real one so the anon/malformed branches behave.
vi.mock('~/server/middleware/block-scope.middleware', () => ({
withBlockScope: (handler: any) => (req: any, res: any) => {
req.blockClaims = claimsBox.claims;
return handler(req, res);
},
verifyBlockToken: vi.fn(),
parseSubjectUserId: (sub: string): number | null => {
if (sub === 'anon') return null;
if (!/^user:\d+$/.test(sub)) throw new ForbiddenError('malformed sub claim');
return Number.parseInt(sub.slice('user:'.length), 10);
},
}));
// The tRPC door's pre-handler belts (validity → revocation → approved) resolve to the
// SAME claims object the REST door is stamped with.
vi.mock('~/server/services/blocks/block-bridge-auth.service', () => ({
authorizeBlockBridgeToken: (...a: unknown[]) => mockAuthorizeBridgeToken(...a),
}));
// The two dependencies the SHARED gate reads. Mocking them by module specifier is what
// makes this file agnostic to where `assertAppBlocksEnabledForTokenUser` physically
// lives — it exercises the real gate on both doors, not a stub of it.
vi.mock('~/server/auth/session-client', () => ({
sessionClient: { getSessionUserById: (...a: unknown[]) => mockGetSessionUser(...a) },
}));
vi.mock('~/server/services/app-blocks-flag', () => ({
isAppBlocksEnabled: (...a: unknown[]) => mockIsAppBlocksEnabled(...a),
}));
vi.mock('~/server/utils/block-catalog-rate-limit', () => ({
checkBlockCatalogRateLimit: (...a: unknown[]) => mockCheckRateLimit(...a),
}));
vi.mock('@civitai/next-axiom', () => ({ withAxiom: (handler: any) => handler }));
// Heavy services stubbed so importing the router doesn't drag in the generated Prisma
// client / selectors. Mirrors `blocks.router.flag-gate.test.ts`.
vi.mock('~/server/orchestrator/get-orchestrator-token', () => ({
getOrchestratorToken: vi.fn(),
}));
vi.mock('~/server/services/orchestrator/orchestration-new.service', () => ({
buildGenerationContext: vi.fn(),
createWorkflowStepsFromGraphInput: vi.fn(),
}));
vi.mock('~/server/services/orchestrator/workflows', () => ({
submitWorkflow: vi.fn(),
getWorkflow: vi.fn(),
cancelWorkflow: vi.fn(),
}));
vi.mock('~/server/services/orchestrator/promptAuditing', () => ({
auditPromptServer: vi.fn(),
}));
vi.mock('~/server/services/user.service', () => ({
getUserById: (...a: unknown[]) => mockGetUserById(...a),
}));
vi.mock('~/server/rewards/active/dailyBoost.reward', () => ({
dailyBoostReward: { apply: vi.fn(), getUserRewardDetails: vi.fn() },
}));
vi.mock('~/server/services/buzz.service', () => ({
getUserBuzzAccounts: (...a: unknown[]) => mockGetUserBuzzAccounts(...a),
}));
vi.mock('~/server/services/block-registry.service', () => ({
BlockRegistry: {
listForModel: vi.fn(),
listAvailable: vi.fn(),
installOnModel: vi.fn(),
updateSettings: vi.fn(),
toggleEnabled: vi.fn(),
uninstallFromModel: vi.fn(),
resolveBlockInstance: vi.fn(),
},
}));
vi.mock('~/server/middleware.trpc', async () => {
const { middleware } = await import('~/server/trpc');
return { rateLimit: () => middleware(async ({ next }) => next()) };
});
import restHandler from '~/pages/api/v1/blocks/me';
import { blocksRouter } from '../blocks.router';
import { TokenScope } from '~/shared/constants/token-scope.constants';
import { dbMock } from '~/__tests__/mocks/db.mock';
import { redisMock } from '~/__tests__/mocks/redis.mock';
import { loggingMock } from '~/__tests__/mocks/logging.mock';
// Referenced so the shared mock modules are loaded for the router's import graph.
void redisMock;
void loggingMock;
const mockFindUnique = dbMock.dbWrite.user.findUnique;
const SUBJECT_ID = 77;
const INSTANCE_ID = 'bki_parity';
function fakeClaims(over: Partial<BlockTokenClaims> = {}): BlockTokenClaims {
return {
iss: 'civitai',
aud: 'civitai-app-block',
sub: `user:${SUBJECT_ID}`,
iat: 0,
exp: 0,
jti: 'jti',
blockId: 'blk',
appId: 'app',
appBlockId: 'apb_parity',
blockInstanceId: INSTANCE_ID,
ctx: {},
scopes: ['user:read:self'],
buzzBudget: 250,
...over,
} as BlockTokenClaims;
}
/** The db row both doors read via `dbWrite.user.findUnique`. */
function userRow(over: Record<string, unknown> = {}) {
return {
id: SUBJECT_ID,
username: 'viewer',
bannedAt: null,
muted: false,
deletedAt: null,
// `me.ts` used to select this to run its hardcoded gate. Left on the fixture ON
// PURPOSE: if the literal is ever reintroduced it will find a value to refuse on,
// so case A fails on the divergence rather than on a missing column.
isModerator: false,
...over,
};
}
/** The SessionUser the shared gate hydrates before evaluating the flag. */
function sessionUser(over: Record<string, unknown> = {}) {
return { id: SUBJECT_ID, username: 'viewer', isModerator: false, tier: 'free', ...over };
}
type Verdict =
| { outcome: 'allow'; body: unknown }
| { outcome: 'refuse'; status: number; message: string };
async function callRest(): Promise<Verdict> {
let statusCode = 200;
let payload: any;
const res: any = {
status(code: number) {
statusCode = code;
return res;
},
json(b: unknown) {
payload = b;
return res;
},
setHeader() {
return undefined;
},
end() {
return res;
},
};
const req: any = {
method: 'GET',
headers: {},
socket: { remoteAddress: '203.0.113.7' },
log: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
};
await (restHandler as any)(req, res);
return statusCode === 200
? { outcome: 'allow', body: payload }
: { outcome: 'refuse', status: statusCode, message: String(payload?.error) };
}
function fakeCtx() {
return {
acceptableOrigin: true,
// NO session user. A `dev:live` block call carries no civitai cookie, and the whole
// point of the shared gate is that the flag is evaluated against the TOKEN subject
// rather than `ctx.user` — leaving this undefined keeps that honest.
user: undefined,
apiKeyId: null,
tokenScope: TokenScope.Full,
req: { headers: {} } as never,
res: { setHeader: () => undefined } as never,
cache: { edgeTTL: 0 },
features: {} as never,
track: undefined,
};
}
async function callTrpc(): Promise<Verdict> {
const caller = blocksRouter.createCaller(fakeCtx() as never);
try {
const body = await caller.getMyViewer({ blockToken: 'tok' });
return { outcome: 'allow', body };
} catch (err) {
if (err instanceof TRPCError) {
return {
outcome: 'refuse',
status: getHTTPStatusCodeFromError(err),
message: err.message,
};
}
throw err;
}
}
/**
* Drive both doors against the SAME state. The db mock is re-primed between the two
* calls (each door reads it exactly once) so neither can consume the other's
* `mockResolvedValueOnce`.
*/
async function bothDoors(row: unknown | null): Promise<{ rest: Verdict; trpc: Verdict }> {
mockFindUnique.mockResolvedValue(row);
const rest = await callRest();
mockFindUnique.mockResolvedValue(row);
const trpc = await callTrpc();
return { rest, trpc };
}
beforeEach(() => {
vi.clearAllMocks();
claimsBox.claims = fakeClaims();
mockAuthorizeBridgeToken.mockImplementation(async () => claimsBox.claims);
mockGetSessionUser.mockResolvedValue(sessionUser());
mockIsAppBlocksEnabled.mockResolvedValue(true);
mockCheckRateLimit.mockResolvedValue({ allowed: true, retryAfterSeconds: 0 });
mockFindUnique.mockResolvedValue(userRow());
});
describe('/api/v1/blocks/me and blocks.getMyViewer return the SAME authorization verdict', () => {
it('A: a NON-MODERATOR inside the app-blocks-enabled audience is ALLOWED on both doors', async () => {
// The population the GA widen creates, and the one the hardcoded literal broke: the
// flag admits this subject, and `isModerator` is false on BOTH the db row and the
// hydrated SessionUser.
mockGetSessionUser.mockResolvedValue(sessionUser({ isModerator: false }));
mockIsAppBlocksEnabled.mockResolvedValue(true);
const { rest, trpc } = await bothDoors(userRow({ isModerator: false }));
expect(rest).toEqual(trpc);
expect(rest).toEqual({
outcome: 'allow',
body: { id: SUBJECT_ID, username: 'viewer', status: 'active', buzzBudget: 250 },
});
});
it('B: a MODERATOR outside the audience is REFUSED on both doors — Flipt is the gate, not the role', async () => {
// The inverse direction, and the one that proves the fix is not "delete a check":
// the flag is what decides, so a moderator the flag does not admit is refused HERE
// too. Without the shared gate on `me.ts` this case is REST-allow / tRPC-refuse.
mockGetSessionUser.mockResolvedValue(sessionUser({ isModerator: true }));
mockIsAppBlocksEnabled.mockResolvedValue(false);
const { rest, trpc } = await bothDoors(userRow({ isModerator: true }));
// 🔴 STATUS, NOT TEXT, ON THIS ONE BRANCH — and the asymmetry is deliberate, not a
// gap. `me.ts` renders both kill-switch refusals with its own generic literal rather
// than echoing the gate's message: `rest-error-envelope-ledger.test.ts` blocks a REST
// route from serialising a caught error's `.message`, and the gate's other message is
// a compiled-branch watchlist anchor that must stay unique app-wide, so re-spelling it
// here to win text parity would break that guard. Every OTHER case in this file still
// compares the whole verdict including the message.
expect(rest.outcome).toBe('refuse');
expect(trpc.outcome).toBe('refuse');
expect((rest as { status: number }).status).toBe((trpc as { status: number }).status);
expect((rest as { status: number }).status).toBe(401);
expect((rest as { message: string }).message).toBe('Apps are not enabled');
// The operator-facing text stays off the wire on the REST door.
expect((trpc as { message: string }).message).toBe('Apps are not enabled');
// Refused BEFORE the primary read, on both doors.
expect(mockFindUnique).not.toHaveBeenCalled();
});
it('C: a banned NON-MODERATOR inside the audience is refused for the SAME reason on both doors', async () => {
// Asserts the MESSAGE, not just the status. Both doors answer 403 here, so a
// status-only assertion is satisfied by the old mod-literal refusing for an
// unrelated reason — which is exactly how this case would have gone green wrongly.
mockGetSessionUser.mockResolvedValue(sessionUser({ isModerator: false }));
const { rest, trpc } = await bothDoors(userRow({ isModerator: false, bannedAt: new Date() }));
expect(rest).toEqual(trpc);
expect(rest).toEqual({ outcome: 'refuse', status: 403, message: 'banned' });
});
it('D: an over-limit block instance is refused on both doors, on the same bucket', async () => {
// A MODERATOR subject deliberately: it isolates the limiter, since the mod literal
// could never have refused this caller. `me.ts` had no limiter at all.
mockGetSessionUser.mockResolvedValue(sessionUser({ isModerator: true }));
mockCheckRateLimit.mockResolvedValue({ allowed: false, retryAfterSeconds: 7 });
const { rest, trpc } = await bothDoors(userRow({ isModerator: true }));
expect(rest).toEqual(trpc);
expect(rest).toEqual({
outcome: 'refuse',
status: 429,
message: 'Rate limit exceeded, please retry shortly.',
});
// Both keyed on the stable per-instance id, never on `jti`, and both refuse BEFORE
// the primary read the limiter exists to bound.
//
// 🔴 `mock.calls`, NOT `toHaveBeenCalledWith` — and this is the whole reason a
// cross-door file needs a different matcher from a per-door one. `toHaveBeenCalledWith`
// passes when ANY call matched, and there are exactly two calls here, one per door: it
// is therefore satisfied by EITHER door alone, which is the one thing this file must
// never be. Measured: with `toHaveBeenCalledWith`, keying ONE door's limiter on
// `claims.jti` instead of `claims.blockInstanceId` left this file fully green. Asserting
// the call LIST pins both.
expect(mockCheckRateLimit.mock.calls).toEqual([[INSTANCE_ID], [INSTANCE_ID]]);
expect(mockFindUnique).not.toHaveBeenCalled();
});
it('J: flag REFUSING and limiter REFUSING at once — both doors answer the kill-switch', async () => {
// 🔴 THE ONLY CASE THAT OBSERVES THE ORDER OF THE TWO NEW GATES RELATIVE TO EACH OTHER,
// and it exists because an audit mutant proved the rest of this file cannot. Moving the
// limiter block ABOVE the kill-switch try/catch in `me.ts` left this file 10/10 green
// and 16 sibling suites 417/417 green: every other case sets at most ONE of the two
// refusals, and with only one armed the order is unobservable — whichever gate is armed
// answers, wherever it sits.
//
// Arm BOTH and the order becomes the whole verdict: the kill-switch runs first, so both
// doors must answer 401 `Apps are not enabled`. Under the swap the REST door answers
// 429 `Rate limit exceeded…` while the bridge still answers 401 — the two front doors
// disagreeing about which refusal a subject gets, which is this file's entire subject.
//
// ⚠️ Position relative to the PRIMARY READ was already pinned, by B and D's
// `expect(mockFindUnique).not.toHaveBeenCalled()` (that killed a gate-after-db mutant).
// Position relative to EACH OTHER is what was not, and it is what three docblocks claim
// ("same position", "same placement"). A docblock claiming coverage the test lacks is
// the defect this whole PR is about; it would have been one more instance of it.
mockGetSessionUser.mockResolvedValue(sessionUser({ isModerator: true }));
mockIsAppBlocksEnabled.mockResolvedValue(false);
mockCheckRateLimit.mockResolvedValue({ allowed: false, retryAfterSeconds: 7 });
const { rest, trpc } = await bothDoors(userRow({ isModerator: true }));
expect(rest).toEqual(trpc);
expect(rest).toEqual({ outcome: 'refuse', status: 401, message: 'Apps are not enabled' });
// The positive half of the same claim: because the kill-switch refuses first, NEITHER
// door ever reaches the limiter. A swapped door would have consulted it.
expect(mockCheckRateLimit).not.toHaveBeenCalled();
expect(mockFindUnique).not.toHaveBeenCalled();
});
it('E (invariant guard, green before the change too): a vanished subject row is 404 on both doors', async () => {
mockGetSessionUser.mockResolvedValue(sessionUser({ isModerator: true }));
const { rest, trpc } = await bothDoors(null);
expect(rest).toEqual(trpc);
expect(rest).toEqual({ outcome: 'refuse', status: 404, message: 'User not found' });
});
it('both doors evaluate the App-Blocks flag against the TOKEN subject, never a session user', async () => {
mockGetSessionUser.mockResolvedValue(sessionUser({ isModerator: false }));
await bothDoors(userRow());
// 🔴 THE CALL LIST, NOT `toHaveBeenCalledWith`. Same trap as case D and it bit harder
// here: with `toHaveBeenCalledWith(SUBJECT_ID)`, changing the REST door to evaluate the
// kill-switch against a HARDCODED WRONG SUBJECT left the entire repo suite green — the
// tRPC door's correct call supplied the match. That is precisely the identity defect
// this gate's docblock says the design exists to prevent, and the test named for it
// could not see it. Both doors must hydrate the self-bound token subject.
expect(mockGetSessionUser.mock.calls).toEqual([[SUBJECT_ID], [SUBJECT_ID]]);
// ...and the flag sees that hydrated user, not `ctx.user` (which is undefined here).
expect(mockIsAppBlocksEnabled.mock.calls).toEqual([
[{ user: sessionUser({ isModerator: false }) }],
[{ user: sessionUser({ isModerator: false }) }],
]);
});
it('H: a NON-401 kill-switch refusal answers the SAME status on both doors', async () => {
// ⚠️ GREEN AT BASE — this is NOT regression coverage, and the base matrix in the header
// says so. Both doors answer 403 at base by coincidence (REST via the mod literal, the
// bridge via the injected FORBIDDEN), so this case proves nothing about the divergence
// the file exists for. It is kept purely as a MUTATION guard:
//
// 🔴 THE ONLY CASE THAT EXERCISES THE STATUS *DERIVATION*, and it exists because the
// claim was unasserted: with every refusal the gate can currently produce being
// UNAUTHORIZED, replacing `getHTTPStatusCodeFromError(trpcError)` with a literal `401`
// in me.ts left the whole suite green. Derived and hardcoded were observationally
// identical at the single point the other cases sample. The day either gate refusal
// becomes FORBIDDEN, a hardcoded route would answer 401 against the bridge's 403 —
// exactly the drift this file exists to stop.
//
// ⚠️ HONEST LIMIT: this drives the mechanism by making a DEPENDENCY throw, not by
// reaching a state the gate produces today. It pins the derivation, not a live
// divergence.
mockIsAppBlocksEnabled.mockRejectedValue(new TRPCError({ code: 'FORBIDDEN', message: 'nope' }));
const { rest, trpc } = await bothDoors(userRow());
expect(rest.outcome).toBe('refuse');
expect(trpc.outcome).toBe('refuse');
expect((rest as { status: number }).status).toBe((trpc as { status: number }).status);
expect((rest as { status: number }).status).toBe(403);
});
it('I: a NON-tRPC error out of the gate is RETHROWN, never rendered as a refusal', async () => {
// Pins the catch's disposition, which the me.ts docblock asserts and nothing measured:
// widening the duck-type to `if (true)` left all cases green while turning a plain
// `Error` into `500 {"error":"Apps are not enabled"}` on a third-party iframe — an
// infrastructure failure wearing a policy refusal's clothes, which is the same
// one-observable-two-mechanisms trap the route's own comment argues against.
mockGetSessionUser.mockRejectedValue(new Error('session service down'));
await expect(callRest()).rejects.toThrow('session service down');
});
it('F: a MUTED viewer passes through as `status: "muted"` on both doors', async () => {
// Pins an item the `getMyViewer` docblock lists as shared. Added after a mutation
// sweep showed that collapsing one door's `status` to always-'active' left this file
// green — the SHARED list was wider than the file that is cited as proving it.
// It is ALSO red at base (the subject is a non-moderator, so the old literal refused
// it before `status` was computed); see the base-color matrix in the header.
const { rest, trpc } = await bothDoors(userRow({ muted: true }));
expect(rest).toEqual(trpc);
expect(rest).toEqual({
outcome: 'allow',
body: { id: SUBJECT_ID, username: 'viewer', status: 'muted', buzzBudget: 250 },
});
});
it('G (invariant guard, green before the change too): a SOFT-DELETED subject row is 404 on both doors', async () => {
// ⚠️ INVARIANT GUARD, NOT REGRESSION COVERAGE — green at base, like E and H. Labelled
// here in the title AND the body because the header claims all three greens are
// "labelled at their own assertion" and this one was the exception: its comment
// explained only how it differs from E, so a reader landing here from a failure got no
// in-place signal that it never diverged.
//
// Distinct from case E, which serves a NULL row. Same sweep: dropping one door's
// `deletedAt` check left case E green, because a null row never exercises it.
const { rest, trpc } = await bothDoors(userRow({ deletedAt: new Date() }));
expect(rest).toEqual(trpc);
expect(rest).toEqual({ outcome: 'refuse', status: 404, message: 'User not found' });
});
});
+73 -110
View File
@@ -16,6 +16,10 @@ import {
type BlockTokenClaims,
} from '~/server/middleware/block-scope.middleware';
import { authorizeBlockBridgeToken } from '~/server/services/blocks/block-bridge-auth.service';
// THE App-Blocks kill-switch for a block-token subject. Lives in a service, not here,
// because `src/pages/api/v1/blocks/me.ts` is its other caller and a Next API route must
// not import this router. See that module's docblock for why a second copy is banned.
import { assertAppBlocksEnabledForTokenUser } from '~/server/services/blocks/block-token-access.service';
import { assertSharedWriteTrust } from '~/server/services/blocks/block-write-trust.service';
import {
BLOCK_POST_DETAIL_MAX,
@@ -396,108 +400,6 @@ async function assertAppEditAccess(
await assertAccess({ appBlockId: block.id, ownerUserId: block.app?.userId, userId });
}
/**
* App-Blocks flag gate for the BLOCK-TOKEN-authed runtime procs
* (estimate/submit/poll/cancelWorkflow, updateUserSettings).
*
* WHY THIS EXISTS `enforceAppBlocksFlag` (the middleware) evaluates the flag
* against `ctx.user` (the request's SESSION user). These procs are
* `publicProcedure` authenticated by a BLOCK JWT, NOT a civitai.com session: a
* page-host call carries a session, but a `dev:live` (localhost) call is
* block-token-only and has NO session cookie `ctx.user` is `undefined`. The
* live `app-blocks-enabled` flag is base-`false` with a `moderators` segment, so
* a no-user (global) eval can never match the segment resolves `false`
* UNAUTHORIZED "App Blocks not enabled", even when the token's subject IS a
* moderator. The flag must therefore be evaluated against the TOKEN's subject
* user, not `ctx.user`.
*
* The flag stays a real kill-switch (a flip still shuts these procs down) we
* only fix the IDENTITY it's evaluated against. This does NOT widen access: with
* the flag base-`false` + `moderators`/cohort segments as it is today, it resolves
* `true` only for an in-segment subject and a non-mod outside the cohort resolves
* `false` blocked. An ANONYMOUS token (`sub:'anon'`) never reaches this function
* at all each of its 16 call sites runs `parseSubjectUserId(claims.sub)` and
* throws UNAUTHORIZED on `null` first, so the no-subject case handled below is a
* VANISHED user, not an anon caller. There are **16** such parse sites, not 17:
* the 17th gate call is `assertViewerIsAppDeveloper`, which shares the parse site
* of the enabled-gate call immediately above it rather than adding one, so the two
* sets OVERLAP and must not be added. (No raw-occurrence total is recorded here,
* and none should be: a grep for either identifier also matches this docblock's
* own prose and the import at the top of the file, and for the gate it matches a
* DIFFERENT function of the same name in `apps.router.ts`. Nor is a re-derivation
* command given the obvious one contains the identifier it searches for, so it
* matches the very line it is written on and returns one too many. Enumerate the
* call sites if you need the number; this paragraph has now been wrong four
* rounds running, each time by writing a figure down.)
* `authorizeBlockBridgeToken` (caller) already rejected invalid/expired
* tokens, revoked instances and non-approved apps before this runs the "revoked"
* half of that sentence used to be false, because the caller ran a bare
* `verifyBlockToken`, which never checked it. Every other belt (the per-scope
* consent checks, budget cap, daily Buzz cap, the per-(user, app) consent budget,
* reserveBlockBuzzSpend, getOrchestratorToken, forced-SFW) is unchanged this
* only swaps which identity the FLAG sees.
*
* Resolves the FULL server-side SessionUser via `sessionClient.getSessionUserById`
* (the hub-backed resolver; never a client-supplied value) so the segment match
* can't be spoofed AND every property `buildFliptContext` consumes is real.
*
* ## Why the full SessionUser, not a trimmed `{ id, isModerator }` cast
*
* `isAppBlocksEnabled({ user })` feeds `user` to `buildFliptContext`, which
* reads `id`, `isModerator`, AND `tier` (deriving `isMember` from `tier`). A
* trimmed `getUserById({ select: { id, isModerator } })` cast to SessionUser
* (the #2740 shape) leaves `tier` undefined the Flipt context carries the
* type-default `tier:'free'` / `isMember:'false'` instead of the user's real
* subscription tier. That is correct TODAY only because the live
* `app-blocks-enabled` flag segments solely on `isModerator`. The moment the
* flag is widened to segment on `tier`/region, a stale-`free` context would
* silently mis-gate a paying user. Resolving the real SessionUser here (whose
* `tier` is derived from the highest active subscription not a User column,
* so it CANNOT be fetched by widening the select) makes the gate stay correct
* across any future widening. Pre-GA security review hardening.
*/
async function assertAppBlocksEnabledForTokenUser(userId: number): Promise<void> {
// Full, authoritative SessionUser (cached; tier derived from active
// subscriptions) so buildFliptContext sees the user's REAL tier/isMember, not
// type-defaults. getSessionUserById returns the package SessionUser (loosely
// typed at this boundary — cast as bearer-token.ts does) or null for a vanished
// user. This is the LAST identity-shaped belt on most runtime procs now that the
// author gate is off them, so its fail-closed posture is not backed up by a
// second one — do not weaken it.
const user = (await sessionClient.getSessionUserById(userId)) as SessionUser | null;
// 🔴 REFUSE AN UNHYDRATABLE SUBJECT OUTRIGHT, before the flag is consulted.
// This used to pass `{ user: user ?? undefined }`, and the comment derived the
// denial from "global eval → flag false → blocked". The premise holds (a no-user
// eval carries entityId 'global' and an empty context, which no segment can
// match) but the conclusion came from `app-blocks-enabled` being base-`false`,
// not from the segment miss: a global eval returns the flag's own base value, so
// a base-`enabled: true` GA flip would have let a token whose subject no longer
// resolves through this gate. `isAppBlocksEnabled`'s no-user branch is KEPT for
// its real machine caller, so the refusal has to live here. Mechanism + the
// measurement against the real wasm engine: GLOBAL-EVAL SEMANTICS in
// `app-blocks-flag.ts`. Distinct message so the two refusals stay separable.
//
// 🔴 WATCHLISTED as `block-token-subject-refusal` in
// `scripts/compiled-branch-watchlist.mjs`. Unlike a type-level guard, this is a pure
// runtime branch, so a bundler that drops it re-opens the exposure with the source
// still correct — which is precisely what shipped in release 5.1.18 (civitai#3983).
// MOVING this branch is fine — the gate resolves its anchor from source at run time,
// so line numbers do not matter. DELETING it fails the production Docker build at
// `assert-compiled-branches.mjs`. And 🔴 REWORDING THE MESSAGE BELOW IS A WATCHLIST
// EDIT: that exact string IS this entry's anchor, so changing it makes the gate exit 2
// ("no line contains this anchor") — a failure that reads like gate breakage rather
// than like the copy change that caused it. Update the entry in the same commit.
if (!user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'runtime block token subject could not be resolved',
});
}
if (!(await isAppBlocksEnabled({ user }))) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Apps are not enabled' });
}
}
/**
* The shared payload half of `previewPostFromApp` / `createPostFromApp`.
*
@@ -6308,14 +6210,75 @@ export const blocksRouter = router({
* db read the ban/mute lookup hits the PRIMARY, so a hammering block must be
* bounded) the /blocks/me identity read.
*
* The identity read mirrors src/pages/api/v1/blocks/me.ts EXACTLY: `dbWrite`
* (NOT the replica) so a banned/muted-during-replication-lag viewer can't
* surface as active; 404 (NOT_FOUND) on a vanished/deleted user; 403
* (FORBIDDEN) on a banned viewer (a token minted just before a ban is valid
* for up to ~15min reject here as a second line of defense); a muted viewer
* passes through with `status: 'muted'` so the block can suppress write UI.
* `buzzBudget` is surfaced from the token claim (if present) so a block can
* clamp UI without a second call same shape /me returns.
* ## WHAT THIS SHARES WITH `src/pages/api/v1/blocks/me.ts`, AND WHAT IT DOES NOT
*
* 🔴 THIS BLOCK SAID THE TWO MIRRORED EACH OTHER "EXACTLY" AND THAT WAS FALSE FOR
* MONTHS. `me.ts` carried a hardcoded `if (!user.isModerator) → 403` and NO
* App-Blocks flag gate; this proc had the flag gate and no mod literal. So for a
* hand-allowlisted NON-moderator inside the live `app-blocks-enabled` audience the
* REST door 403'd while this one returned 200 invisible only because the audience
* was mostly moderators, and guaranteed to surface for every user admitted by the GA
* widen. Resolved 2026-09-18 by DROPPING the literal (Flipt is the gate) and giving
* `me.ts` this proc's flag gate and rate limiter. Both now run the SAME
* `assertAppBlocksEnabledForTokenUser`, imported from
* `~/server/services/blocks/block-token-access.service` rather than spelled twice,
* and the same `checkBlockCatalogRateLimit` bucket.
*
* SHARED and this list is exactly what `blocks.router.me-parity.test.ts` EXERCISES,
* not a superset of it. Each item below is a case in that file, driving both doors with
* one subject and comparing the whole verdict:
* - the App-Blocks kill-switch on the token subject (one shared implementation);
* - the catalog rate-limit bucket, same helper, same key, same position;
* - `dbWrite` (NOT the replica), so a banned/muted-during-replication-lag viewer
* cannot surface as active;
* - refusal on a vanished/deleted user, and on a banned viewer (a token minted just
* before a ban is valid for up to ~15min);
* - a muted viewer passing through with `status: 'muted'`;
* - the same `{ id, username, status, buzzBudget }` body.
*
* NOT SHARED. An earlier draft of this block listed the scope check and the non-anon
* subject check under SHARED they are NOT, and saying so re-made the same kind of
* unbacked promise the "EXACTLY" claim was. Both doors REFUSE in every case below; what
* differs is the status, the text, or where the belt lives. None is an allow/deny
* inversion, and none is pinned by the parity test, which says so in its own header.
* - THE PRE-BELTS ARE NOT THE SAME SET. Token validity, revocation and
* approved-status are common (this proc via `authorizeBlockBridgeToken`; the REST
* door inside `withBlockScope`, before its handler is entered). But `withBlockScope`
* ALSO runs `enforceContextBinding`, which this proc has no equivalent of: it is
* deny-by-default over every scope on the token, so a token carrying an unknown
* scope, or `models:read:self` bound to a modelId the request does not name, is
* refused on REST and admitted here. The REST door is the stricter one.
* - THE CONSENT SCOPE REFUSAL differs in text: REST answers 403
* `missing required scope: user:read:self` (from the wrapper), this proc 403
* `block lacks user:read:self scope`. Same code, same decision.
* - THE ANON-SUBJECT REFUSAL differs in status: REST 403, this proc 401. It does
* NOT differ in the way an earlier revision of this block claimed. That revision
* quoted `me.ts`'s handler literal (`Anonymous block tokens may not call
* /blocks/me`), which PRODUCTION NEVER EMITS: `me.ts` declares
* `requiredScope: 'user:read:self'`, so `withBlockScope` runs
* `enforceContextBinding` first, and that refuses an anon subject holding a `:self`
* scope with `user:read:self requires authenticated subject`. The handler's own
* branch is unreachable defence-in-depth. The 403-vs-401 difference is real; the
* text quoted for it was not.
* - 🔴 A MALFORMED `sub` DIVERGES ON NEITHER DOOR, and an earlier revision of this
* block asserted it did that this proc let `parseSubjectUserId`'s bare
* `ForbiddenError` escape as a 500, called it "a pre-existing gap", and invited a
* fix. That was WRONG, and wrong in the more dangerous direction: it reported a
* live 500-leak that cannot occur, in the document a maintainer treats as
* authoritative. `verifyBlockToken` rejects any `sub` that is not `anon` or
* `user:<1-12 digits>` BEFORE returning claims (`isValidSubject`,
* `block-scope.middleware.ts`), and BOTH doors go through it this proc via
* `authorizeBlockBridgeToken`, the REST door via the wrapper. So a malformed `sub`
* is a 401 `invalid block token` on both, and both handlers' malformed-`sub`
* branches are unreachable belt-and-braces. Do not "fix" either one.
* - HOW A REFUSAL IS SPELLED. This proc throws `TRPCError`; `me.ts` writes an HTTP
* status. For the kill-switch branch that status is DERIVED from this side's code
* via tRPC's own `getHTTPStatusCodeFromError`; every other status on that route is
* a literal that agrees with this one by inspection. `me.ts` deliberately does NOT
* echo the gate's message see its comment for the two reasons so the parity
* test compares STATUS, not text, on that one branch.
* - `me.ts` additionally answers 405 on a non-GET and 401 on absent claims. Neither
* is reachable through tRPC, which has no method and no claim-less call.
*/
getMyViewer: publicProcedure
// Block-JWT-authed (no session for dev:live) — flag evaluated against the
@@ -134,7 +134,7 @@ const REST_ROUTE_RATIONALE: Record<string, { exposure: RestExposure; why: string
},
'src/pages/api/v1/blocks/me.ts': {
exposure: 'READ_VIEWER_SCOPED',
why: 'Viewer identity (id, username, status) and the tokens buzzBudget. Gated on the viewer being a moderator today, which is a GA posture and not a takedown check.',
why: 'Viewer identity (id, username, status) and the tokens buzzBudget. Gated on the `app-blocks-enabled` Flipt audience, evaluated against the TOKEN subject via the same shared assertAppBlocksEnabledForTokenUser its tRPC twin blocks.getMyViewer calls — a GA posture, not a takedown check. It used to be gated on a hardcoded isModerator literal instead, which the twin never had; that divergence was resolved 2026-09-18 by dropping the literal.',
},
'src/pages/api/v1/blocks/models.ts': {
exposure: 'READ_PUBLIC',
+4 -2
View File
@@ -253,8 +253,10 @@ export const APP_BLOCKS_RUNTIME_FLAG = 'app-blocks-runtime-enabled';
* no-user caller of THIS helper starts passing. That is the intended reading
* for a kill-switch, and it is why the identity-shaped callers must not route a
* missing subject through it see GLOBAL-EVAL SEMANTICS at the top of this
* file, and `blocks.router.ts::assertAppBlocksEnabledForTokenUser`, which
* refuses an unhydratable subject before it gets here.
* file, and `block-token-access.service.ts::assertAppBlocksEnabledForTokenUser`,
* which refuses an unhydratable subject before it gets here. (That helper lived
* in `blocks.router.ts` until 2026-09-18; it moved to a service so the REST
* route `/api/v1/blocks/me` could share the one implementation.)
*
* The FLAG_OVERRIDE/local-overrides env exists for unit tests + local dev that
* need to flip the flag without standing up Flipt.
@@ -0,0 +1,167 @@
import { readdirSync, readFileSync, statSync } from 'fs';
import { join, relative, sep } from 'path';
import { describe, expect, it } from 'vitest';
/**
* POPULATION GUARD for `assertAppBlocksEnabledForTokenUser` THE App-Blocks kill-switch
* for a block-token subject (`~/server/services/blocks/block-token-access.service`).
*
* ## Why this exists
*
* That function was module-private in `blocks.router.ts` until it was shared with the REST
* route `/api/v1/blocks/me`, so that the two front doors to viewer identity could stop
* disagreeing about authorization. Exporting it is what fixed the divergence and is also
* what created this gap: its parameter is a bare `number`, and its contract that the id
* MUST be the self-bound token subject, `parseSubjectUserId(claims.sub)` on a verified
* token, never a value derived from client input became a convention the moment it
* stopped being checkable by reading one file.
*
* `no-unguarded-block-bridge-token.test.ts` does not cover it: that guard computes
* reachability textually INSIDE `blocks.router.ts` and is about a different function. So
* before this file, nothing enumerated who may call the kill-switch.
*
* ## 🔴 WHAT THIS PINS, AND WHAT IT DOES NOT read this before trusting it
*
* PINS: the SET of production modules that import and call the shared gate. It fails when
* that set GROWS (a third consumer appears) or SHRINKS (one stops calling it), and when
* the per-consumer call COUNT changes (a new call site inside an existing consumer).
*
* DOES NOT PIN: that each call site self-binds. Proving "this argument descends from
* `parseSubjectUserId(claims.sub)`" textually is not something a regex can do honestly,
* and a structural check type-checks past a wrong argument anyway. That is deliberate and
* it is the limit of this guard writing a check whose description is wider than its
* implementation is the exact defect the change this file ships alongside exists to fix.
*
* What it buys is the thing that actually decays: a NEW caller cannot land silently. It
* lands here, in a diff, next to the contract and whoever updates this table has to read
* that contract to do it. Self-binding stays a human check, made unavoidable rather than
* automatic.
*
* ## 🔴 THE NAME IS AMBIGUOUS AND MATCHING ON IT IS WRONG
*
* `apps.router.ts` declares its OWN module-private `assertAppBlocksEnabledForTokenUser`,
* taking `(userId, op)` and incrementing `appStorageOpsCounter` on both refusals. It is a
* deliberate, documented divergence NOT a consumer of the shared gate. A ledger that
* grepped the bare name would count it, then "reconcile" two functions that are separate on
* purpose. So consumption is resolved by IMPORT of the service module, never by name, and
* `apps.router.ts` is asserted as an explicit NEGATIVE control below: if it ever starts
* matching, the discriminator has broken and every number here is suspect.
*/
const ROOT = process.cwd();
const SRC = join(ROOT, 'src');
const GATE = 'assertAppBlocksEnabledForTokenUser';
const SERVICE_SPECIFIER = '~/server/services/blocks/block-token-access.service';
/** The module that DEFINES the gate. Not a consumer of it. */
const DEFINING_MODULE = 'src/server/services/blocks/block-token-access.service.ts';
/**
* Declares its own same-named private function. Asserted NOT to resolve as a consumer
* the positive control that import-resolution really is discriminating, rather than this
* file silently matching nothing at all.
*/
const NAME_COLLISION_MODULE = 'src/server/routers/apps.router.ts';
/**
* Every PRODUCTION consumer of the SHARED gate, with its call count and why it is allowed
* to call it. Update in the same commit as any change to the set.
*
* 🔴 Each entry's `selfBinds` is the reviewer's note, not a machine-checked fact see the
* limits above. It records WHERE the subject comes from so the next person does not have
* to re-derive it.
*/
const LEDGER: Record<string, { calls: number; selfBinds: string; why: string }> = {
'src/server/routers/blocks.router.ts': {
calls: 17,
selfBinds: 'parseSubjectUserId(claims.sub) on claims from authorizeBlockBridgeToken',
why: 'The tRPC bridge procs. Block-JWT-authed publicProcedures, so the flag cannot be evaluated against ctx.user and must be evaluated against the token subject.',
},
'src/pages/api/v1/blocks/me.ts': {
calls: 1,
selfBinds: 'parseSubjectUserId(claims.sub) on claims stamped by withBlockScope',
why: 'The REST twin of blocks.getMyViewer. Joined this set when its hardcoded isModerator literal was dropped in favour of the Flipt gate; sharing this exact function is what makes the two doors agree.',
},
};
function walk(dir: string): string[] {
const out: string[] = [];
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) {
if (name === '__tests__' || name === 'node_modules') continue;
out.push(...walk(full));
} else if (/\.tsx?$/.test(name)) {
out.push(full);
}
}
return out;
}
/** Repo-relative, POSIX-separated, so the ledger keys are platform-independent. */
function rel(file: string): string {
return relative(ROOT, file).split(sep).join('/');
}
/** True iff the file IMPORTS the gate from the service module (never a bare name match). */
function importsSharedGate(source: string): boolean {
const importRe = new RegExp(
String.raw`import\s*\{[^}]*\b${GATE}\b[^}]*\}\s*from\s*['"]${SERVICE_SPECIFIER.replace(
/[/~.]/g,
'\\$&'
)}['"]`,
's'
);
return importRe.test(source);
}
/** Call sites: the gate's name followed by an open paren, excluding the import line. */
function countCalls(source: string): number {
return source
.split('\n')
.filter((line) => !/^\s*import\b/.test(line))
.reduce((n, line) => n + (line.includes(`${GATE}(`) ? 1 : 0), 0);
}
describe('assertAppBlocksEnabledForTokenUser — production call-site ledger', () => {
const files = walk(SRC).filter((f) => rel(f) !== DEFINING_MODULE);
const consumers = files.filter((f) => importsSharedGate(readFileSync(f, 'utf8'))).map(rel);
it('the consumer set is EXACTLY the ledger — a new caller fails here', () => {
expect(
consumers.sort(),
'A production module now imports the App-Blocks kill-switch. Its argument MUST be the ' +
'self-bound token subject (parseSubjectUserId(claims.sub) on a verified token), never ' +
'a value from client input — read the contract on the function before adding it here.'
).toEqual(Object.keys(LEDGER).sort());
});
it('each consumer calls it the recorded number of times', () => {
const actual: Record<string, number> = {};
for (const c of consumers) actual[c] = countCalls(readFileSync(join(ROOT, c), 'utf8'));
const expected = Object.fromEntries(Object.entries(LEDGER).map(([k, v]) => [k, v.calls]));
expect(actual).toEqual(expected);
});
it('NEGATIVE CONTROL: the same-named private function in apps.router.ts is NOT counted', () => {
// It declares its own `(userId, op)` variant and imports nothing from the service. If
// this ever flips, the import discriminator has broken and both assertions above are
// measuring the wrong population rather than passing honestly.
const source = readFileSync(join(ROOT, NAME_COLLISION_MODULE), 'utf8');
expect(source).toContain(`async function ${GATE}(`);
expect(importsSharedGate(source)).toBe(false);
expect(consumers).not.toContain(NAME_COLLISION_MODULE);
});
it('POSITIVE CONTROL: the detector finds a real consumer, so a green set is not a wired-to-nothing zero', () => {
// A reassuring "the set matches" is indistinguishable from a walker that scanned no
// files or a regex that can never match. Assert the population is non-empty, that the
// walk reached a meaningful number of files, and that the detector fires on a known
// consumer's actual source.
expect(files.length).toBeGreaterThan(500);
expect(consumers.length).toBe(2);
expect(
importsSharedGate(readFileSync(join(ROOT, 'src/pages/api/v1/blocks/me.ts'), 'utf8'))
).toBe(true);
});
});
@@ -0,0 +1,157 @@
import { TRPCError } from '@trpc/server';
import { sessionClient } from '~/server/auth/session-client';
import { isAppBlocksEnabled } from '~/server/services/app-blocks-flag';
import type { SessionUser } from '~/types/session';
/**
* THE App-Blocks kill-switch for every BLOCK-TOKEN-authed runtime caller, on BOTH
* front doors the tRPC bridge procs in `blocks.router.ts`
* (estimate/submit/poll/cancelWorkflow, updateUserSettings, `getMyViewer`, ) and
* the REST handler `src/pages/api/v1/blocks/me.ts`.
*
* 🔴 WHY IT LIVES IN A SERVICE RATHER THAN IN `blocks.router.ts`, WHERE IT USED TO.
* `/api/v1/blocks/me` and `blocks.getMyViewer` are two front doors to ONE capability
* and their docblock claimed they mirrored each other; they did not `me.ts` carried a
* hardcoded `if (!user.isModerator)` 403 and NO flag gate, while `getMyViewer` had the
* flag gate and no mod literal. Masked while the Flipt audience was mods-plus-a-cohort;
* it would have diverged for every newly admitted user at the GA widen. The fix is not
* a second copy of this predicate in the REST route an open-coded predicate is how the
* two came to disagree so the function moved here and BOTH callers import it. A REST
* route cannot import `blocks.router.ts` (it would drag the whole tRPC router into a
* Next API bundle), which is why a plain service module and not an export from there.
*
* 🔴 THE SAME-NAMED TWIN IN `apps.router.ts` IS A DELIBERATE, DOCUMENTED DIVERGENCE and
* this paragraph is the CURRENT half of a pointer pair whose other half is stale.
* `apps.router.ts`'s `assertAppBlocksEnabledForTokenUser` additionally takes a `StorageOp`
* and increments `appStorageOpsCounter` on BOTH of its refusals, where this one takes only
* a userId and counts nothing; its unhydratable-subject message is also
* `'block token subject could not be resolved'` a strict PREFIX-free substring of this
* module's `'runtime block token subject'`. Keep all of that when reconciling the two.
* `apps.router.ts` names `blocks.router` four times (lines 40, 105, 112, 114, across two
* docblocks); THREE of them 105, 112, 114 are about THIS function and are now stale.
* Line 40 is about `assertViewerIsAppDeveloper`, which did NOT move and is still correct.
* The three were left stale ON PURPOSE: that file is already prettier-dirty on `main`, so
* editing one comment reformats ~780 unrelated lines of a security-sensitive storage
* router. 🔴 Note they all write `blocks.router's`, never `blocks.router.ts`, so grepping
* the filename there returns NOTHING and the staleness is invisible to the obvious search.
* The fact is recorded HERE instead, in a file the move already touched, so a reconciler
* who follows the stale pointer and finds nothing has somewhere current to land.
*
* WHY IT EXISTS AT ALL `enforceAppBlocksFlag` (the middleware) evaluates the flag
* against `ctx.user` (the request's SESSION user). The tRPC callers are
* `publicProcedure` authenticated by a BLOCK JWT, NOT a civitai.com session: a
* page-host call carries a session, but a `dev:live` (localhost) call is
* block-token-only and has NO session cookie `ctx.user` is `undefined`. The
* live `app-blocks-enabled` flag is base-`false` with a `moderators` segment, so
* a no-user (global) eval can never match the segment resolves `false`
* UNAUTHORIZED "App Blocks not enabled", even when the token's subject IS a
* moderator. The flag must therefore be evaluated against the TOKEN's subject
* user, not `ctx.user`.
*
* The flag stays a real kill-switch (a flip still shuts these procs down) we
* only fix the IDENTITY it's evaluated against. This does NOT widen access: with
* the flag base-`false` + `moderators`/cohort segments as it is today, it resolves
* `true` only for an in-segment subject and a non-mod outside the cohort resolves
* `false` blocked.
*
* 🔴 CALLER CONTRACT, AND IT IS NOW A CONVENTION RATHER THAN A FILE-LOCAL INVARIANT.
* `userId` MUST be the SELF-BOUND token subject `parseSubjectUserId(claims.sub)` on a
* verified token never a value derived from client input. While this was a
* module-private function in `blocks.router.ts` that was checkable by reading one file;
* exported, it takes a bare `number` and nothing mechanical pins it (the bridge's
* reachability guard, `no-unguarded-block-bridge-token.test.ts`, computes reachability
* INSIDE `blocks.router.ts` only and names "verification performed in a module this file
* does not read" as an explicitly open limit). Hand it an id from a request body and the
* kill-switch is evaluated against a user who is not the token subject which is not a
* refusal bypass, but it IS the wrong audience decision. Both current callers self-bind;
* a third must too.
*
* AN ANONYMOUS TOKEN (`sub:'anon'`) NEVER REACHES THIS FUNCTION. Every caller runs
* `parseSubjectUserId(claims.sub)` and refuses on `null` first, so the no-subject case
* handled below is a VANISHED user, not an anon caller. 🔴 NO CALL-SITE COUNT IS
* RECORDED HERE, AND NONE SHOULD BE: the figure was wrong four rounds running, each
* time by writing one down, and it went stale again the moment the REST route joined.
* A grep for either identifier also matches this docblock's own prose, the import at
* each call site, and a DIFFERENT function of the same name in `apps.router.ts` so
* the obvious re-derivation returns too many. Enumerate the call sites if you need the
* number; what is invariant, and what this paragraph is actually for, is that no caller
* reaches this gate with an unparsed or anonymous subject.
*
* WHAT EACH CALLER HAS ALREADY SPENT BEFORE THIS RUNS. Three belts are common to both
* doors token validity, per-instance revocation, and the backing app still `approved`
* (`authorizeBlockBridgeToken` on the tRPC side, `withBlockScope` on the REST side).
* THE SETS ARE NOT EQUAL, AND AN EARLIER DRAFT OF THIS SENTENCE SAID THEY WERE. The
* REST wrapper additionally runs `enforceContextBinding` (`block-scope.middleware.ts`,
* its only call site in `src/`), which is deny-by-default over EVERY scope on the token
* an unknown scope string 403s, and `models:read:self` must match the request's `?id`.
* The bridge has no equivalent. So the REST door is the STRICTER of the two on tokens
* carrying extra scopes; do not read "shares this gate" as "same pre-belt set".
* Every other belt (the per-scope consent checks, budget cap, daily Buzz cap, the
* per-(user, app) consent budget, reserveBlockBuzzSpend, getOrchestratorToken,
* forced-SFW) is unchanged this gate only decides which identity the FLAG sees.
*
* Resolves the FULL server-side SessionUser via `sessionClient.getSessionUserById`
* (the hub-backed resolver; never a client-supplied value) so the segment match
* can't be spoofed AND every property `buildFliptContext` consumes is real.
*
* ## Why the full SessionUser, not a trimmed `{ id, isModerator }` cast
*
* `isAppBlocksEnabled({ user })` feeds `user` to `buildFliptContext`, which
* reads `id`, `isModerator`, AND `tier` (deriving `isMember` from `tier`). A
* trimmed `getUserById({ select: { id, isModerator } })` cast to SessionUser
* (the #2740 shape) leaves `tier` undefined the Flipt context carries the
* type-default `tier:'free'` / `isMember:'false'` instead of the user's real
* subscription tier. That is correct TODAY only because the live
* `app-blocks-enabled` flag segments solely on `isModerator`. The moment the
* flag is widened to segment on `tier`/region, a stale-`free` context would
* silently mis-gate a paying user. Resolving the real SessionUser here (whose
* `tier` is derived from the highest active subscription not a User column,
* so it CANNOT be fetched by widening the select) makes the gate stay correct
* across any future widening. Pre-GA security review hardening.
*/
export async function assertAppBlocksEnabledForTokenUser(userId: number): Promise<void> {
// Full, authoritative SessionUser (cached; tier derived from active
// subscriptions) so buildFliptContext sees the user's REAL tier/isMember, not
// type-defaults. getSessionUserById returns the package SessionUser (loosely
// typed at this boundary — cast as bearer-token.ts does) or null for a vanished
// user. This is the LAST identity-shaped belt on most runtime procs now that the
// author gate is off them, so its fail-closed posture is not backed up by a
// second one — do not weaken it.
const user = (await sessionClient.getSessionUserById(userId)) as SessionUser | null;
// 🔴 REFUSE AN UNHYDRATABLE SUBJECT OUTRIGHT, before the flag is consulted.
// This used to pass `{ user: user ?? undefined }`, and the comment derived the
// denial from "global eval → flag false → blocked". The premise holds (a no-user
// eval carries entityId 'global' and an empty context, which no segment can
// match) but the conclusion came from `app-blocks-enabled` being base-`false`,
// not from the segment miss: a global eval returns the flag's own base value, so
// a base-`enabled: true` GA flip would have let a token whose subject no longer
// resolves through this gate. `isAppBlocksEnabled`'s no-user branch is KEPT for
// its real machine caller, so the refusal has to live here. Mechanism + the
// measurement against the real wasm engine: GLOBAL-EVAL SEMANTICS in
// `app-blocks-flag.ts`. Distinct message so the two refusals stay separable.
//
// 🔴 WATCHLISTED as `block-token-subject-refusal` in
// `scripts/compiled-branch-watchlist.mjs`. Unlike a type-level guard, this is a pure
// runtime branch, so a bundler that drops it re-opens the exposure with the source
// still correct — which is precisely what shipped in release 5.1.18 (civitai#3983).
// MOVING this branch WITHIN its module is fine — the gate resolves its anchor from
// source at run time, so line numbers do not matter. 🔴 MOVING IT TO ANOTHER MODULE IS
// NOT: the watchlist entry pins `module:`, and this comment asserted the unqualified
// "moving is fine" right up until the function was moved HERE out of
// `blocks.router.ts`, at which point both anchors resolved to zero lines in the pinned
// module. Update `module:` in the same commit as any such move.
// DELETING it fails the production Docker build at
// `assert-compiled-branches.mjs`. And 🔴 REWORDING THE MESSAGE BELOW IS A WATCHLIST
// EDIT: that exact string IS this entry's anchor, so changing it makes the gate exit 2
// ("no line contains this anchor") — a failure that reads like gate breakage rather
// than like the copy change that caused it. Update the entry in the same commit.
if (!user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'runtime block token subject could not be resolved',
});
}
if (!(await isAppBlocksEnabled({ user }))) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Apps are not enabled' });
}
}
+109 -21
View File
@@ -11,15 +11,28 @@ import type { BlockTokenClaims } from '~/server/middleware/block-scope.middlewar
* - claims: missing blockClaims (defense-in-depth) 401.
* - subject: malformed sub (parseSubjectUserId throws) 403;
* anon sub (parseSubjectUserId null) 403.
* - gate: the App-Blocks kill-switch on the TOKEN subject an unhydratable
* subject 401, a subject the `app-blocks-enabled` flag does not
* admit 401. Re-asserted on this route because block-token minting
* is gated on the same flag but a token minted just before the
* subject leaves the audience stays valid for up to ~15min.
* - limit: an over-limit block instance 429, BEFORE the primary read.
* - lookup: user missing OR soft-deleted 404 (via dbWrite, NOT the replica,
* so a ban during replication lag can't surface as active).
* - gate: resolved non-moderator 403 (mod-gated until GA re-asserted
* here because a token minted just before demotion stays valid).
* - ban: bannedAt set 403 (second line of defense vs. the mint gate).
* - happy: active mod 200 { id, username, status:'active', buzzBudget }.
* - muted: a muted (non-banned) mod passes through with status:'muted'.
* - happy: admitted viewer 200 { id, username, status:'active', buzzBudget }.
* - muted: a muted (non-banned) viewer passes through with status:'muted'.
* - budget: buzzBudget mirrors the JWT claim; absent claim null.
*
* 🔴 THERE USED TO BE A `resolved non-moderator → 403` CASE HERE AND IT IS GONE ON
* PURPOSE. This route carried a hardcoded `if (!user.isModerator)` while its tRPC twin
* `blocks.getMyViewer` whose docblock claimed to mirror it EXACTLY never did, so
* the same subject got different answers on the two doors. The Flipt flag is the gate;
* the literal was dropped and the twin's flag gate + rate limiter added here instead.
* The two doors are now compared against each other, as behaviour, in
* `src/server/routers/__tests__/blocks.router.me-parity.test.ts` this file is the
* per-door suite and is BY CONSTRUCTION unable to see a divergence between them.
*
* withBlockScope is mocked as a passthrough that stamps req.blockClaims (the
* real token-verify path is covered by block-scope.middleware tests).
* parseSubjectUserId is a FAITHFUL re-implementation of the real one so the
@@ -65,6 +78,12 @@ function createMocks({
return { req, res };
}
const { mockGetSessionUser, mockIsAppBlocksEnabled, mockCheckRateLimit } = vi.hoisted(() => ({
mockGetSessionUser: vi.fn(),
mockIsAppBlocksEnabled: vi.fn(),
mockCheckRateLimit: vi.fn(),
}));
// The inner handler reads `req.blockClaims`; withBlockScope injects it. Point
// claimsBox.claims at the token under test per-case.
const claimsBox: { claims: BlockTokenClaims | undefined } = { claims: undefined };
@@ -91,6 +110,19 @@ vi.mock('~/server/middleware/block-scope.middleware', () => ({
vi.mock('@civitai/next-axiom', () => ({ withAxiom: (handler: any) => handler }));
// The two dependencies of the SHARED App-Blocks kill-switch
// (`assertAppBlocksEnabledForTokenUser`, `~/server/services/blocks/block-token-access.service`),
// mocked by module specifier so the REAL gate runs against them.
vi.mock('~/server/auth/session-client', () => ({
sessionClient: { getSessionUserById: (...a: unknown[]) => mockGetSessionUser(...a) },
}));
vi.mock('~/server/services/app-blocks-flag', () => ({
isAppBlocksEnabled: (...a: unknown[]) => mockIsAppBlocksEnabled(...a),
}));
vi.mock('~/server/utils/block-catalog-rate-limit', () => ({
checkBlockCatalogRateLimit: (...a: unknown[]) => mockCheckRateLimit(...a),
}));
import handler from '~/pages/api/v1/blocks/me';
import { dbMock } from '~/__tests__/mocks/db.mock';
const mockFindUnique = dbMock.dbWrite.user.findUnique;
@@ -114,19 +146,24 @@ function fakeClaims(over: Partial<BlockTokenClaims> = {}): BlockTokenClaims {
} as BlockTokenClaims;
}
const activeMod = {
const activeViewer = {
id: 42,
username: 'mod',
username: 'viewer',
bannedAt: null,
muted: false,
deletedAt: null,
isModerator: true,
};
/** What the shared kill-switch hydrates from the self-bound token subject. */
const subjectSessionUser = { id: 42, username: 'viewer', isModerator: false, tier: 'free' };
beforeEach(() => {
vi.clearAllMocks();
claimsBox.claims = fakeClaims();
mockFindUnique.mockResolvedValue(activeMod);
mockFindUnique.mockResolvedValue(activeViewer);
mockGetSessionUser.mockResolvedValue(subjectSessionUser);
mockIsAppBlocksEnabled.mockResolvedValue(true);
mockCheckRateLimit.mockResolvedValue({ allowed: true, retryAfterSeconds: 0 });
});
describe('GET /api/v1/blocks/me', () => {
@@ -170,13 +207,11 @@ describe('GET /api/v1/blocks/me', () => {
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(404);
// The lookup is keyed on the SELF-BOUND token subject (42), never client input.
expect(mockFindUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 42 } })
);
expect(mockFindUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 42 } }));
});
it('404 when the user is soft-deleted (deletedAt set)', async () => {
mockFindUnique.mockResolvedValueOnce({ ...activeMod, deletedAt: new Date() });
mockFindUnique.mockResolvedValueOnce({ ...activeViewer, deletedAt: new Date() });
const { req, res } = createMocks();
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(404);
@@ -191,37 +226,90 @@ describe('GET /api/v1/blocks/me', () => {
expect(mockFindUnique).toHaveBeenCalledTimes(1);
});
it('403 when the resolved viewer is NOT a moderator (mod-gated until GA)', async () => {
mockFindUnique.mockResolvedValueOnce({ ...activeMod, isModerator: false });
it('401 when the app-blocks-enabled flag does not admit the token subject', async () => {
// The gate that REPLACED the hardcoded moderator literal. `isModerator` is
// deliberately NOT what decides here — see the file header.
mockIsAppBlocksEnabled.mockResolvedValueOnce(false);
const { req, res } = createMocks();
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(403);
expect((res._getJSONData() as { error: string }).error).toMatch(/Civitai team/);
expect(res._getStatusCode()).toBe(401);
expect((res._getJSONData() as { error: string }).error).toBe('Apps are not enabled');
// Refused BEFORE the primary read.
expect(mockFindUnique).not.toHaveBeenCalled();
});
it('401 when the token subject no longer hydrates (fail-closed, before the flag)', async () => {
mockGetSessionUser.mockResolvedValueOnce(null);
const { req, res } = createMocks();
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(401);
// The route renders BOTH kill-switch refusals with its own generic literal and does
// NOT echo the gate's message. Asserted from both sides: the operator-facing text
// (which is also a compiled-branch watchlist anchor, and must stay unique app-wide)
// must not reach a third-party block iframe.
expect((res._getJSONData() as { error: string }).error).toBe('Apps are not enabled');
expect(JSON.stringify(res._getJSONData())).not.toContain('could not be resolved');
// The flag is never consulted for a subject we could not resolve — a global eval
// would answer the flag's BASE value, which a GA flip makes `true`.
expect(mockIsAppBlocksEnabled).not.toHaveBeenCalled();
expect(mockFindUnique).not.toHaveBeenCalled();
});
it('a MODERATOR the flag does not admit is refused — the flag is the gate, not the role', async () => {
// The direction the old literal could never express: the flag refuses a moderator.
//
// ⚠️ THIS CASE DOES NOT SPECIFICALLY GUARD AGAINST THE LITERAL COMING BACK — it varies
// the SESSION user, while a reintroduced `if (!user.isModerator)` reads the DB ROW.
// 🔴 An earlier comment here went further and said the mutation is "invisible in this
// file". That was wrong, and measured: `activeViewer` carries no `isModerator` column,
// so the reintroduced literal sees `undefined`, refuses EVERY viewer, and reddens FIVE
// cases here (dbWrite-primary, banned-403, happy-200, muted-200, buzzBudget-null).
// Understating existing coverage is the direction that gets a guard deleted later.
// The case that covers it ON PURPOSE is in `blocks.router.me-parity.test.ts`, whose
// `userRow()` keeps the column on the fixture; measured, the literal reddens its cases
// A, C and F. This case is kept for what it does assert: the flag beats the role.
mockGetSessionUser.mockResolvedValueOnce({ ...subjectSessionUser, isModerator: true });
mockIsAppBlocksEnabled.mockResolvedValueOnce(false);
const { req, res } = createMocks();
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(401);
});
it('429 when the block instance is over the shared catalog rate limit', async () => {
mockCheckRateLimit.mockResolvedValueOnce({ allowed: false, retryAfterSeconds: 7 });
const { req, res } = createMocks();
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(429);
expect(res._getHeaders()['Retry-After']).toBe('7');
// Keyed on the stable per-instance id, and refused BEFORE the primary read it
// exists to bound.
expect(mockCheckRateLimit).toHaveBeenCalledWith('bki_test');
expect(mockFindUnique).not.toHaveBeenCalled();
});
it('403 when the resolved viewer is banned (bannedAt set) — second line of defense', async () => {
mockFindUnique.mockResolvedValueOnce({ ...activeMod, bannedAt: new Date() });
mockFindUnique.mockResolvedValueOnce({ ...activeViewer, bannedAt: new Date() });
const { req, res } = createMocks();
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(403);
expect((res._getJSONData() as { error: string }).error).toBe('banned');
});
it('200 with the viewer profile + buzzBudget for an active mod', async () => {
it('200 with the viewer profile + buzzBudget for an admitted viewer', async () => {
claimsBox.claims = fakeClaims({ buzzBudget: 250 });
const { req, res } = createMocks();
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(200);
expect(res._getJSONData()).toEqual({
id: 42,
username: 'mod',
username: 'viewer',
status: 'active',
buzzBudget: 250,
});
});
it('200 with status:"muted" for a muted (non-banned) mod — block suppresses write UI', async () => {
mockFindUnique.mockResolvedValueOnce({ ...activeMod, muted: true });
it('200 with status:"muted" for a muted (non-banned) viewer — block suppresses write UI', async () => {
mockFindUnique.mockResolvedValueOnce({ ...activeViewer, muted: true });
const { req, res } = createMocks();
await handler(req as never, res as never);
expect(res._getStatusCode()).toBe(200);