feat(app): add civitai app metrics <slug> for owner-only App analytics - #190
Conversation
Adds the CLI plumbing for the existing server-side blocks.getMyAppAnalytics tRPC proc: resolve a slug to its appBlockId through the caller's own submissions (the route `civitai app status` already uses — no new resolution endpoint), then render installs / runs + Buzz spent / Buzz purchased / engagement. No server change is involved. Three properties the command exists to get right, because each one otherwise produces a believable-but-wrong reading: * The window is printed from the RESPONSE, never the flags. The server defaults to 30 days and clamps to 366, and it returns zeros for data that exists outside that window — an app with 20 runs in mid-June reads 0 under the default. Echoing range.from/to/granularity keeps a zero unambiguous. * `notOwned` is honoured before anything renders. The proc is behind the `appBlocksAuthor` flag and answers 200-with-all-zeros rather than an error when the caller is not entitled, so a naive renderer presents a permission failure as a real, empty dashboard. That case now errors with the account checks to run instead. * A 403 names the fix. The proc declares no requiredScope, so it defaults to full scope and an OAuth login token is refused where a personal API key works; the error says `civitai login --token <key>` rather than "forbidden". --from/--to accept a bare YYYY-MM-DD (midnight UTC) or full RFC3339; a malformed value or an inverted window is a usage error (exit 2) decided before any request. --json passes the server's unwrapped payload straight through, so scripts keep the series arrays and notOwned. Also rejects a literal `null` tRPC payload, which would otherwise unmarshal cleanly into a zeroed struct and render as an empty dashboard over an empty window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi
… contract, document the --json notOwned gap Three audit findings on `civitai app metrics <slug>`. 1. `engagement.errorRate` is a 0-1 RATIO, not a pre-scaled percentage. The server computes `errorRate = apiCalls > 0 ? errorCount / apiCalls : 0` (civitai/civitai src/server/services/blocks/app-analytics.service.ts), so printing it verbatim showed `Error rate 0.02040816326530612` where the reader wants `2.0%`. The human view now renders it via `pctFromRatio` at one decimal place (an exact zero reads `0.0%`). `--json` is untouched and still passes the server's raw ratio through. 2. The exit-code classification was untested. Stripping the `TagStatus` deferral in `analyticsError` and the `ErrNotFound` tag on the unknown-slug guard left every error MESSAGE byte-identical, so the whole `TestAppMetrics*` suite stayed green while 401/403 -> exit 3 and 404 -> exit 4 (promised by README + the taxonomy in root.go) silently degraded to exit 1. The 401, 403, unknown-slug and server-404 tests now assert `errors.Is` against `civitai.ErrUnauthorized` / `civitai.ErrNotFound` alongside the existing message assertions, which are unchanged. 3. `--json` deliberately fails OPEN on a not-entitled read: unlike the human view it passes a `notOwned: true` payload through with every counter zeroed and still exits 0, so `... --json | jq .runs.count` yields a 0 that is indistinguishable from a genuine zero. Behaviour is kept as-is (raw passthrough is the point of the flag); the gap is now documented in the --json flag help and in README, both saying scripts must branch on the `notOwned` field. Also fixes a rendering bug found while writing (3): pflag's UnquoteUsage lifts the first back-quoted span out of a usage string and uses it as the flag's value name, so a usage string mentioning a back-quoted `notOwned: true` rendered the boolean as `--json notOwned: true`. Back-quotes dropped, with a regression test. Verified by mutation: each new assertion was watched go red with its own message (errorRate reverted to verbatim -> 3 tests red; TagStatus neutered -> 401/403/404 red; ErrNotFound tag dropped -> unknown-slug red; notOwned sentence removed -> help test red; back-quotes reintroduced -> flag-render test red), with a no-mutation control green. Full suite: 1315 RUN / 1313 PASS / 0 FAIL / 2 SKIP (the two pre-existing environment-gated skips), gofmt + vet clean. The README sample output was regenerated from the built binary against a local fake server, not hand-written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi
Audit fixes pushed —
|
| test | new assertion |
|---|---|
TestAppMetricsUnauthorizedPointsAtLogin |
errors.Is(err, civitai.ErrUnauthorized) — exit 3 |
TestAppMetricsForbiddenAsksForPersonalAPIKey |
errors.Is(err, civitai.ErrUnauthorized) — exit 3 |
TestAppMetricsUnknownSlugIsActionableNotFound |
errors.Is(err, civitai.ErrNotFound) — exit 4 |
TestAppMetricsServer404ClassifiesNotFound (new) |
errors.Is(err, civitai.ErrNotFound) — exit 4 |
The last one is new because the two not-found paths are tagged differently: the unknown-slug guard uses an explicit civitai.Tag, while an analytics-query 404 is tagged by analyticsError's TagStatus deferral. One test could not cover both.
3. --json fails open on notOwned 🟡
Behaviour deliberately unchanged — raw passthrough at exit 0 is the point of the flag, and the test at app_metrics_test.go pinning it is left alone. Documented instead, in the --json flag help and in README.md: a notOwned: true payload is passed through with every counter zeroed and still exits 0, so civitai app metrics <slug> --json | jq .runs.count returns 0 for an app you can't see, and a script must branch on notOwned rather than trusting the counts.
Bonus: a rendering bug found while writing that doc string
pflag's UnquoteUsage lifts the first back-quoted span out of a usage string and uses it as the flag's value name. Mentioning a back-quoted `notOwned: true` payload therefore rendered the boolean as:
--json notOwned: true emit the raw analytics payload …
i.e. the help told you to pass an argument to a flag that takes none. Back-quotes dropped; TestAppMetricsJSONFlagRendersAsABoolean now pins it.
Verification
Mutation matrix — each mutation applied to the source, suite re-run, failure message recorded, source restored. M0 is the no-mutation control (proves the harness can observe a pass); M2 and M3 are exactly the two mutations that previously stayed green.
| # | mutation | result | failing assertion |
|---|---|---|---|
| M0 | (control — none) | green | — |
| M1 | pctFromRatio → FormatFloat(…, 'g', -1, 64) |
red ×3 | metrics output missing "0.0%" · error rate 0.25 should render as 25.0% · errorRate 0.020408… should render as 2.0% · the human view must not print the raw ratio |
| M2 | defer func() { err = civitai.TagStatus(status, err) }() → no-op |
red ×3 | a 401 must classify as ErrUnauthorized (exit 3), got *errors.errorString · same for 403 · a 404 must classify as ErrNotFound (exit 4) |
| M3 | drop civitai.Tag(civitai.ErrNotFound, …) on the unknown-slug guard |
red ×1 | an unknown slug must classify as ErrNotFound (exit 4), got *errors.errorString |
| M4 | remove the notOwned sentence from the --json help |
red ×1 | metrics help missing "notowned" |
| M5 | pctFromRatio drops the *100 |
red ×1 | pctFromRatio(0.25) = "0.2%", want "25.0%" |
| M6 | reintroduce back-quotes in the --json usage string |
red ×1 | --json picked up a value name from a back-quoted usage span |
Under M2 and M3 only the new classification assertions fire — every message assertion still passes, which is the whole point: those mutations are invisible to message-shape tests.
Full suite (go test ./... -count=1 -v, counted rather than read off the exit code):
=== RUN : 1315
--- PASS : 1313
--- FAIL : 0
--- SKIP : 2 (TestScanDirFromEnv, TestScaffoldPinsSatisfyPublished — pre-existing, environment-gated)
panic: test timed out : 0
gofmt -s -l . prints nothing, go vet ./... clean, go build ./... ok.
README sample — the Error rate line in the civitai app metrics sample block was regenerated by running the built binary against a local fake server (payload matching the sample's other figures: 1 error in 26 calls), not hand-edited. It now reads Error rate 3.8%.
No live credentials were used; both binary checks ran against a throwaway loopback server. No force-push, not merged.
Rendering errorRate as a one-decimal percentage collapsed every ratio below 0.0005 to `0.0%` — byte-identical to a genuine zero. An app with 5,000 API calls and 2 errors (errorRate 0.0004) read `Error rate 0.0%`, i.e. as having no errors at all. That band is exactly where a healthy, high-traffic app sits, so the loss landed on the population the command is most useful for; the raw passthrough this replaced did distinguish the two. pctFromRatio now renders `<0.1%` for anything nonzero that would otherwise round to zero. The decision is made on the RENDERED string rather than a hard-coded 0.0005 threshold, so the two cannot drift apart at the float64 boundary. Only an exact zero prints `0.0%`; everything at or above the threshold keeps the existing one-decimal form; `--json` is untouched and still passes the server's raw ratio through verbatim. Also documents the actual behaviour on the function (the old comment claimed only that an exact zero reads `0.0%`, which was true of inexact near-zeros too) and pins the [0,1] input precondition to the server invariant it rests on: `errorCount` counts a strict subset of the rows `apiCalls` counts (the same block_scope_invocations filter plus statusCode >= 400), so negative, >1, NaN and Inf are not producible. Tests: TestPctFromRatio gains the sub-0.0005 band and both sides of the boundary — the gap that let this ship — plus TestPctFromRatioTinyRateIsNotZero, which asserts only that a nonzero rate never renders identically to zero, and an end-to-end TestAppMetricsTinyErrorRateRendersDistinctly covering the dashboard and --json together. Verified: 4-mutant matrix (revert-to-bare-format, drop the r>0 guard, shift the band to 0.1, disable the --json branch) — every new assertion watched fail with its own message and the source restored byte-identically. `make ci` green, 1327 run / 1325 pass / 0 fail / 2 expected env-gated skips. Built binary run against a local fake server: 0 -> 0.0%, 0.0004 -> <0.1%, 1e-5 -> <0.1%, 0.0005 -> 0.1%, 0.02040816326530612 -> 2.0%, 0.25 -> 25.0%, with --json raw in every case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi
Closed the last audit finding: a real-but-tiny error rate no longer reads
|
| Mutant | Result | Which new assertions went red |
|---|---|---|
| control (unmutated) | 14 pass / 0 fail | — |
M1 revert to bare FormatFloat(r*100,'f',1,64) (the exact regression) |
7 fail | all 4 band cases, TinyRateIsNotZero (all 4 ratios), the e2e dashboard assertions |
M2 drop the r > 0 guard |
3 fail | exact zero stays 0.0% + the TinyRateIsNotZero zero-precondition Fatalf |
M3 shift the band to pct == "0.1" |
8 fail | all band cases and exactly the 0.0005 boundary (0.0005 wrongly became <0.1%) — pins both sides |
M4 disable the --json passthrough branch |
2 fail | both --json assertions in the new e2e test (positive control proving that half is wired to real output) |
M2 and M3 exist because M1 alone leaves the exact-zero case and the upper boundary untested — they'd pass with the guard broken in the other direction.
Gate. make ci green, gofmt -s -l . empty. Counted, not read from the exit code: RUN 1327 / PASS 1325 / FAIL 0 / SKIP 2, no panic: test timed out. Baseline before the change was 1315/1313/0/2, so +12 = 10 new TestPctFromRatio subtests + 2 new tests. The 2 skips are the pre-existing env-gated TestScanDirFromEnv and TestScaffoldPinsSatisfyPublished.
Live rendering — the built binary (not the test harness) driven against a local fake server, since there are no live credentials here:
server errorRate |
human Error rate |
--json |
|---|---|---|
0 |
0.0% |
0 |
0.0004 |
<0.1% |
0.0004 |
1e-5 |
<0.1% |
1e-05 |
0.0005 |
0.1% |
0.0005 |
0.02040816326530612 |
2.0% (unchanged) |
0.02040816326530612 |
0.25 |
25.0% (unchanged) |
0.25 |
The harness carried a negative control (an unknown slug must exit non-zero — it exits 4), so these rows are known to be real round-trips rather than a silently-stubbed path.
README. The sample block's Error rate 3.8% is unaffected and still matches what the binary prints for that ratio; the surrounding paragraph gained one sentence noting that only a genuine zero reads 0.0%. Its other numbers were left alone — it's an illustrative composite, not a capture.
🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi
#191) Adds items 5-7 to "Intentional decisions that look wrong (read before 'fixing')" and updates the section's framing sentence. 5. `civitai app metrics` calls tRPC, not REST — because there is no REST route to call. Owner analytics exist only as `blocks.getMyAppAnalytics`; there is no /api/v1 equivalent. The command therefore resolves slug -> appBlockId via the existing REST GET /api/v1/blocks/submissions and then issues the non-batched tRPC GET, reusing the authedDo + result.data.json unwrap pattern GetForgejoCloneInfo established. Documented so nobody "fixes" it into a REST call that does not exist. 6. `notOwned` is a cross-repo contract. The proc sits behind the `appBlocksAuthor` flag and answers a non-entitled caller with HTTP 200 and every counter zeroed, so a renderer that ignores it prints a plausible empty dashboard for a permission failure. The human view refuses to render on `notOwned`; `--json` deliberately passes it through and still exits 0, so scripts must branch on it themselves. Whoever changes the payload server-side has to keep the field. 7. The exit-code contract is pinned by errors.Is, never by message text. The sentinels carry no visible text (Tag/TagStatus preserve Error() byte-for- byte), so a message assertion says nothing about the exit code. Measured on the metrics PR: stripping the classification while leaving every message identical left the ENTIRE suite green, and the README's 403 -> exit 3 / not-found -> exit 4 promise was unpinned. Generalised to every command that claims an exit code. Sentinel names verified against the code, not paraphrased: the HTTP kinds are `civitai.ErrUnauthorized`/`ErrNotFound` in pkg/civitai/errkind.go, but the usage sentinel is `cmd.ErrUsage` in internal/cmd/usage_error.go (there is no `civitai.ErrUsage`) — the item says so explicitly. DEPENDS ON #190: internal/cmd/app_metrics.go and internal/appapi/analytics.go exist only on `feat/app-metrics` and are NOT on main. Merge this AFTER #190 or AGENTS.md will describe a command the tree does not have. `make ci` green: tidy + vet clean, 16/16 packages ok, 0 FAIL; `gofmt -s -l` prints nothing. Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…token can read its own analytics (#3572) * fix(app-blocks): scope-annotate getMyAppAnalytics so the CLI login token can read its own analytics `blocks.getMyAppAnalytics` carried no `.meta({ requiredScope })`, so `enforceTokenScope` fell back to its default of `TokenScope.Full`. A Full personal API key therefore worked, but the OAuth token minted by `civitai login` — the CLI's DEFAULT auth path, and the only one most users have — was rejected with FORBIDDEN "Your API key does not have the required scope for this action". That made `civitai app metrics <slug>` (civitai/cli#190) unusable for the majority of its intended audience. Annotated with `TokenScope.AppBlocksSubmit`, chosen deliberately: - It is the bit `GET /api/v1/blocks/submissions` already requires, and `civitai app metrics <slug>` calls BOTH — submissions to resolve slug -> appBlockId, then this proc. Any other bit would leave the two hops of a single command needing different scopes. - It keeps that route's stated rule: the same credential that could submit can read its own app data, and nothing weaker. - NOT `UserRead`: UserRead is inside `Full`, i.e. what every third-party "log in with Civitai" client requests. This proc exposes install counts, Buzz spend, endpoint names and active-user counts, so gating it on UserRead would expose a developer's app economics to any such client. AppBlocksSubmit is opt-in and deliberately excluded from `Full`. - NOT `AppBlocksDevTunnel` (the `stopDevTunnel` precedent): that bit means "open an on-site dev tunnel" and its consent label says so. Reusing it would force analytics readers to grant tunnel-opening and vice versa. Same fix, same bit, and same test shape as the listing-media procs in `app-listings.router.cli-scope.test.ts` (civitai/cli#186) — an identical un-annotated-proc 403. No regression and no migration: `enforceTokenScope` early-returns on `ctx.tokenScope === TokenScope.Full`, and `createContext` defaults a session to Full, so Full personal keys and the web `/apps/revenue` panel are unaffected. The `civitai-cli` OAuth client is already provisioned with this bit and the login token already carries it, so no `allowedScopes` change is needed. Verification: of the five tests added, only "CLI OAuth login token … reaches the service" is regression coverage — with the `.meta` line deleted it is the sole test that goes red, and it fails with this gate's own error at enforce-token-scope.ts:84. The other three behavioural tests pass on pre-change code and are labelled in the file as invariant guards, not regression guards. Full unit suite 755 files / 10989 passed / 1 skipped / 0 failed; tsc --noEmit 0 errors (harness validated against an injected type error reporting exactly 1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(app-blocks): annotate getMyForgejoCloneInfo too, and pin what the no-regression claim rests on Audit round on #3572. Three substantive changes plus two comment corrections. 1. `getMyForgejoCloneInfo` had the SAME defect and is live. It is `protectedProcedure` with no `.meta`, so it implicitly required `TokenScope.Full` and 403'd the OAuth login token exactly as getMyAppAnalytics did — and `civitai app pull` drives it (cli/internal/appapi/appblocks.go:739). The CLI renders that failure as "not permitted (are you the app owner, and is Apps enabled for your account?)", which sends the developer hunting an ownership problem they do not have. Annotated with the same bit, plus the same 3-case matrix in its own test file. `getMyRevenue` and `getMyApps` stay un-annotated deliberately — no CLI command calls them, so they are reached only by the session-authed web panel, which takes the Full early-return regardless. Said so in a comment, because two adjacent identically- gated procs differing with no explanation is a trap for the next reader. 2. The no-regression claim was stated as unconditional and is not. enforceTokenScope's bypass is exact equality (`ctx.tokenScope !== TokenScope.Full`), NOT hasFlag, so a strict SUPERSET of Full that lacks bit 25 — `Full|AppBlocksDevTunnel` = 100663295 — satisfied the un-annotated gate before and is FORBIDDEN after. Verified numerically: mask before after 33554431 PASS(early-return) PASS(early-return) 100663297 FORBIDDEN PASS <- the fix 100663295 PASS FORBIDDEN <- the flip No such credential can exist, but that is enforced by schema caps rather than luck: api-key.schema.ts caps a personal key at .max(Full) and oauth-client.schema.ts caps allowedScopes at .max(Full) on both create and update. Those caps are now pinned by a test, with a boundary assertion that they still ADMIT Full so they cannot be vacuously rejecting. If one is raised, the flip becomes reachable. 3. The comment claimed "No allowedScopes migration either: the civitai-cli client is already provisioned with this bit" — asserting prod state this repo cannot see. Both relevant migrations are manual-apply and the device flow lives on the auth hub. Reworded to say what is actually knowable, and to note the failure mode if bit 25 is absent in prod is "this fix does not take effect", never "a working path breaks". Also: clarified the regression-coverage note (three BEHAVIOURAL invariant guards plus the enum-only sanity test = four green, one regression guard — the previous wording read as contradicting the file's own closing line), and corrected a stale pre-existing docstring that said `moderatorProcedure` where the proc is `appDeveloperProcedure`. Verification — every mutation verified to have actually applied before its result was read, and all three source files confirmed byte-identical to their backups afterwards: - drop `.meta` from getMyForgejoCloneInfo -> 1 red, failing with the gate's own TRPCError "Your API key does not have the required scope for this action"; - raise the api-key cap -> the superset test red; - raise the oauth-client create cap -> the superset test red (so it is load-bearing on both caps independently, not just one). Full unit suite 755 files / 10994 passed / 1 skipped / 0 failed; tsc --noEmit 0 errors, no OOM. Prettier: the one violation my earlier region introduced is fixed; the remaining violations in both test files are pre-existing on main (classified by hunk position, and the invocation positive-controlled against a known violator). NOT addressed, deliberately: the `AppBlocksSubmit` consent label ("Submit Apps for review") now also authorizes an analytics read and under-describes that. It is shared consent copy in @civitai/auth, so it is a product-copy decision rather than a drive-by edit — flagged for the maintainer instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(app-blocks): correct what the scope-gate comments and the cap test actually prove Delta re-audit round on #3572. No behaviour change — the code is untouched except for one added boundary assertion. What changed is what the change SAYS about itself, in the two places the previous round got it wrong in exactly the way it was meant to fix. 1. The superset-of-Full justification named the wrong enforcement mechanism. It said the flip is "enforced by schema caps rather than luck", citing `.max(Full)` in api-key.schema.ts and oauth-client.schema.ts. Those caps are real but they do NOT govern OAuth access tokens: the hub decodes a requested scope against `ALL_SCOPES`, not `Full`, deliberately (clamping to Full would drop a legitimately-requested opt-in bit), so a token's ceiling is its client's `allowedScopes`. And the one client exceeding Full — civitai-cli, 100663297 — got that value from a RAW SQL migration, which no zod schema governs. The flip really is unreachable, because 100663297 is not a superset of Full (bit 0 and none of 1..24) — but that last step rests on INSPECTION of a migration, not on a cap. Comment now says so, and names what would silently escape: a future migration granting some client `Full | <opt-in bit>`. The test was titled "no creatable credential can hold a superset-of-Full mask", which claimed the global property it cannot prove. Renamed to "the two zod-validated credential surfaces reject a superset-of-Full mask", with its docstring stating the two surfaces it covers and the two it cannot. 2. The green-test count went stale again, broken by this PR's own previous round: the comment said "FOUR green tests" while the block now holds six tests, five green on pre-change code — the round appended the fifth green test 78 lines below the comment asserting the count. Rather than write a fifth correct number that the next round can invalidate, the comment now tells the reader to count at the source and records why: a hardcoded count in a docstring is a claim with no gate on it. Also from the re-audit: - Recorded the bit-choice reasoning for `getMyForgejoCloneInfo` separately instead of inheriting "same reason as getMyAppAnalytics". It is not the same stake: analytics is a pure read, whereas this proc MINTS A CREDENTIAL (a per-user Forgejo identity whose token carries `write:repository`, returned embedded in `cloneUrl`), so annotating widens who can trigger that mint to include the CLI login token. Accepted, with the bounds that make it acceptable written down — and the note that if an `AppBlocksRead` bit ever lands, this proc is the one that must NOT move to it. - Added the missing boundary control for `updateOauthClientSchema`. Its two siblings each had a `Full -> success` assertion isolating the cap as the cause of the rejection; the update path had only the rejection. Verified load-bearing: raising ONLY the update cap turns the test red, so all three caps are now independently pinned. - Fixed the last stale `moderatorProcedure` comment (the proc is `appDeveloperProcedure`; the "moderator" test NAMES are fine, since fakePerUserFlag keys the author capability off isModerator — noted inline so the next reader does not "fix" the names). Verification: both touched test files 27/27; full unit suite 755 files / 10994 passed / 1 skipped / 0 failed; tsc --noEmit 0 errors, no OOM. Prettier: zero hunks in the added regions (classified by hunk position); the remaining violations in both files are pre-existing on main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(app-blocks): fix the author-capability mechanism claim (third audit round) Third delta re-audit on #3572 found a 🔴 in a comment written by the SECOND round to fix a comment — the same defect class, third occurrence. Comment/docstring only; no behaviour change. 🔴 The parenthetical added last round said `appDeveloperProcedure` passes because "fakePerUserFlag keys the author capability off isModerator". Wrong, and verified wrong: `hasAppBlocksAuthor` (trpc.ts) reads ONLY `getFeatureFlags(ctx).appBlocksAuthor`, this test file does not mock feature-flags.service at all (0 references), and `fakePerUserFlag` is the mock implementation of `isAppBlocksEnabled` — the DARK flag, which is the second clause of that same sentence. So the comment attributed both gates to one mock and got the first one wrong, while contradicting the file's own header docstring. The discriminator needs no mutation: this very test forces `mockIsAppBlocksEnabled.mockResolvedValue(false)` and still returns successfully. If that mock keyed the author capability, the proc would throw FORBIDDEN first. Now states both gates and how each is actually satisfied (static `availability: ['mod']` fallback vs the mocked dark flag), keeping the note that the "moderator" test names are correct — both gates key off isModerator, via different code. Also from the same round: - "Same gate" -> "Same SCOPE gate", plus a note that the base procedures DIFFER (`appDeveloperProcedure` vs `protectedProcedure` + inline feature check), which matters because the block goes on to argue about who can trigger the credential mint — a broader audience than the author cohort. - The AppBlocksRead note was internally tense: moving getMyAppAnalytics to a future read bit alone would recreate the "two scopes for one command" problem, since `civitai app metrics` also calls `GET /api/v1/blocks/submissions`. Now says they must move together. - The non-zod-writer list read as an exhaustive partition and omitted a third writer: publish-request.service writes `allowedScopes` for `appblk-*` clients. Safe by construction (mapped bits all below 25, `grants: []` so no bearer token) rather than by any cap the test asserts on. Listed, and labelled as "known when written", not a partition. - Corrected "a token's ceiling is its client's allowedScopes" to `allowedScopes | UserRead` (all three hub sites OR in bit 0); pointed "the test below" at the right file; replaced an `enforce-token-scope.ts:84` line reference with the function name so it cannot rot. Verification: both touched files 27/27; full unit suite 755 files / 10994 passed / 1 skipped / 0 failed; tsc --noEmit 0 errors, no OOM; zero prettier hunks in the added regions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(app-blocks): correct the getMyAppRepo comparison my scope annotation made stale Fourth audit round on #3572 came back clean on the previous delta, but noted that the annotation makes an UNCHANGED neighbouring docstring imprecise: it claimed getMyForgejoCloneInfo is 'distinct from getMyAppRepo only in intent'. That was true before this PR and is not now — the two also differ in token scope, since only this one carries `.meta({ requiredScope: AppBlocksSubmit })`. Verified while fixing it that getMyAppRepo is NOT a fourth instance of the same bug: zero references in the CLI (positive control: getMyForgejoCloneInfo has 5 in internal/appapi/appblocks.go), and its callers are web-only (AuthorViaGit.tsx, git-access.ts, browser tests). So it is session-authed, takes enforceTokenScope's Full early-return, and is correctly left un-annotated — now stated rather than left for the next reader to re-derive. Comment now enumerates all three real differences (token scope, intent, collaborator read-vs-write) and confirms ownership gating IS still identical. Comment-only. Full unit suite 755 files / 10994 passed / 1 skipped / 0 failed; tsc --noEmit 0 errors, no OOM; getMyAppRepo/getMyForgejoCloneInfo/getMyAppAnalytics test files 33/33; prettier clean on the touched file (positive-controlled). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Captures the state of #190, civitai/civitai#3557 and #3561, the stale-integration-gate blocker, the measured per-app analytics, ranked follow-ups, and the session's reusable lessons. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi
* docs: handoff for app-analytics CLI command + two platform fixes Captures the state of #190, civitai/civitai#3557 and #3561, the stale-integration-gate blocker, the measured per-app analytics, ranked follow-ups, and the session's reusable lessons. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi * docs: re-gate cleared the stale-gate blocker; #3557 prettier fixed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi * docs: record the three docs PRs and the #191-after-#190 merge constraint Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi * docs(handoff): record the shipped outcomes so the doc stops reading as a to-do list The app-analytics handoff has been sitting on this branch, unmerged, describing three open PRs and a re-gate in flight. All of that shipped — 8 PRs across both repos, plus follow-ups #1, #2 and #4 — so as written the doc's most prominent content is a set of live-sounding directives for work that is done. A stranded doc that is also stale is worse than no doc. Changes: - A RESOLVED banner up top with the full merged-PR table, replacing "nothing merged". - Neutralised the "Do not merge until that re-gate reports" imperative, and recorded that the rebase it anticipated WAS needed, for a different reason: #3566 landed later and edited the same `detail: {}` object, turning #3561 CONFLICTING after its gate had passed. - Struck follow-ups #1, #2 and #4 with what actually happened, including the two places this doc was WRONG: * #1's entry missed a second proc with the identical live defect — `getMyForgejoCloneInfo`, which `civitai app pull` drives. It was found by an audit, not by the list, which is worth knowing about ranked follow-up lists in general: the list is not a survey. * #2's suggested fix (reuse `humaniseScopeEndpoint`) would have shipped a bug. Measured against the real function it returns '(no workflow id)' for `workflow:submit` and '' for `user-settings:write`, because it is the per-ROW labeller and an aggregate bucket has no `detail`. - Recorded #1's scope decision with the prod evidence that later confirmed it: 331 live tokens unblocked, 30 of which lack bit 26 — so copying the nearest precedent (AppBlocksDevTunnel) would have left those 30 still 403ing. Plus the measurement trap: `(mask & Full) = Full` is also true when `mask == Full` and reports 145 false hits; the strict-superset form needs `AND mask <> Full`. - New "Still open — start here" section: the CI `component`-tier gap (three PRs shipped browser tests that have never run on a canonical browser), the stale-node_modules trap that silently removes ~1,126 tests, the unverified `addCollaborator` downgrade lead, and a note that `installs: 0` should be assumed broken until a positive control exists. - "What actually caught the bugs": across 12 adversarial audit rounds every fix round found a defect in the previous fix, and the mechanical gate caught none of them — the suite and typecheck were green at every tip. Doc-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(handoff): retract the "CI does not run the component project" claim — it was false The "Still open" section asserted that CI does not run the `component` (browser) project at all, and used that to frame the browser tests shipped today as having no CI coverage. **That was wrong.** The `preview / component-tests` external check runs exactly that project. Recording HOW the error happened, because the mechanism is more useful than the fact: every status query written during the session filtered for `Unit tests|Typecheck|ESLint|event-engine`, so `preview / component-tests` was never in a result set — and its absence from those results was read as evidence it did not exist. The evidence was selected and then the selection was treated as the finding. The claim was then repeated in three PR comments and this doc without ever being checked directly. What the check actually shows on #3574, the PR that shipped 7 browser tests: 075519d380 (before the audit-fix round) success 8e75826616 (first fix round) FAILURE 928273e4dd (second fix round) FAILURE It is report-only, which is why the merge was not blocked. Cutting the other way: PR 3591 PASSES component-tests on a base that contains the #3574 merge, while PR 3594 fails on the same base — green for some PRs, red for others, which looks like flakiness or content-dependence rather than a defect #3574 introduced. Genuinely unresolved. Also recorded: this cannot be settled from a NixOS host. The full-project component run does not complete locally either WITH the merge (crashes, "Browser connection was closed") or WITHOUT it (times out at 25 minutes) — measured both ways specifically to check whether the local failure was attributable to the change. It is not. Single-file runs pass, cold cache included, so a local run can validate one file and says nothing about the suite. The preview pipeline's logs are the only authority. And one genuinely stale thing found while investigating, left as an actionable note: `lint.yml`'s unit job excludes browser tests on the grounds that they "carry the cold-optimizeDeps flake documented at vitest.config.mts:98-124" — but that section documents the FIX, and a cold-cache single-file run passes. The stated reason no longer holds. Doc-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(handoff): add the followups handoff, with the component-tests root cause Second doc on this PR. The predecessor records the original session; this one records where the stream stands now and carries one fully-diagnosed open bug. The headline: `preview / component-tests` went success -> failure exactly at #3574's audit-fix round, and the root cause is mine. The 'Runs (range)' stat's tooltip begins 'Generations run through your app...', my test asserts getByText('Generations'), and getByText is substring + case-insensitive — so the locator resolves to 2 elements whenever that hover-only tooltip is mounted. civitai#3593 established that vitest browser mode shares ONE page across every .browser.test.tsx file, so a leftover pointer position from an earlier file can have it open at mount: fails in a full-suite run, never in a single-file run, which is exactly the observed pattern. Confirmed by computation that the PREVIOUS label ('Generation submits') did NOT substring-match that tooltip, so the vocabulary rename is what introduced it. Doc includes the verbatim fix to apply, the eliminations (cold-optimizeDeps flake is fixed; PR 3591 passes on a base containing the merge, so the suite is not universally broken; a local repro cannot settle it because the full-project run fails on this host with AND without the change), and the environment traps that produced false greens all session. Doc-only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(handoff): mark the component-tests investigation resolved (civitai#3606) Records the fix, the proof matrix, and the reproduction trap: the tooltip target is the 14px IconInfoCircle, not the label — hovering the label does NOT mount it, so a probe without its own positive control reads as 'hypothesis wrong' and would have shipped an unproven fix. Also flags what is still unconfirmed: preview/component-tests had not reported on #3606 at merge time, so the next PR's result on that check is the confirmation to read. Doc-only. * docs(handoff): sync the header with the fix, and record the pins-vs-published blocker Goal/state lines still described the component test as outstanding. Also records why cli#193 is BLOCKED: pins-vs-published is red on main too (npm published past the scaffold pins), it has nothing to do with these docs, and the one-command fix unblocks it while fixing a real defect. Doc-only. * docs(handoff): record the pins blocker as fixed by cli#194 The `pins-vs-published` blocker described here is resolved — cli#194 (189d5a4) bumped @civitai/app-sdk ^0.28.0 -> ^0.30.0 and @civitai/blocks-react ^0.37.0 -> ^0.38.0 via the repo's own bump-pins command. Three corrections to what this doc told the next session: - The bumper rewrites scaffold_test.go's assertions ITSELF, along with the package.json.tmpl and README.md.tmpl literals. The doc's "then update the matching assertions in scaffold_test.go" implied a hand-edit that would have been redundant. - The blocker is RECURRING, not a one-off: it fires whenever npm publishes a new @civitai/* minor, on any open PR regardless of content. Noted, with a pointer to check why bump-scaffold-pins.yml did not open the bump PR on its own. - Recorded the one thing the bumper gets slightly wrong (a README prose line that overstates the minimum SDK version post-bump), so it is not rediscovered. Also renumbers the ranked list and strikes the two completed items. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017vvMdxcMKJP9ripQLEiPm9 * docs(handoff): correct the bump-pins lead — cron latency, not a broken workflow My previous commit told the next session to "check why bump-scaffold-pins.yml did not open the bump PR itself". Measured it instead of leaving the hint: the workflow is fine and the framing was wrong. The scheduled run fired Mon 2026-08-03 10:51 UTC and correctly no-op'd. Both publishes landed LATER THE SAME DAY — @civitai/blocks-react 0.38.0 at 20:15 UTC, @civitai/app-sdk 0.30.0 at 22:13 UTC (npm registry `time` field). A successful no-op run and a broken detector look identical from the run list alone; the publish timestamps are the signal that separates them. The real exposure is the weekly cron (`17 7 * * 1`): a required check can sit red on main and every open PR for up to ~7 days after any @civitai/* minor. Records the two consequences — the fixed remedy (or `gh workflow run`), and that the lever is cron frequency, flagged as ask-first per AGENTS.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017vvMdxcMKJP9ripQLEiPm9 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
Adds
civitai app metrics <slug>— the authenticated owner's analytics for one oftheir own App Blocks: installs, runs + Buzz spent, Buzz purchased, and API
engagement. Registered under
newAppCmd()alongsidestatus/withdraw.This is CLI plumbing only — no server change. The
blocks.getMyAppAnalyticstRPC proc already exists and is live on civitai.com; nothing here adds or alters
a server-side route. The slug →
appBlockIdresolution reuses the existingGET /api/v1/blocks/submissionsclient (appapi.Lister), the same routecivitai app statusreads — no new resolution endpoint was introduced.The three correctness requirements, and how each is tested
Each of these is a case where the obvious implementation prints something that
looks like data but isn't. They are the reason the command is shaped this way.
1. The printed window comes from the response, never the flags
The server defaults to the last 30 days, clamps any request to 366 days,
and returns zeros for data that exists outside that window — an app with 20
runs in mid-June reads
0under the default window. So a zero is meaninglesswithout the period it covers, and echoing the requested window would be a lie
whenever the server clamped it. The renderer prints
range.from/range.to/range.granularitystraight from the payload.Tested by
TestAppMetricsClampedRangeEchoesResponseNotFlags: the CLI requests--from 2020-01-01, the fake server answers with a range clamped to2025-08-03, and the test asserts the output contains the clamped start anddoes not contain
2020-01-01.TestAppMetricsRendersVerifiedPayloadandTestAppMetricsGenuineZeroRendersWithWindowadditionally pin that the window ispresent on both a populated and an all-zero read.
2. "Not entitled" is distinguished from "genuinely zero"
The proc sits behind the
appBlocksAuthorfeature flag (moderators-only bydefault) and, when the caller doesn't match it or doesn't own the app, answers
HTTP 200 with every counter zeroed — flagged only by the payload's
notOwnedboolean. Rendering that would present a permission failure as a real,empty dashboard, which is the primary failure mode here. On
notOwnedthecommand renders nothing and returns an actionable error naming
civitai whoamiandcivitai app status <slug>.Tested by
TestAppMetricsNotOwnedRefusesDashboard, which asserts both halves:the error text, and that stdout contains none of
Installs/Runs/Buzz purchased/Engagement/Granularity. Its complement,TestAppMetricsGenuineZeroRendersWithWindow, feeds all-zero counts withnotOwned:falseand asserts the dashboard does render (with its window) and isnot reported as a permission problem — so the two cases can't collapse into
each other.
3. A 403 names the fix instead of saying "forbidden"
The proc declares no
requiredScope, so it defaults to full scope: an OAuthlogin token gets a 403 where a personal API key succeeds. The 403 branch says to
create a key and run
civitai login --token <key>, and points atcivitai whoamito see which credential is active. Per AGENTS.md item 4, noscope bitmask is vendored — this is purely the HTTP status plus guidance.
Tested by
TestAppMetricsForbiddenAsksForPersonalAPIKey, which asserts theerror contains
403,personal API key,civitai login --tokenandOAuth—not merely that some error occurred.
Also fixed while building it
A literal
nulltRPC payload ({"result":{"data":{"json":null}}}) unmarshalscleanly into the zero struct and would have rendered as an all-zero dashboard
over an empty window — the exact silent-empty failure requirement 2 is about. It
is now rejected as malformed. This was caught by a test written before the code
handled it, and watched fail first.
Flags
--from/--to— a bareYYYY-MM-DD(interpreted as midnight UTC, sothe window is reproducible regardless of where the CLI runs) or a full RFC3339
timestamp, normalised to UTC on the wire. A malformed value or an inverted
window is an
ErrUsage→ exit 2, decided client-side before any request(asserted: the tests check no HTTP call was made). Omitting both bounds omits
them from the wire so the server's own 30-day default applies.
--json— passes the server's unwrapped payload through verbatim (notre-marshalled through the CLI's structs), so scripts keep every field
including
notOwnedand the per-bucketseriesarrays the human view omits.Test coverage
internal/cmd/app_metrics_test.godrives the command end-to-end throughNewRootCmd()+SetArgsagainst anhttptestserver serving both routes(submissions + tRPC) — no live network. 31 test cases including subtests:
TestAppMetricsRendersVerifiedPayload--jsonpassthrough (unwrapped, no rendered text)TestAppMetricsJSONPassthroughnotOwned:true→ no dashboardTestAppMetricsNotOwnedRefusesDashboardTestAppMetricsGenuineZeroRendersWithWindowTestAppMetricsClampedRangeEchoesResponseNotFlagsTestAppMetricsForbiddenAsksForPersonalAPIKeycivitai loginTestAppMetricsUnauthorizedPointsAtLoginTestAppMetricsMissingTokenErrorsTestAppMetricsUnknownSlugIsActionableNotFoundappBlockId: null→ "no approved block yet"TestAppMetricsNoApprovedBlockYetTestAppMetricsPicksNewestNonNullAppBlockIDnull, wrong type, empty)TestAppMetricsMalformedEnvelopeIsCleanError--from/--toparsing ×3 (date, RFC3339, offset→UTC)TestAppMetricsWindowFlagsAcceptedTestAppMetricsBadWindowIsUsageErrorTestAppMetricsNoWindowFlagsOmitsBothBoundsTestAppMetricsSubmissionsErrorSurfacesTestUSDFromCents,TestUTCStampEvery error-path test asserts the specific message or behaviour, not merely
that an error occurred.
Mutation sweep — 13/13 mutants killed. Each guard was broken on purpose and
confirmed to go red on its own assertion (not a different guard's): the window
echo, the
notOwnedbranch (two mutants — one that skips it, one that rendersbefore erroring, so both halves of the assertion are load-bearing), the 403
message, the
null-payload rejection, the resolver's fall-through to theapproved row, both
asUsageErrortags, the inverted-window check, date-onlyparsing,
--jsonpassthrough, and both formatting helpers. The sweep was runwith a green baseline before and after, and the tree verified restored.
Verification
Counted rather than read off the exit code: 1311
=== RUN, 1309--- PASS,0
--- FAIL, 2--- SKIP, 0 panics, 16 packages ok. Both skips(
TestScanDirFromEnv,TestScaffoldPinsSatisfyPublished) are pre-existing andenvironment-gated, unrelated to this change.
gofmt -s -l .prints nothing.The built binary was also driven against a local fake server for the three
user-visible paths: the render above (exit 0),
notOwned(error, no dashboard,exit 1), and
--from last-tuesday(usage error, exit 2).Docs
README gets the command-table row and an App metrics section (the sample
output above is real CLI output, not hand-written), covering the three
requirements plus the data caveat: engagement counts only authenticated,
scope-gated API calls, so an app with no scoped API surface shows real
installs and revenue with a flat engagement section — expected, not a bug. The
same caveat is in the command's
Longhelp, and the human output prints a notewhen
apiCalls == 0.Note for review
errorRateis rendered verbatim rather than as a percentage. Its unit isn'tdeterminable from the verified payload (the observed value is
0), andmultiplying by 100 on a guess would be a silent 100× error in a user-facing
figure. If the server's contract says it's a 0–1 ratio, converting it is a
one-line follow-up.
🤖 Generated with Claude Code