Skip to content

fix(usage): key-mask collision, double countdown timer, and OpenCode Go quota (#3640, #3470, #3334) - #3882

Open
ntdatt812 wants to merge 2 commits into
decolua:masterfrom
ntdatt812:bundle/usage-quota
Open

ntdatt812 wants to merge 2 commits into
decolua:masterfrom
ntdatt812:bundle/usage-quota

Conversation

@ntdatt812

Copy link
Copy Markdown
Contributor

Three usage/quota fixes that were sitting in separate PRs (#3337, #3537, #3650), rebased onto current master. Independent commits.

commit defect
feat(opencode-go) (#3334) OpenCode Go subscription quota was not surfaced on the dashboard
fix(quota) (#3470) the auto-refresh countdown ran two interval owners, so the timer jumped and fired twice
fix(usage) (#3640) usage was bucketed by an API key's mask, and every key minted on one machine shares its first 8 characters — so separate keys collapsed into one row

The #3640 one is the load-bearing fix: bucketing by the mask both mis-attributed usage and, in the obvious "just group by the raw key" alternative, would have put the key itself on the dashboard. It buckets by key identity instead, which is why tests/unit/security-audit.test.js is touched — that suite pins that no raw key reaches the response.

Verification

Run with vitest 3 and the repo config — both parts matter:

npx vitest@3 run --config tests/vitest.config.js <files>

Without --config the @/ alias does not resolve (Failed to load url @/lib/dataDir.js). And vitest 2.x cannot run anything that reaches open-sse/translator/index.js: it throws TypeError: register is not a function at the top-level register(...) calls, because index.js deliberately relies on function-declaration hoisting to survive the import cycle while Vite 5's SSR transform turns the named import into a call-time property access. I confirmed that failure on clean master and on master as of 15 Aug, so it is not caused by these changes.

Every fix was checked by reverting it and confirming which test dies. No claim here rests on a test that passes for another reason.

…olua#3470)

Two places created the quota tracker's intervals and each cleared only
what its own ref pointed at:

    // auto-refresh effect
    intervalRef.current  = setInterval(refreshAll, REFRESH_INTERVAL_MS);
    countdownRef.current = setInterval(tick, 1000);

    // visibilitychange handler, on becoming visible
    intervalRef.current  = setInterval(refreshAll, REFRESH_INTERVAL_MS);
    countdownRef.current = setInterval(tick, 1000);

The second assignment overwrites the refs, so whatever they held is
orphaned and ticks forever. Two paths reach that:

- the tab is hidden (handler clears both, nulls the refs), `refreshAll`
  changes identity while hidden, and the effect re-runs: its cleanup finds
  null refs and does nothing, then it creates a pair while the tab is
  still hidden. Becoming visible creates a second pair on top.
- two visible transitions in a row - which split-view and window focus
  changes can produce - run the resume branch twice.

Either way the countdown drops two seconds per second, which is the ~2x
speed in the report.

Move ownership into createRefreshTimers(): `start()` stops first, so a
second start replaces the timers instead of stacking them, and both
callers go through it. The effect no longer starts anything while the tab
is hidden, and the visibility effect stops the timers on unmount - a tab
hidden at mount takes the effect through its early return, which leaves no
cleanup behind.

`refreshAll` is now read through a ref, so a new identity no longer tears
the interval down and restarts the 60s window mid-count.

COUNTDOWN_SECONDS is derived from REFRESH_INTERVAL_MS rather than written
as a literal 60 in three places.
Keys are minted as sk-{machineId}-{keyId}-{crc}, so every key issued by one
install shares its first 8 characters -- exactly what maskApiKey() returns.
The 24h/today branch of getUsageStats grouped on that mask, so all of an
install's keys collapsed onto one row: the first key seen kept its name and
absorbed everyone else's requests, tokens and cost.

Grouping on the raw key instead, as the report suggests, would regress
AUDIT-002: these bucket names are returned to the dashboard. Probing that
guard showed the daily-summary branch (7d / 30d / all) already breaks it --
aggregateEntryToDay names its stored buckets after the raw key, and those
names were copied into the response verbatim. The source-text assertion only
covered the 24h template, so it never saw it.

Derive both branches from a shared non-secret identity: the key's own id, or
a digest for a key that is no longer registered. Distinct keys get distinct
buckets, and no branch puts the secret in the payload.

Fixes decolua#3640
@ntdatt812

Copy link
Copy Markdown
Contributor Author

Rebased onto master (39e36d3d). Was CONFLICTING, now clean.

One commit came out of the bundle: feat(opencode-go): show subscription quota on the dashboard — master already has it. usage.js imports getOpenCodeGoUsage from ./usage/opencode-go.js and registers it at line 58, and tests/unit/opencode-go-usage.test.js covers it.

Worth flagging how that showed up, because it is quiet: the conflict was only over neighbouring lines, so the merge produced a USAGE_HANDLERS object with two "opencode-go" keys. The later one wins silently — no error, no warning — so your implementation was shadowed by mine and your four tests failed while the map still looked correct on inspection. A rebase that "succeeds" can still do this; it only surfaced because the failures were compared against master by test name.

Two commits remain:

commit what
dfaf85ba the quota auto-refresh countdown gets a single timer owner, instead of one timer per row racing the others (#3470)
8922b9e2 usage is bucketed by API-key identity rather than by the key's mask, so two keys sharing a mask prefix no longer merge into one row (#3640)

npx vitest@3 run --config tests/vitest.config.js: 89 failures, the same 89 by name as on master at 39e36d3d.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant