mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
fix(apps): stop the block bridge honouring a revoked install until the token expires (#4806)
* fix(apps): stop the block bridge honouring a revoked install until the token expires
The tRPC procedures behind the host<->block postMessage bridge each called
verifyBlockToken directly - thirteen open-coded copies of the same two lines -
and checked nothing else. verifyBlockToken answers one question: is this a token
we signed, for this issuer/audience, not yet expired. It cannot see an uninstall,
a toggle-off, a publisher ban or a suspended app. So revoking or uninstalling an
app did not stop it driving the bridge; the app kept polling the orchestrator,
cancelling workflows and calling publishGenerationOutputs (which persists public
Image rows) for the rest of the token lifetime.
The REST wrapper never had this gap (withBlockScope has always called
BlockRevocation.isRevoked), and neither do the two tRPC resolvers beside this one
- resolveStorageContext in apps.router and resolveSharedContext in
apps-shared.router. The bridge was the remaining hole.
All thirteen call sites now resolve their claims through one exported helper,
authorizeBlockBridgeToken, which checks in order: token validity, per-instance
revocation, then the backing app_blocks row's approved status. No direct
verifyBlockToken call remains in blocks.router.ts.
One exemption, named rather than silent: the approved-status check is skipped for
a dev token. /api/v1/block-tokens' tryDevTunnelOwnedNonApprovedMint deliberately
mints a dev token carrying an app's REAL ids for an app that is NOT approved - a
suspended/pending/deprecated app stays runnable by its OWNER in the owner's own
dev tunnel (ownership enforced in the query, an active tunnel required, self-bound,
forced-SFW, budget-capped, never public) so they can diagnose it back into review.
Enforcing approval here would break that. Revocation is NOT exempted: every dev
and review-sandbox mint stamps a revocable instance id, so those tokens stay
killable.
Consolidating the thirteen copies surfaced two disagreements, both preserved
rather than papered over: getImagesByIds and updateUserSettings never checked a
consent scope at all (unchanged here - that is a separate question), and a
docblock above assertAppBlocksEnabledForTokenUser claimed the caller had "already
rejected invalid/expired/revoked tokens" when the caller ran a bare
verifyBlockToken and had never checked revocation. That sentence is now true.
Guarded by a structural ledger test in the test:lint-rules family
(no-unguarded-block-bridge-token) that pins the relationship, not a count: it
fails when a bridge call site disappears from the guarded set AND when one
appears that is not ledgered, plus separately when any direct verifyBlockToken
call reappears in the router.
The six existing blocks.router suites that exercise a bridge proc now declare an
approved app_blocks row; the shared db mock answers null by default, which the
new lookup would otherwise read as a deleted app.
* docs(apps): correct two claims this PR's own comments made — REST parity, and what the spelling guard covers
Round 0 of the audit ladder found both. Comments only; no behaviour change, both
guard suites still green (18 tests).
1. The helper's docblock said "The REST wrapper has always known this ... The bridge
procs ... checked neither", which reads as REST enforcing BOTH revocation and
approved status. Measured: withBlockScope calls BlockRevocation.isRevoked and has
NO approved-status gate, and neither does any handler it wraps. So the approved
check added here has no REST counterpart -- after a moderator suspension (which
flips app_blocks.status and writes no revocation marker) the tRPC bridge refuses
while REST keeps serving for the rest of the token lifetime. That asymmetry is
real, is NOT closed by this PR, and is now stated as out of scope rather than
implied away.
2. The ledger test was titled "has the two checks the guard exists for" while its body
only greps for three strings. It is walkable both ways: a semantically identical
rewrite fails it, and a comparison against the wrong value spelled the same way
passes it. Retitled and documented as a spelling check, pointing at the file that
actually pins the behaviour, so nobody reads it as coverage it does not provide.
Neither edit invents a new rationale for anything -- where the reason is that the
scope was not decided, it now says so.
* fix(apps): make the ledger see an unguarded bridge token, and stop three comments claiming an order that changed
Two round-1 audit findings on this branch.
1. The structural ledger could not see the hazard its filename names.
no-unguarded-block-bridge-token.test.ts keyed on calls to
authorizeBlockBridgeToken and on the literal spelling verifyBlockToken( —
both of which a procedure that verifies NOTHING satisfies vacuously.
Measured: adding a proc to blocks.router.ts that takes blockToken in its
input and base64-decodes the JWT payload inline left the file at 7 passed /
0 failed. An import alias walked through the same way.
The fix pins the RELATIONSHIP rather than the call sites. A second ledger,
BRIDGE_INPUT_LEDGER, names the population — every procedure whose .input()
carries a blockToken, derived from the router text (12 inline, 3 arriving
through schemas imported from buzz.schema, re-derived here rather than taken
from the audit) — and a new assertion requires every member of it to reach
the guard, directly or through a router-local helper resolved to a fixpoint.
Both ledgers stay SET comparisons in both directions. The alias hole is
closed structurally: the router may not import verifyBlockToken under any
local name.
Mutation matrix, each mutant inserted, run, and removed:
- unguarded proc (decodes the payload, verifies nothing) -> RED, and RED on
the relationship assertion ALONE once the proc is also added to the
population ledger, so the assertion is reachable and dies for its own
reason rather than to a neighbouring ledger mismatch
- direct verifyBlockToken( call inside an already-ledgered proc -> RED,
1 failed / 12 passed, isolated
- guard call site added but not ledgered (growth) -> RED
- guard call site removed (shrink) -> RED
- import { verifyBlockToken as verify } + an aliased call -> RED on three
assertions including the new import check
- clean tree -> 13 passed / 0 failed
What is still out of reach is stated in the file rather than implied away: a
token carried under a different input field name, verification performed in
a module this scan does not read, and a schema chain deeper than the depth
cap. An .input() identifier that cannot be resolved to a definition is
asserted as a failure, not scored as "no token".
2. Three comments asserted a rate-limiter ordering that the revocation guard
made false. All five in-resolver checkBlockCatalogRateLimit calls now sit
downstream of authorizeBlockBridgeToken, so a Redis GET and an indexed
appBlock.findUnique on the replica are spent before the limiter can refuse
anything. Corrected in authorizeBlockBuzzRead's docblock (which claimed
"BEFORE any db/ClickHouse work" and whose order list did not mention the two
new checks at all), in cancelAppWorkflow ("BEFORE any orchestrator
read/DELETE or DB query") and in getMyViewer ("Runs BEFORE the db read").
queryAppWorkflows' comment says "BEFORE the orchestrator call", which is
still true, and was left alone.
The ordering question was evaluated, not assumed. The limiter is keyed on
claims.blockInstanceId, so it can never precede verification — that is a hard
floor. Moving it to sit between verification and the approval read would
apply a 120-req/10s ceiling to all fifteen bridge procedures instead of the
seven that opted in, pollWorkflow among them, which is an availability change
rather than a cleanup; and it would save one Redis GET plus one replica
findUnique only on requests already over the ceiling. So the order stands,
and the guard's docblock now says so along with the per-request cost a reader
meets there: one Redis GET plus one indexed findUnique on every bridge call,
polling ones included.
Comments and tests only — no behaviour change.
Gates: typecheck 0 errors; test:lint-rules 38 files / 523 passed;
blocks.router.bridgeTokenGuard 11 passed; all 25 blocks.router.* suites
25 files / 733 passed.
* fix(apps): close four ways the bridge-token population could go unread, and two sentences wider than their code
Round-2 audit findings on this branch. Every one is the same shape as round 1 —
a description claiming more than the implementation delivers — so each is either
widened to match the sentence or narrowed to match the code, and nothing is
justified a third way.
1. The `unresolved` ledger was narrower than its own docstring. It promised an
unreadable schema is never "silently scored as carrying no token", but only
recorded one when the ENTIRE `.input()` argument was a bare identifier.
Measured with an unguarded proc that decodes the JWT and verifies nothing:
`.input(mysteryBridgeInput)` was loud, `.input(mysteryBridgeInput.extend({
page }))` gave `procs: []` / `unresolved: []` and left the whole file at
13 passed / 0 failed. Same for `.merge(b)`, a factory call, and any schema
behind a relative-path or package import.
The rule is now the argument's SCHEMA POSITIONS, not its shape: every
identifier in an argument that is not a member name, an object key, a locally
bound arrow parameter, a keyword, or the zod namespace must resolve. Comments
and string literals are blanked first by a character scanner — without that,
tokenising the router's annotated inline arguments yields 990 proc-identifier
pairs over 492 English words across 38 procs, which is how an `unresolved`
ledger becomes unusable and gets switched off. With it: 0.
Contributing cause, fixed explicitly: the suite had NO positive control that
`unresolved` can ever be non-empty. Both existing controls asserted `[]`, so a
probe wired to nothing was indistinguishable from a clean tree. There is now a
control feeding all four unreadable shapes and requiring 5 entries, plus a
negative control requiring 0 on an annotated inline argument, and the pair is
reported together.
2. Three more ways a proc could leave the population unread, all closed:
- the depth cap. The claim "the real corpus resolves every `.input()`
identifier within 2" was false: at a cap of 5 the walk reached depth 6 and
truncated 9 calls across 7 identifiers on the committed tree. No verdict
moved, but nothing said so. The walk terminates on its own at depth 8 with
no change in wall time, so the cap is 12 and truncations are now a ledger
asserted empty rather than a blind spot described in prose.
- a proc chunk cut short by a column-zero line, e.g. a multi-line template
literal continuation before `.input(`. Asserted against directly: every proc
chunk must still contain the `.mutation(` / `.query(` / `.subscription(`
that terminates it. 73 of 73 intact.
- a proc nested in a sub-router. `PROC_RE` pinned a two-space indent, so a
four-space sub-router yielded an EMPTY population. Widened; zero such procs
exist today, so this is a latent shape closed, not a bug fixed.
3. A router-local helper written as `const h = async (...) =>` made every proc
behind it read as UNGUARDED — fail-closed, but a false red on a legitimate
refactor, and wider than the docstring's promise that router-local delegation
is covered generally. `FN_RE` now matches both declaration forms.
4. "must not import verifyBlockToken under ANY local name" was wider than the
assertion: `importMap` parses static imports only, so the dynamic destructured
rename this router uses 89 times for other modules walks through it. Scope
narrowed on that test, AND a wider one added — the identifier may appear in
the router only on comment lines, which sees the static import, the dynamic
destructure, a namespace member access and a direct call alike.
5. Two sentences corrected, both closed enumerations that omitted something.
- the seven rate-limited procs were described as four procs "and the three
`getMyBuzz*` procs" behind `authorizeBlockBuzzRead`. The count of 7 is
right, the membership is wrong in both directions: `getMyDailyCompensation`
is behind the helper and is not a `getMyBuzz*` name, and `getMyBuzzBalance`
IS one, reaches the guard directly, and has NO limiter at all. So a reader
enumerating the seven both missed a throttled proc and counted the one
unthrottled buzz read as throttled. Named explicitly now, in the service,
in the call-site ledger and in the population ledger.
- "what the reorder would save is one Redis GET + one replica findUnique ...
roughly one op" omitted the priciest step. Measured ordering at all five
limiter sites: guard, then assertAppBlocksEnabledForTokenUser, then the
limiter. That middle step resolves the full SessionUser through a cached
read that falls through to an auth-hub fetch on a miss, then evaluates the
flag. On a cache miss the saving is a network round-trip, not one op. The
CONCLUSION is unchanged and was not rewritten: it rests on the availability
argument, which was verified independently.
What is still out of reach is stated in the file, not implied away: a token under
a different field name, verification in a module this scan does not read, the
verifier reached without spelling its name, and reachability being a textual call
graph rather than a proof the guard is awaited on every path.
Mutation matrix, each mutant inserted, run, and removed:
- unguarded proc behind `.extend(...)` -> RED on the `unresolved` assertion
alone, 1 failed / 19 passed; the SAME mutant against the pre-round-2 file:
13 passed / 0 failed, i.e. completely invisible
- `unresolved` rule reverted to bare-identifier-only -> RED on the positive
control, showing 1 of 5 shapes seen
- column-zero continuation inside a non-bridge proc -> RED on the chunk-integrity
assertion, isolated; same mutant pre-round-2: 13 passed / 0 failed
- `PROC_RE` reverted to a two-space indent -> RED on the sub-router control
- `FN_RE` reverted to `function` only -> RED on the arrow-helper control
- `const { verifyBlockToken: vbt } = await import(...)` + an aliased call ->
RED on three assertions including the new prose-only one, which names the
offending line; the import-alias and direct-call checks both stayed GREEN,
which is the overclaim being fixed
- depth cap lowered to 5 -> RED on the truncation ledger, reproducing all 9
truncations
- a second name added to the exemption set -> RED on the exemption pin
- `stripNonCode` neutered -> RED on the negative control and on the real-router
population scan
- clean tree -> 20 passed / 0 failed
Comments and tests only, on both files -- no behaviour change.
Gates: typecheck 0 errors; test:lint-rules 38 files / 530 passed;
blocks.router.bridgeTokenGuard 11 passed; all 25 blocks.router.* suites
25 files / 733 passed.
This commit is contained in:
@@ -178,7 +178,7 @@ Worked examples of both fixes: the two retry tests in
|
||||
|
||||
### Convention guards
|
||||
|
||||
32 live in `src/server/services/__tests__/no-*.test.ts`:
|
||||
33 live in `src/server/services/__tests__/no-*.test.ts`:
|
||||
`no-agent-ground-truth-write`, `no-coerce-boolean-in-api`, `no-direct-shared-module-mock`,
|
||||
`no-divergent-can-generate-derivation`, `no-divergent-paid-gate-derivation` (the feed and the search index must derive the paid badge from one helper, never two copies of the query), `no-divergent-safetensor-rule` (the coverage view and `checkLoadable` state the checkpoint SafeTensor rule twice and nothing executes the SQL, so the two literals and the checkpoint scoping are pinned textually), `no-doubled-free-slot-noun`, `no-hand-typed-redis-key-constants` (the Redis key-constant
|
||||
ratchet — hand-typed `REDIS_KEYS` in an allowlisted mock had drifted 15 times), `no-io-in-transaction`,
|
||||
@@ -188,7 +188,9 @@ the trigger silently stops opening),
|
||||
`no-module-scope-cache`, `no-pk-addressed-engagement-write`, `no-server-infra-in-app-graph`,
|
||||
`no-sharp-outside-native-project`, `no-stale-moderator-route-probe`, `no-static-html2canvas-import`,
|
||||
`no-unbounded-paging-fake`, `no-unbumped-draft-status-write`, `no-unguarded-billable-submit` (a user-token orchestrator submit must have its
|
||||
owner checked — see `assertWorkflowOwner`), `no-unguarded-user-text`, `no-unloadable-image-fixture`,
|
||||
owner checked — see `assertWorkflowOwner`),
|
||||
`no-unguarded-block-bridge-token` (every tRPC bridge proc must resolve its claims through
|
||||
`authorizeBlockBridgeToken`, never a bare `verifyBlockToken`), `no-unguarded-user-text`, `no-unloadable-image-fixture`,
|
||||
`no-unmoderated-blob-retraction` (the ledger of flows allowed to ask the image-cache service to
|
||||
destroy an image's SHARED stored object — a cross-account, irreversible act; moderation only),
|
||||
`no-unmuteable-comment-processor`, `no-unscoped-email-verification-exemption`,
|
||||
@@ -203,7 +205,7 @@ fail only in a full-suite run. Five were missing when this was last audited, on
|
||||
wired in then. If the diff adds a guard, check it was wired into the script, and don't treat a green
|
||||
`test:lint-rules` as "all guards passed".
|
||||
|
||||
`test:lint-rules` names 37 files today.
|
||||
`test:lint-rules` names 38 files today.
|
||||
|
||||
Both numbers and the list are checked by `no-lint-rules-script-drift`, which reads the two phrasings
|
||||
above literally — edit the numbers, not the shapes.
|
||||
|
||||
@@ -211,7 +211,7 @@ Use a top-level `import type * as PromClient` — an inline `typeof import('...'
|
||||
**Before widening a mock, check whether the import edge is needed at all.** A failing suite may be telling you the code pulled in a dependency it doesn't want, not that the mock is too narrow, and widening it would hide that. (Bit us twice in one day, Aug 2026, on two branches; one of those three suites was fixed by extracting the helpers into their own module instead.)
|
||||
|
||||
#### Convention guards run as tests
|
||||
Several repo conventions are enforced by tests, not by eslint. 32 live in
|
||||
Several repo conventions are enforced by tests, not by eslint. 33 live in
|
||||
`src/server/services/__tests__/no-*.test.ts` — `no-agent-ground-truth-write`, `no-coerce-boolean-in-api`,
|
||||
`no-direct-shared-module-mock` (the shared-mock ratchet, see `docs/testing/shared-module-mocks.md`),
|
||||
`no-divergent-can-generate-derivation` (coverage alone is not canGenerate — the ecosystem must also support the model TYPE, and the pair is composed only in `isGenerationEligible`),
|
||||
@@ -227,7 +227,10 @@ the trigger silently stops opening — six sites had it independently),
|
||||
`no-unbounded-paging-fake`, `no-unbumped-draft-status-write` (a raw-SQL write that moves a Model
|
||||
into `Draft` must set `"updatedAt" = now()`, or `remove-old-drafts` can cascade-delete it with no
|
||||
grace period), `no-unguarded-billable-submit` (a user-token orchestrator submit must have its
|
||||
owner checked — see `assertWorkflowOwner`), `no-unguarded-user-text`, `no-unloadable-image-fixture`,
|
||||
owner checked — see `assertWorkflowOwner`),
|
||||
`no-unguarded-block-bridge-token` (every tRPC bridge proc must resolve its claims through
|
||||
`authorizeBlockBridgeToken` — a bare `verifyBlockToken` honours a revoked install and a
|
||||
suspended app for a whole token lifetime), `no-unguarded-user-text`, `no-unloadable-image-fixture`,
|
||||
`no-unmoderated-blob-retraction` (the ledger of flows allowed to ask the image-cache service to
|
||||
destroy an image's SHARED stored object — content-addressed, so it takes every byte-identical image
|
||||
of every owner with it; moderation only),
|
||||
@@ -248,7 +251,7 @@ was last audited, on 2026-08-24, and were wired in then. **Add a new guard to th
|
||||
you write it**, and don't read a green `test:lint-rules` as "all guards passed" without checking the directory
|
||||
against the script.
|
||||
|
||||
`test:lint-rules` names 37 files today.
|
||||
`test:lint-rules` names 38 files today.
|
||||
|
||||
The count above, the count in the list, and the list itself are what went stale three times, so
|
||||
`no-lint-rules-script-drift` fails when they disagree with the directory or the script. It reads two exact
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@
|
||||
"test:packages:run": "vitest run --project '@civitai/*'",
|
||||
"test:apps": "vitest --project 'app:*'",
|
||||
"test:apps:run": "vitest run --project 'app:*'",
|
||||
"test:lint-rules": "vitest run --project 'unit*' src/server/notifications/__tests__/notification-settings-polarity.test.ts src/server/schema/__tests__/track.addView.schema.test.ts src/server/services/__tests__/hub-filter-parity.test.ts src/server/services/__tests__/no-agent-ground-truth-write.test.ts src/server/services/__tests__/no-coerce-boolean-in-api.test.ts src/server/services/__tests__/no-direct-shared-module-mock.test.ts src/server/services/__tests__/no-divergent-can-generate-derivation.test.ts src/server/services/__tests__/no-divergent-paid-gate-derivation.test.ts src/server/services/__tests__/no-divergent-safetensor-rule.test.ts src/server/services/__tests__/no-doubled-free-slot-noun.test.ts src/server/services/__tests__/no-hand-typed-redis-key-constants.test.ts src/server/services/__tests__/no-io-in-transaction.test.ts src/server/services/__tests__/no-job-kind-on-remix-mint.test.ts src/server/services/__tests__/no-lint-rules-script-drift.test.ts src/server/services/__tests__/no-menu-target-tooltip-nesting.test.ts src/server/services/__tests__/no-module-scope-cache.test.ts src/server/services/__tests__/no-pk-addressed-engagement-write.test.ts src/server/services/__tests__/no-server-infra-in-app-graph.test.ts src/server/services/__tests__/no-sharp-outside-native-project.test.ts src/server/services/__tests__/no-stale-moderator-route-probe.test.ts src/server/services/__tests__/no-static-html2canvas-import.test.ts src/server/services/__tests__/no-unbounded-paging-fake.test.ts src/server/services/__tests__/no-unbumped-draft-status-write.test.ts src/server/services/__tests__/no-unguarded-billable-submit.test.ts src/server/services/__tests__/no-unguarded-user-text.test.ts src/server/services/__tests__/no-unloadable-image-fixture.test.ts src/server/services/__tests__/no-unmoderated-blob-retraction.test.ts src/server/services/__tests__/no-unmuteable-comment-processor.test.ts src/server/services/__tests__/no-unpriced-default-model.test.ts src/server/services/__tests__/no-unroled-image-resource-match.test.ts src/server/services/__tests__/no-unscoped-email-verification-exemption.test.ts src/server/services/__tests__/no-untruthy-query-gate.test.ts src/server/services/__tests__/no-unverified-provenance-write.test.ts src/server/services/__tests__/no-unwrapped-knob-rotation.test.ts src/server/services/__tests__/no-wholesale-module-mock.test.ts src/server/services/__tests__/poi-checks-strip-benign-phrases.test.ts src/server/services/__tests__/video-leaderboard-badge-staging.test.ts",
|
||||
"test:lint-rules": "vitest run --project 'unit*' src/server/notifications/__tests__/notification-settings-polarity.test.ts src/server/schema/__tests__/track.addView.schema.test.ts src/server/services/__tests__/hub-filter-parity.test.ts src/server/services/__tests__/no-agent-ground-truth-write.test.ts src/server/services/__tests__/no-coerce-boolean-in-api.test.ts src/server/services/__tests__/no-direct-shared-module-mock.test.ts src/server/services/__tests__/no-divergent-can-generate-derivation.test.ts src/server/services/__tests__/no-divergent-paid-gate-derivation.test.ts src/server/services/__tests__/no-divergent-safetensor-rule.test.ts src/server/services/__tests__/no-doubled-free-slot-noun.test.ts src/server/services/__tests__/no-hand-typed-redis-key-constants.test.ts src/server/services/__tests__/no-io-in-transaction.test.ts src/server/services/__tests__/no-job-kind-on-remix-mint.test.ts src/server/services/__tests__/no-lint-rules-script-drift.test.ts src/server/services/__tests__/no-menu-target-tooltip-nesting.test.ts src/server/services/__tests__/no-module-scope-cache.test.ts src/server/services/__tests__/no-pk-addressed-engagement-write.test.ts src/server/services/__tests__/no-server-infra-in-app-graph.test.ts src/server/services/__tests__/no-sharp-outside-native-project.test.ts src/server/services/__tests__/no-stale-moderator-route-probe.test.ts src/server/services/__tests__/no-static-html2canvas-import.test.ts src/server/services/__tests__/no-unbounded-paging-fake.test.ts src/server/services/__tests__/no-unbumped-draft-status-write.test.ts src/server/services/__tests__/no-unguarded-billable-submit.test.ts src/server/services/__tests__/no-unguarded-block-bridge-token.test.ts src/server/services/__tests__/no-unguarded-user-text.test.ts src/server/services/__tests__/no-unloadable-image-fixture.test.ts src/server/services/__tests__/no-unmoderated-blob-retraction.test.ts src/server/services/__tests__/no-unmuteable-comment-processor.test.ts src/server/services/__tests__/no-unpriced-default-model.test.ts src/server/services/__tests__/no-unroled-image-resource-match.test.ts src/server/services/__tests__/no-unscoped-email-verification-exemption.test.ts src/server/services/__tests__/no-untruthy-query-gate.test.ts src/server/services/__tests__/no-unverified-provenance-write.test.ts src/server/services/__tests__/no-unwrapped-knob-rotation.test.ts src/server/services/__tests__/no-wholesale-module-mock.test.ts src/server/services/__tests__/poi-checks-strip-benign-phrases.test.ts src/server/services/__tests__/video-leaderboard-badge-staging.test.ts",
|
||||
"test:component": "node scripts/test-component-run.mjs",
|
||||
"test:component:watch": "vitest --project component",
|
||||
"test:geometry": "vitest run --project geometry",
|
||||
|
||||
@@ -194,6 +194,12 @@ beforeEach(() => {
|
||||
mockBlockWorkflowOwned.mockResolvedValue(true);
|
||||
mockQueryWorkflows.mockResolvedValue({ items: [], nextCursor: null });
|
||||
});
|
||||
// `authorizeBlockBridgeToken` resolves the backing app_blocks row on every bridge proc and
|
||||
// refuses a missing or non-approved one. The shared db mock answers `null` by default, so
|
||||
// without this every call here would 404 on a condition none of these tests is about.
|
||||
beforeEach(() => {
|
||||
dbMock.dbRead.appBlock.findUnique.mockResolvedValue({ status: 'approved' });
|
||||
});
|
||||
|
||||
describe('blocks.queryAppWorkflows', () => {
|
||||
it('calls the orchestrator LIST with the host-forced per-app tag + the viewer token', async () => {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* The host↔block postMessage bridge procedures in `blocks.router.ts` are authed by a
|
||||
* block JWT alone. `verifyBlockToken` checks the signature, the issuer/audience and the
|
||||
* expiry — and NOTHING about whether the install still exists or the app is still
|
||||
* allowed to run. So before `authorizeBlockBridgeToken` existed, revoking an install
|
||||
* (uninstall / toggle-off / publisher ban) or suspending the app left every already
|
||||
* minted token driving the bridge until its natural expiry.
|
||||
*
|
||||
* The REST `withBlockScope` wrapper has always enforced revocation
|
||||
* (`block-scope.middleware.ts`), as have the two tRPC resolvers beside this one
|
||||
* (`resolveStorageContext` in apps.router, `resolveSharedContext` in
|
||||
* apps-shared.router). The bridge was the remaining gap.
|
||||
*
|
||||
* WHAT IS PINNED HERE — the two guards, per bridge proc, as BEHAVIOUR:
|
||||
* - a revoked `blockInstanceId` ⇒ FORBIDDEN `block instance revoked`;
|
||||
* - a backing `app_blocks` row that is missing ⇒ NOT_FOUND, or not `approved`
|
||||
* ⇒ FORBIDDEN `app block is not approved`;
|
||||
* - and the positive control beside each: the SAME call, with the SAME fixture, and
|
||||
* only the guarded condition flipped, still succeeds. Without that control a
|
||||
* guard test passes just as well against a proc that rejects everything.
|
||||
*
|
||||
* The messages are asserted verbatim, not just the code: every gate on these procs
|
||||
* answers FORBIDDEN, so a code-only assertion is satisfied by a DIFFERENT guard
|
||||
* rejecting the fixture — which is exactly how a mutation to the revocation check
|
||||
* dies for the wrong reason.
|
||||
*/
|
||||
|
||||
const {
|
||||
mockIsAppBlocksEnabled,
|
||||
mockVerifyBlockToken,
|
||||
mockParseSubjectUserId,
|
||||
mockGetUserById,
|
||||
mockGetUserBuzzAccounts,
|
||||
mockGetSessionUser,
|
||||
mockIsRevoked,
|
||||
mockListMyBlockWorkflows,
|
||||
} = vi.hoisted(() => ({
|
||||
mockIsAppBlocksEnabled: vi.fn(),
|
||||
mockVerifyBlockToken: vi.fn(),
|
||||
mockParseSubjectUserId: vi.fn(),
|
||||
mockGetUserById: vi.fn(),
|
||||
mockGetUserBuzzAccounts: vi.fn(),
|
||||
mockGetSessionUser: vi.fn(),
|
||||
mockIsRevoked: vi.fn(),
|
||||
mockListMyBlockWorkflows: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('~/server/services/app-blocks-flag', () => ({
|
||||
isAppBlocksEnabled: mockIsAppBlocksEnabled,
|
||||
}));
|
||||
vi.mock('~/server/services/blocks/app-analytics.service', () => ({
|
||||
getMyAppAnalytics: vi.fn(),
|
||||
emptyAnalytics: vi.fn(),
|
||||
resolveRange: vi.fn(),
|
||||
}));
|
||||
vi.mock('~/server/services/blocks/buzz-attribution.service', () => ({
|
||||
getRevenueForOwner: vi.fn(),
|
||||
getRecentAttributionsForOwner: vi.fn(),
|
||||
emptyRevenue: vi.fn(),
|
||||
recordSpendAttribution: vi.fn(),
|
||||
}));
|
||||
vi.mock('~/server/middleware/block-scope.middleware', () => ({
|
||||
verifyBlockToken: mockVerifyBlockToken,
|
||||
parseSubjectUserId: (...a: unknown[]) => mockParseSubjectUserId(...a),
|
||||
}));
|
||||
vi.mock('~/server/services/block-revocation.service', () => ({
|
||||
BlockRevocation: { isRevoked: (...a: unknown[]) => mockIsRevoked(...a) },
|
||||
}));
|
||||
vi.mock('~/server/services/blocks/block-workflows.service', () => ({
|
||||
listMyBlockWorkflows: (...a: unknown[]) => mockListMyBlockWorkflows(...a),
|
||||
upsertBlockWorkflowOnSubmit: vi.fn(),
|
||||
updateBlockWorkflowStatus: vi.fn(),
|
||||
blockWorkflowOwnedByAppUser: vi.fn(),
|
||||
}));
|
||||
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/auth/session-client', () => ({
|
||||
sessionClient: { getSessionUserById: (...a: unknown[]) => mockGetSessionUser(...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),
|
||||
getUserBuzzAccount: vi.fn(),
|
||||
getUserBuzzTransactions: vi.fn(),
|
||||
getDailyCompensationRewardByUser: vi.fn(),
|
||||
}));
|
||||
vi.mock('~/server/services/blocks/user-app-surface.service', () => ({
|
||||
recordScopeInvocation: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock('~/server/services/block-registry.service', () => ({
|
||||
BlockRegistry: {
|
||||
listForModel: vi.fn(),
|
||||
listAvailable: vi.fn(),
|
||||
installOnModel: vi.fn(),
|
||||
updateSettings: vi.fn(),
|
||||
upsertUserSettings: vi.fn(),
|
||||
toggleEnabled: vi.fn(),
|
||||
uninstallFromModel: vi.fn(),
|
||||
resolveBlockInstance: vi.fn(),
|
||||
listUserSubscriptions: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('~/server/middleware.trpc', async () => {
|
||||
const { middleware } = await import('~/server/trpc');
|
||||
return { rateLimit: () => middleware(async ({ next }) => next()) };
|
||||
});
|
||||
|
||||
import { blocksRouter } from '../blocks.router';
|
||||
import { TokenScope } from '~/shared/constants/token-scope.constants';
|
||||
import { dbMock } from '~/__tests__/mocks/db.mock';
|
||||
|
||||
const mockDbRead = dbMock.dbRead;
|
||||
|
||||
function validClaims(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
iss: 'civitai',
|
||||
aud: 'civitai-app-block',
|
||||
sub: 'user:42',
|
||||
iat: 0,
|
||||
exp: 0,
|
||||
jti: 'jti_test',
|
||||
blockId: 'blk_test',
|
||||
appId: 'app_test',
|
||||
appBlockId: 'apb_test',
|
||||
blockInstanceId: 'bki_test',
|
||||
ctx: {},
|
||||
scopes: ['buzz:read:self', 'ai:write:budgeted'],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCtx() {
|
||||
return {
|
||||
acceptableOrigin: true,
|
||||
user: undefined,
|
||||
apiKeyId: null,
|
||||
tokenScope: TokenScope.Full,
|
||||
req: { headers: {} } as never,
|
||||
res: { setHeader: () => undefined } as never,
|
||||
cache: { edgeTTL: 0 },
|
||||
features: {} as never,
|
||||
track: undefined,
|
||||
};
|
||||
}
|
||||
const caller = () => blocksRouter.createCaller(fakeCtx() as never);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockVerifyBlockToken.mockResolvedValue(validClaims());
|
||||
mockParseSubjectUserId.mockImplementation((sub: string) =>
|
||||
sub === 'anon' ? null : Number(sub.split(':')[1])
|
||||
);
|
||||
// `assertAppBlocksEnabledForTokenUser` hydrates the TOKEN subject, then evaluates the
|
||||
// kill-switch against it. Both on, so neither can be the reason a call is refused.
|
||||
mockGetSessionUser.mockResolvedValue({ id: 42, isModerator: true });
|
||||
mockIsAppBlocksEnabled.mockResolvedValue(true);
|
||||
mockGetUserBuzzAccounts.mockResolvedValue({ blue: 1, green: 2, yellow: 3 });
|
||||
mockListMyBlockWorkflows.mockResolvedValue({ items: [], nextCursor: null });
|
||||
// Default world: the install is live and the app is approved — so every rejection
|
||||
// below is attributable to the one condition that test flips.
|
||||
mockIsRevoked.mockResolvedValue(false);
|
||||
mockDbRead.appBlock.findUnique.mockResolvedValue({ id: 'apb_test', status: 'approved' });
|
||||
});
|
||||
|
||||
describe('bridge guard — revocation', () => {
|
||||
it('lets a live, approved instance through (positive control)', async () => {
|
||||
await expect(caller().getMyBuzzBalance({ blockToken: 't' })).resolves.toEqual({
|
||||
blue: 1,
|
||||
green: 2,
|
||||
yellow: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('403s getMyBuzzBalance for a revoked blockInstanceId', async () => {
|
||||
mockIsRevoked.mockResolvedValue(true);
|
||||
await expect(caller().getMyBuzzBalance({ blockToken: 't' })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'block instance revoked',
|
||||
});
|
||||
});
|
||||
|
||||
it('403s listMyWorkflows for a revoked blockInstanceId', async () => {
|
||||
mockIsRevoked.mockResolvedValue(true);
|
||||
await expect(caller().listMyWorkflows({ blockToken: 't' })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'block instance revoked',
|
||||
});
|
||||
});
|
||||
|
||||
it('checks the token claim, not client input', async () => {
|
||||
mockIsRevoked.mockResolvedValue(false);
|
||||
await caller().getMyBuzzBalance({ blockToken: 't' });
|
||||
expect(mockIsRevoked).toHaveBeenCalledWith('bki_test');
|
||||
});
|
||||
|
||||
it('refuses BEFORE the app-block read — revocation is the cheaper check and runs first', async () => {
|
||||
mockIsRevoked.mockResolvedValue(true);
|
||||
await expect(caller().getMyBuzzBalance({ blockToken: 't' })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
expect(mockDbRead.appBlock.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bridge guard — approved status', () => {
|
||||
it('403s getMyBuzzBalance when the app_blocks row is not approved', async () => {
|
||||
mockDbRead.appBlock.findUnique.mockResolvedValue({ id: 'apb_test', status: 'suspended' });
|
||||
await expect(caller().getMyBuzzBalance({ blockToken: 't' })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'app block is not approved',
|
||||
});
|
||||
});
|
||||
|
||||
it('403s listMyWorkflows when the app_blocks row is not approved', async () => {
|
||||
mockDbRead.appBlock.findUnique.mockResolvedValue({ id: 'apb_test', status: 'pending' });
|
||||
await expect(caller().listMyWorkflows({ blockToken: 't' })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'app block is not approved',
|
||||
});
|
||||
});
|
||||
|
||||
it('404s when the app_blocks row has gone', async () => {
|
||||
mockDbRead.appBlock.findUnique.mockResolvedValue(null);
|
||||
await expect(caller().getMyBuzzBalance({ blockToken: 't' })).rejects.toMatchObject({
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The one exemption, and the reason it is not a hole: `/api/v1/block-tokens`'s
|
||||
* `tryDevTunnelOwnedNonApprovedMint` mints a dev token carrying the app's REAL ids for
|
||||
* an app that is deliberately NOT approved — a suspended/pending/deprecated app stays
|
||||
* runnable by its OWNER in the owner's own dev tunnel (self-bound, forced-SFW,
|
||||
* budget-capped, tunnel-gated, never public). Enforcing the approved status on a `dev`
|
||||
* token would break that documented path. Revocation still binds — see below.
|
||||
*/
|
||||
it('skips the approved check for a dev token, which may legitimately be non-approved', async () => {
|
||||
mockVerifyBlockToken.mockResolvedValue(validClaims({ dev: true }));
|
||||
mockDbRead.appBlock.findUnique.mockResolvedValue({ id: 'apb_test', status: 'suspended' });
|
||||
await expect(caller().getMyBuzzBalance({ blockToken: 't' })).resolves.toMatchObject({
|
||||
blue: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('still revokes a dev token — the exemption is the approved check only', async () => {
|
||||
mockVerifyBlockToken.mockResolvedValue(validClaims({ dev: true }));
|
||||
mockIsRevoked.mockResolvedValue(true);
|
||||
await expect(caller().getMyBuzzBalance({ blockToken: 't' })).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'block instance revoked',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('bridge guard — token validity', () => {
|
||||
it('401s an unverifiable token, before either new check runs', async () => {
|
||||
mockVerifyBlockToken.mockResolvedValue(null);
|
||||
await expect(caller().getMyBuzzBalance({ blockToken: 't' })).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'invalid block token',
|
||||
});
|
||||
expect(mockIsRevoked).not.toHaveBeenCalled();
|
||||
expect(mockDbRead.appBlock.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -180,6 +180,12 @@ beforeEach(() => {
|
||||
ctx?.isModerator === 'true'
|
||||
);
|
||||
});
|
||||
// `authorizeBlockBridgeToken` resolves the backing app_blocks row on every bridge proc and
|
||||
// refuses a missing or non-approved one. The shared db mock answers `null` by default, so
|
||||
// without this every call here would 404 on a condition none of these tests is about.
|
||||
beforeEach(() => {
|
||||
dbMock.dbRead.appBlock.findUnique.mockResolvedValue({ status: 'approved' });
|
||||
});
|
||||
|
||||
describe('assertAppBlocksEnabledForTokenUser — Flipt context is hydrated from the real SessionUser', () => {
|
||||
it('builds the Flipt context with the REAL tier/isMember (not free/false defaults)', async () => {
|
||||
|
||||
@@ -212,6 +212,12 @@ beforeEach(() => {
|
||||
mockSettleCustomComfySpend.mockResolvedValue(undefined);
|
||||
mockCancelWorkflow.mockResolvedValue(undefined);
|
||||
});
|
||||
// `authorizeBlockBridgeToken` resolves the backing app_blocks row on every bridge proc and
|
||||
// refuses a missing or non-approved one. The shared db mock answers `null` by default, so
|
||||
// without this every call here would 404 on a condition none of these tests is about.
|
||||
beforeEach(() => {
|
||||
dbMock.dbRead.appBlock.findUnique.mockResolvedValue({ status: 'approved' });
|
||||
});
|
||||
|
||||
describe('blocks.pollWorkflow — long poll wiring', () => {
|
||||
it('BACK-COMPAT: without waitSeconds the orchestrator read carries NO query at all', async () => {
|
||||
|
||||
@@ -415,6 +415,12 @@ beforeEach(() => {
|
||||
model: { id: 7, type: 'Checkpoint' },
|
||||
});
|
||||
});
|
||||
// `authorizeBlockBridgeToken` resolves the backing app_blocks row on every bridge proc and
|
||||
// refuses a missing or non-approved one. The shared db mock answers `null` by default, so
|
||||
// without this every call here would 404 on a condition none of these tests is about.
|
||||
beforeEach(() => {
|
||||
dbMock.dbRead.appBlock.findUnique.mockResolvedValue({ status: 'approved' });
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. The cohort is unblocked, and the widening it could have caused is closed.
|
||||
|
||||
@@ -343,6 +343,12 @@ beforeEach(() => {
|
||||
registryOverride.clear();
|
||||
registryOverride.set(CHAT_TYPE, textStep);
|
||||
});
|
||||
// `authorizeBlockBridgeToken` resolves the backing app_blocks row on every bridge proc and
|
||||
// refuses a missing or non-approved one. The shared db mock answers `null` by default, so
|
||||
// without this every call here would 404 on a condition none of these tests is about.
|
||||
beforeEach(() => {
|
||||
dbMock.dbRead.appBlock.findUnique.mockResolvedValue({ status: 'approved' });
|
||||
});
|
||||
|
||||
describe('blocks.pollWorkflow — textOutput moderation is WIRED', () => {
|
||||
it('the injected fixture is a genuinely registrable entry (the suite CONTROL)', () => {
|
||||
|
||||
@@ -857,6 +857,12 @@ beforeEach(() => {
|
||||
new Map(versions.map((v) => [v.id, { canGenerate: true }]))
|
||||
);
|
||||
});
|
||||
// `authorizeBlockBridgeToken` resolves the backing app_blocks row on every bridge proc and
|
||||
// refuses a missing or non-approved one. The shared db mock answers `null` by default, so
|
||||
// without this every call here would 404 on a condition none of these tests is about.
|
||||
beforeEach(() => {
|
||||
dbMock.dbRead.appBlock.findUnique.mockResolvedValue({ status: 'approved' });
|
||||
});
|
||||
|
||||
describe('blocks.pollWorkflow', () => {
|
||||
it('returns a snapshot for a valid token + workflowId', async () => {
|
||||
|
||||
@@ -13,9 +13,9 @@ import { logToAxiom } from '~/server/logging/client';
|
||||
import { getOrchestratorToken } from '~/server/orchestrator/get-orchestrator-token';
|
||||
import {
|
||||
parseSubjectUserId,
|
||||
verifyBlockToken,
|
||||
type BlockTokenClaims,
|
||||
} from '~/server/middleware/block-scope.middleware';
|
||||
import { authorizeBlockBridgeToken } from '~/server/services/blocks/block-bridge-auth.service';
|
||||
import {
|
||||
BLOCK_BUZZ_CAP_PER_DAY,
|
||||
BLOCK_CONSENT_BUDGET_MAX_PER_DAY,
|
||||
@@ -356,9 +356,11 @@ async function assertAppEditAccess(
|
||||
* only fix the IDENTITY it's evaluated against. This does NOT widen access: the
|
||||
* mod-segmented flag resolves `true` only for a moderator subject; a non-mod or
|
||||
* anon (`sub:'anon'` → no resolvable user) subject still resolves `false` →
|
||||
* blocked. `verifyBlockToken` (caller) already rejected invalid/expired/revoked
|
||||
* tokens before this runs, and every other belt (the per-scope consent checks,
|
||||
* budget cap, daily Buzz cap, the per-(user, app) consent budget,
|
||||
* blocked. `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.
|
||||
*
|
||||
@@ -410,21 +412,34 @@ async function assertAppBlocksEnabledForTokenUser(userId: number): Promise<void>
|
||||
* versus a spendable-balance read — which is why these three also carry the
|
||||
* per-instance rate limit below.
|
||||
*
|
||||
* Order (each step fail-closed): verify token → require consent scope → self-bind
|
||||
* the userId off `claims.sub` (never client input) → App-Blocks kill-switch
|
||||
* against the token subject → per-instance rate limit (keyed on the stable
|
||||
* `blockInstanceId`, BEFORE any db/ClickHouse work). Returns the self-bound
|
||||
* Order (each step fail-closed, except where noted): `authorizeBlockBridgeToken`
|
||||
* — which is itself verify token → revocation (a Redis GET, fail-OPEN) → approved
|
||||
* status (an indexed `dbRead.appBlock.findUnique`, skipped for a `dev` token) —
|
||||
* then require the consent scope → self-bind the userId off `claims.sub` (never
|
||||
* client input) → App-Blocks kill-switch against the token subject → per-instance
|
||||
* rate limit, keyed on the stable `blockInstanceId`. Returns the self-bound
|
||||
* `userId` + verified `claims`.
|
||||
*
|
||||
* ⚠️ THE RATE LIMIT IS NOT FIRST, and this docblock claimed it ran "BEFORE any
|
||||
* db/ClickHouse work" until the revocation guard landed and made that false. Three
|
||||
* things are now spent before the limiter can refuse anything: a Redis GET and a replica
|
||||
* `findUnique` inside `authorizeBlockBridgeToken`, and then the full `SessionUser`
|
||||
* resolve in `assertAppBlocksEnabledForTokenUser` — a cached read that falls through to
|
||||
* an auth-hub fetch on a miss, i.e. the priciest of the three. The limiter cannot be
|
||||
* hoisted above any of them: it is keyed on `claims.blockInstanceId`, which does not
|
||||
* exist until the token is verified. What it still bounds is everything AFTER it — the
|
||||
* ClickHouse daily-compensation read and the buzz-service calls in the three procs
|
||||
* below, which are the expensive half. See `block-bridge-auth.service.ts` for why the
|
||||
* order was left as it is.
|
||||
*
|
||||
* The consent scope — not an authoring capability — is the authority here: the
|
||||
* author gate that used to follow the kill-switch is gone from every runtime
|
||||
* proc (see `assertViewerIsAppDeveloper`).
|
||||
*/
|
||||
async function authorizeBlockBuzzRead(
|
||||
blockToken: string
|
||||
): Promise<{ userId: number; claims: NonNullable<Awaited<ReturnType<typeof verifyBlockToken>>> }> {
|
||||
const claims = await verifyBlockToken(blockToken);
|
||||
if (!claims) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'invalid block token' });
|
||||
): Promise<{ userId: number; claims: BlockTokenClaims }> {
|
||||
const claims = await authorizeBlockBridgeToken(blockToken);
|
||||
if (!claims.scopes.includes('buzz:read:self')) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'block lacks buzz:read:self scope' });
|
||||
}
|
||||
@@ -438,7 +453,8 @@ async function authorizeBlockBuzzRead(
|
||||
await assertAppBlocksEnabledForTokenUser(userId);
|
||||
// Per-instance rate limit (shared blocks limiter) — bounds a block hammering
|
||||
// these private reads (esp. daily-compensation → ClickHouse) onto the origin.
|
||||
// Runs BEFORE any service call. Fail-open on a redis incident.
|
||||
// Runs before the buzz/ClickHouse service calls in the procs below, but AFTER
|
||||
// the guard's own Redis GET + `appBlock.findUnique`. Fail-open on a redis incident.
|
||||
const rate = await checkBlockCatalogRateLimit(claims.blockInstanceId);
|
||||
if (!rate.allowed) {
|
||||
throw new TRPCError({
|
||||
@@ -3503,8 +3519,7 @@ export const blocksRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const claims = await verifyBlockToken(input.blockToken);
|
||||
if (!claims) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'invalid block token' });
|
||||
const claims = await authorizeBlockBridgeToken(input.blockToken);
|
||||
if (!claims.scopes.includes('ai:write:budgeted')) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'block lacks ai:write:budgeted scope' });
|
||||
}
|
||||
@@ -3627,8 +3642,7 @@ export const blocksRouter = router({
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const claims = await verifyBlockToken(input.blockToken);
|
||||
if (!claims) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'invalid block token' });
|
||||
const claims = await authorizeBlockBridgeToken(input.blockToken);
|
||||
if (!claims.scopes.includes('ai:write:budgeted')) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'block lacks ai:write:budgeted scope' });
|
||||
}
|
||||
@@ -3677,8 +3691,7 @@ export const blocksRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const claims = await verifyBlockToken(input.blockToken);
|
||||
if (!claims) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'invalid block token' });
|
||||
const claims = await authorizeBlockBridgeToken(input.blockToken);
|
||||
if (!claims.scopes.includes('ai:write:budgeted')) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'block lacks ai:write:budgeted scope' });
|
||||
}
|
||||
@@ -3774,8 +3787,7 @@ export const blocksRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const claims = await verifyBlockToken(input.blockToken);
|
||||
if (!claims) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'invalid block token' });
|
||||
const claims = await authorizeBlockBridgeToken(input.blockToken);
|
||||
// Same trust boundary as submit: an app authorized to spend the viewer's
|
||||
// Buzz on generation can read the subqueue of gens it produced.
|
||||
if (!claims.scopes.includes('ai:write:budgeted')) {
|
||||
@@ -3868,8 +3880,7 @@ export const blocksRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const claims = await verifyBlockToken(input.blockToken);
|
||||
if (!claims) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'invalid block token' });
|
||||
const claims = await authorizeBlockBridgeToken(input.blockToken);
|
||||
if (!claims.scopes.includes('ai:write:budgeted')) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'block lacks ai:write:budgeted scope' });
|
||||
}
|
||||
@@ -3883,7 +3894,11 @@ export const blocksRouter = router({
|
||||
// App-Blocks flag gate, evaluated against the TOKEN subject (not ctx.user).
|
||||
await assertAppBlocksEnabledForTokenUser(userId);
|
||||
// Per-instance rate limit (shared blocks limiter), BEFORE any orchestrator
|
||||
// read/DELETE or DB query. Cancel is the HEAVIER path (2 orchestrator GETs +
|
||||
// read/DELETE and before this resolver's own DB lookups. It is NOT before
|
||||
// every DB query on the request: `authorizeBlockBridgeToken` above already
|
||||
// spent a Redis GET and an indexed `appBlock.findUnique` on the replica, and
|
||||
// the limiter cannot be hoisted above them because it is keyed on
|
||||
// `claims.blockInstanceId`. Cancel is the HEAVIER path (2 orchestrator GETs +
|
||||
// 1 DELETE + 1 DB lookup per call), so it MUST be bounded exactly like the
|
||||
// sibling queryAppWorkflows — same key (blockInstanceId) + scope. Fail-open
|
||||
// on a redis incident (matches the buzz self-read bridges / query proc).
|
||||
@@ -3971,8 +3986,7 @@ export const blocksRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const claims = await verifyBlockToken(input.blockToken);
|
||||
if (!claims) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'invalid block token' });
|
||||
const claims = await authorizeBlockBridgeToken(input.blockToken);
|
||||
// Same trust boundary as submit/query: an app authorized to spend the
|
||||
// viewer's Buzz on generation can publish the outputs it produced.
|
||||
if (!claims.scopes.includes('ai:write:budgeted')) {
|
||||
@@ -4131,8 +4145,7 @@ export const blocksRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const claims = await verifyBlockToken(input.blockToken);
|
||||
if (!claims) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'invalid block token' });
|
||||
const claims = await authorizeBlockBridgeToken(input.blockToken);
|
||||
const userId = parseSubjectUserId(claims.sub);
|
||||
if (userId == null) {
|
||||
throw new TRPCError({
|
||||
@@ -4177,8 +4190,7 @@ export const blocksRouter = router({
|
||||
// TOKEN subject below, not the `enforceAppBlocksFlag` middleware's ctx.user.
|
||||
.input(z.object({ blockToken: z.string().min(1), body: blockWorkflowBodySchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const claims = await verifyBlockToken(input.blockToken);
|
||||
if (!claims) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'invalid block token' });
|
||||
const claims = await authorizeBlockBridgeToken(input.blockToken);
|
||||
if (!claims.scopes.includes('ai:write:budgeted')) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'block lacks ai:write:budgeted scope' });
|
||||
}
|
||||
@@ -4387,8 +4399,7 @@ export const blocksRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const claims = await verifyBlockToken(input.blockToken);
|
||||
if (!claims) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'invalid block token' });
|
||||
const claims = await authorizeBlockBridgeToken(input.blockToken);
|
||||
if (!claims.scopes.includes('ai:write:budgeted')) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'block lacks ai:write:budgeted scope' });
|
||||
}
|
||||
@@ -5295,8 +5306,7 @@ export const blocksRouter = router({
|
||||
// is a mutation for exactly this reason (token in the POST body). Keep it so.
|
||||
.input(z.object({ blockToken: z.string().min(1) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const claims = await verifyBlockToken(input.blockToken);
|
||||
if (!claims) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'invalid block token' });
|
||||
const claims = await authorizeBlockBridgeToken(input.blockToken);
|
||||
// CONSENT gate — the user's own grant is what authorizes this read now that
|
||||
// the author capability no longer gates the runtime. Checked BEFORE the
|
||||
// subject is resolved, matching authorizeBlockBuzzRead's order.
|
||||
@@ -5433,8 +5443,7 @@ export const blocksRouter = router({
|
||||
// MUTATION for the bearer-token-in-URL reason above (see getMyBuzzBalance).
|
||||
.input(z.object({ blockToken: z.string().min(1) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const claims = await verifyBlockToken(input.blockToken);
|
||||
if (!claims) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'invalid block token' });
|
||||
const claims = await authorizeBlockBridgeToken(input.blockToken);
|
||||
// CONSENT: the least-privileged "viewer identity" scope (mirrors /blocks/me).
|
||||
if (!claims.scopes.includes('user:read:self')) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'block lacks user:read:self scope' });
|
||||
@@ -5453,7 +5462,11 @@ export const blocksRouter = router({
|
||||
await assertAppBlocksEnabledForTokenUser(userId);
|
||||
// Per-instance rate limit (shared blocks limiter) — bounds a block
|
||||
// hammering the PRIMARY (the ban/mute lookup below reads dbWrite). Runs
|
||||
// BEFORE the db read. Fail-open on a redis incident.
|
||||
// BEFORE that primary read, which is the one worth bounding — but NOT
|
||||
// before every db read: `authorizeBlockBridgeToken` above already spent a
|
||||
// Redis GET and an indexed `appBlock.findUnique` on the REPLICA. The
|
||||
// limiter is keyed on `claims.blockInstanceId`, so it cannot precede the
|
||||
// verification that produces it. Fail-open on a redis incident.
|
||||
const rate = await checkBlockCatalogRateLimit(claims.blockInstanceId);
|
||||
if (!rate.allowed) {
|
||||
throw new TRPCError({
|
||||
@@ -5613,8 +5626,7 @@ export const blocksRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const claims = await verifyBlockToken(input.blockToken);
|
||||
if (!claims) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'invalid block token' });
|
||||
const claims = await authorizeBlockBridgeToken(input.blockToken);
|
||||
const userId = parseSubjectUserId(claims.sub);
|
||||
if (userId == null) {
|
||||
throw new TRPCError({
|
||||
@@ -6680,7 +6692,7 @@ async function getBlockSessionUser(userId: number): Promise<SessionUser> {
|
||||
// deterministic per-job Buzz bound the orchestrator offers.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type BlockClaims = NonNullable<Awaited<ReturnType<typeof verifyBlockToken>>>;
|
||||
type BlockClaims = BlockTokenClaims;
|
||||
type CustomComfyBody = Extract<BlockWorkflowBody, { kind: 'customComfy' }>;
|
||||
/** The INLINE arm (`mode:'inline'`) — carries the ComfyUI graph itself. */
|
||||
type CustomComfyInlineBody = Extract<CustomComfyBody, { mode: 'inline' }>;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { dbRead } from '~/server/db/client';
|
||||
import {
|
||||
verifyBlockToken,
|
||||
type BlockTokenClaims,
|
||||
} from '~/server/middleware/block-scope.middleware';
|
||||
import { BlockRevocation } from '~/server/services/block-revocation.service';
|
||||
|
||||
/**
|
||||
* THE authorization gate for the tRPC half of the host↔block postMessage bridge
|
||||
* (`blocks.router.ts`). Every bridge procedure resolves its claims through here and
|
||||
* nowhere else.
|
||||
*
|
||||
* `verifyBlockToken` answers ONE question — is this a token we signed, for this
|
||||
* issuer/audience, not yet expired. It says nothing about whether the install still
|
||||
* exists or the app is still allowed to run, so on its own it keeps honouring a token
|
||||
* for a whole lifetime after the user uninstalled the app or a moderator suspended it.
|
||||
* The two tRPC resolvers beside this one check both — `resolveStorageContext`
|
||||
* (apps.router) and `resolveSharedContext` (apps-shared.router). The bridge procs called
|
||||
* `verifyBlockToken` directly, thirteen times, and checked neither.
|
||||
*
|
||||
* 🔴 THE REST WRAPPER CHECKS REVOCATION ONLY. `withBlockScope`
|
||||
* (`block-scope.middleware.ts`) calls `BlockRevocation.isRevoked` and has NO
|
||||
* approved-status gate, and neither does any handler it wraps — measured, not assumed.
|
||||
* So step 3 below has no REST counterpart: after a moderator suspension (which flips
|
||||
* `app_blocks.status` and writes NO revocation marker — see `flipBackingBlockStatus` in
|
||||
* `offsite-moderation.service.ts`) the tRPC bridge refuses while the REST endpoints keep
|
||||
* serving for the rest of the token lifetime. That asymmetry is REAL and is NOT closed
|
||||
* here; do not read this helper as evidence the two paths agree. Widening `withBlockScope`
|
||||
* was deliberately left out of scope rather than decided against.
|
||||
*
|
||||
* ORDER, and why it is this order:
|
||||
* 1. TOKEN VALIDITY — nothing downstream can be trusted before it; an unverifiable
|
||||
* token also has no `blockInstanceId` to key a revocation lookup on.
|
||||
* 2. REVOCATION — a Redis GET, so it is the cheap check and it runs before the DB
|
||||
* read. It is also the one that responds to a user action (uninstall / toggle-off /
|
||||
* publisher ban) within seconds rather than at the next approval change.
|
||||
* 3. APPROVED STATUS — the backing `app_blocks` row must still say `approved`.
|
||||
*
|
||||
* Each step fails closed EXCEPT revocation, which fails OPEN by construction inside
|
||||
* `BlockRevocation.isRevoked` (a Redis incident must not take the bridge down; exposure
|
||||
* is bounded by the token lifetime instead of by Redis recovery time — see that
|
||||
* service's own note). That is a property of the primitive, deliberately inherited here
|
||||
* rather than re-decided, so the REST and tRPC paths cannot drift apart on it.
|
||||
*
|
||||
* 🔴 THE PER-REQUEST COST, because this runs on EVERY bridge call including the polling
|
||||
* ones. Steps 2 and 3 add ONE Redis GET plus ONE indexed `dbRead.appBlock.findUnique`
|
||||
* (the `(appId, blockId)` unique, on the replica — never the primary) to every bridge
|
||||
* request. `pollWorkflow` is the shape to think about: a running block polls it on a
|
||||
* timer, so that pair is paid per poll, per open block instance. A `dev` token skips the
|
||||
* DB read (see `assertAppBlockApproved`) but still pays the Redis GET.
|
||||
*
|
||||
* 🔴 AND THE ORDER THIS PUT THE RATE LIMITER IN. `checkBlockCatalogRateLimit` has five
|
||||
* call sites in `blocks.router.ts`, covering seven of the fifteen bridge procedures. Four
|
||||
* are in the procedure itself — `queryAppWorkflows`, `cancelAppWorkflow`, `getImagesByIds`
|
||||
* and `getMyViewer` — and the fifth is in the `authorizeBlockBuzzRead` helper, which
|
||||
* `getMyBuzzTransactions`, `getMyBuzzAccounts` and `getMyDailyCompensation` go through.
|
||||
*
|
||||
* ⚠️ NAMED, NOT WILDCARDED, AND THAT IS THE POINT. This said "the three `getMyBuzz*`
|
||||
* procs" until the round-2 audit, which is wrong in BOTH directions: `getMyDailyCompensation`
|
||||
* is behind the helper and is not a `getMyBuzz*` name, while `getMyBuzzBalance` IS one,
|
||||
* reaches this guard directly, and carries NO limiter of any kind. So a reader enumerating
|
||||
* the seven from that wildcard both missed a throttled proc and counted the one unthrottled
|
||||
* buzz read as throttled. `authorizeBlockBuzzRead`'s own docblock carries the
|
||||
* `getMyBuzzBalance` carve-out and explains why that proc cannot call the helper; this
|
||||
* cross-file sentence did not.
|
||||
*
|
||||
* All five sites run AFTER this helper, so an over-limit request has already paid the
|
||||
* Redis GET and the `findUnique` by the time the limiter refuses it. That is not free,
|
||||
* and it is not an oversight:
|
||||
* - The limiter CANNOT precede verification. It is keyed on `claims.blockInstanceId`,
|
||||
* which only exists once the token has been verified — there is no earlier key to
|
||||
* throttle on, so step 1 is a hard floor beneath it.
|
||||
* - Moving it INTO this helper, between steps 1 and 2, would apply a 120-req/10s
|
||||
* ceiling to ALL FIFTEEN bridge procedures rather than the seven that opted in —
|
||||
* `pollWorkflow` and `submitWorkflow` among them. Those are deliberately not on the
|
||||
* catalog bucket, and a polling proc is exactly the one a shared ceiling would start
|
||||
* refusing legitimately. That is an availability change, not a cleanup.
|
||||
* - What the reorder would save, on requests ALREADY over the ceiling — the abusive
|
||||
* tail, not the normal path — is this helper's Redis GET, its replica `findUnique`,
|
||||
* AND everything the caller runs between this helper returning and the limiter. At
|
||||
* all five sites that last part is `assertAppBlocksEnabledForTokenUser`, and it is the
|
||||
* priciest of the three: it resolves the full `SessionUser` through
|
||||
* `sessionClient.getSessionUserById` — a shared-cache read that falls through to an
|
||||
* internal HTTP fetch against the auth hub on a miss — and then evaluates the
|
||||
* App-Blocks flag (an in-process, cached Flipt eval).
|
||||
* ⚠️ This enumeration used to omit that step while phrasing itself as closed ("what
|
||||
* the reorder would save is one Redis GET + one replica findUnique … roughly one
|
||||
* op"). It is not roughly one op: on a session-cache miss it is a network round-trip.
|
||||
* The conclusion is unchanged, because it never rested on the cost: what decides it is the
|
||||
* availability argument above — a shared 120/10s ceiling would reach `pollWorkflow`. The
|
||||
* cost line only ever said the reorder was not worth making for its own sake, and a
|
||||
* larger saving on an over-limit request does not buy a ceiling on the polling procs.
|
||||
* So the order stands. If a bridge proc ever needs a cheaper refusal than this, the
|
||||
* change to make is a limiter keyed on something available pre-verification, not a
|
||||
* reshuffle of these three steps.
|
||||
*/
|
||||
export async function authorizeBlockBridgeToken(blockToken: string): Promise<BlockTokenClaims> {
|
||||
const claims = await verifyBlockToken(blockToken);
|
||||
if (!claims) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'invalid block token' });
|
||||
|
||||
// Per-instance revocation. Keyed on the token's OWN `blockInstanceId` claim, never on
|
||||
// anything the caller sent. Dev and review-sandbox tokens carry a synthetic but stable
|
||||
// instance id minted for exactly this purpose, so they are covered too.
|
||||
if (await BlockRevocation.isRevoked(claims.blockInstanceId)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'block instance revoked' });
|
||||
}
|
||||
|
||||
await assertAppBlockApproved(claims);
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* The backing `app_blocks` row must still be `approved`. Resolved by the same
|
||||
* `(appId, blockId)` unique the sibling resolvers use, and from the token's claims only.
|
||||
*
|
||||
* 🔴 THE ONE EXEMPTION — a `dev` token, and it is a documented product decision, not an
|
||||
* oversight. `/api/v1/block-tokens`'s `tryDevTunnelOwnedNonApprovedMint` mints a dev
|
||||
* token carrying the app's REAL ids for an app that is deliberately NOT approved: a
|
||||
* suspended / pending / deprecated app stays runnable by its OWNER inside the owner's own
|
||||
* dev tunnel, so they can diagnose it back into review. That path is contained by its own
|
||||
* belt — ownership enforced in the query, an ACTIVE dev tunnel required, author +
|
||||
* dev-tunnel flags, self-bound `sub`, forced-SFW, dev-budget-capped, and never public.
|
||||
* Enforcing approval here would break it. The dev-token mints that have no backing row at
|
||||
* all (the pending / local-manifest / review-sandbox paths, which sign a synthetic
|
||||
* `pubreq_…` / `page_local_…` / `ephemeral-…` appBlockId) are covered by the same
|
||||
* exemption for the same reason: there is no row to be approved.
|
||||
*
|
||||
* Revocation above is NOT exempted — every one of those mints stamps a revocable instance
|
||||
* id, so a dev token is still killable.
|
||||
*/
|
||||
async function assertAppBlockApproved(claims: BlockTokenClaims): Promise<void> {
|
||||
if (claims.dev === true) return;
|
||||
|
||||
const block = await dbRead.appBlock.findUnique({
|
||||
where: { appId_blockId: { appId: claims.appId, blockId: claims.blockId } },
|
||||
select: { status: true },
|
||||
});
|
||||
if (!block) throw new TRPCError({ code: 'NOT_FOUND', message: 'app block not found' });
|
||||
if (block.status !== 'approved') {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'app block is not approved' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user