Skip to content

Add self-service API key usage status - #2908

Merged
diegosouzapw merged 3 commits into
diegosouzapw:release/v3.8.6from
guanbear:feature/self-service-api-key-usage
May 29, 2026
Merged

diegosouzapw merged 3 commits into
diegosouzapw:release/v3.8.6from
guanbear:feature/self-service-api-key-usage

Conversation

@guanbear

Copy link
Copy Markdown
Contributor

Summary

  • Add GET /api/v1/me/status so a delegated API key can view only its own USD usage, budget percentage, token totals, and optional shared Codex account quota.
  • Add self-service scopes with safe defaults: self:usage is enabled by default for new and legacy keys, while self:account-quota is opt-in and only useful with an explicit single connection.
  • Update API Manager permissions UI, i18n catalogs, OpenSpec/design/BDD docs, and harden /api/usage/budget with handler-level management auth.

Test Plan

  • DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --test tests/unit/api-manager-scope-preservation.test.ts tests/unit/api-key-scope-validation.test.ts tests/unit/api-key-self-service.test.ts tests/unit/api/v1-me-status-route.test.ts tests/unit/budget-route-auth.test.ts
  • npm run typecheck:core
  • npm run lint -- --quiet
  • npm run i18n:sync-ui:dry && npm run i18n:check-ui-coverage
  • npm run build
  • Staging deploy on a remote VM using copied production-like data: migration 073 applied, missing Bearer returned 401, default self-service key returned 200 with own cost/token usage and no accountQuota, quota-enabled key returned 200 with Codex session/weekly quota percentages.

Notes

  • Full npm test was attempted but did not complete in this local environment; it was manually interrupted after several minutes with no final TAP summary while still in the existing broad unit suite. The focused regression tests above passed.
  • The remote VM currently runs an older deployed package and has very limited memory; production rollout should either build off-box or set an explicit Node heap/increase memory before upgrading.

diegosouzapw and others added 2 commits May 29, 2026 08:16
The tokenCacheKey() SHA-256 digest is an in-memory cache key derived from
the session token, not password-at-rest storage. Document why CWE-916 KDFs
(bcrypt/scrypt/Argon2) are inapplicable here so the CodeQL
js/insufficient-password-hash finding (code-scanning alert 261) is correctly
understood as a false positive.
@guanbear
guanbear requested a review from diegosouzapw as a code owner May 29, 2026 15:55

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements self-service API key usage and quota visibility, introducing the GET /api/v1/me/status endpoint, new self:usage and self:account-quota scopes, a database migration to backfill the usage scope for existing keys, and UI controls in the API Manager. It also hardens the /api/usage/budget endpoint with management authentication and raises the scope validation limit to 32. The review feedback highlights a critical bug where toNumber conversions on ISO strings or Date objects in the self-service status builder will fail and discard the actual budget period, suggesting direct passing to isoOrNull instead. Additionally, it recommends extending isoOrNull to support Date instances and adding explicit type="button" attributes to the new buttons in PermissionsModal to prevent accidental form submissions.

Comment on lines +98 to +103
const periodStartAt = hasBudget
? toNumber(summary.periodStartAt, fallbackWindow.periodStartAt)
: fallbackWindow.periodStartAt;
const resetAt = hasBudget
? toNumber(summary.nextResetAt ?? summary.budgetResetAt, fallbackWindow.resetAt)
: fallbackWindow.resetAt;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using toNumber on summary.periodStartAt and summary.nextResetAt / summary.budgetResetAt will fail if these fields are returned as ISO strings (e.g., '2026-05-15T12:34:56.000Z') or Date objects. Number('2026-05-15T12:34:56.000Z') evaluates to NaN, causing toNumber to always return the fallback window. This completely discards the actual budget period start and reset dates. Instead, pass the values directly and let isoOrNull handle the type normalization.

Suggested change
const periodStartAt = hasBudget
? toNumber(summary.periodStartAt, fallbackWindow.periodStartAt)
: fallbackWindow.periodStartAt;
const resetAt = hasBudget
? toNumber(summary.nextResetAt ?? summary.budgetResetAt, fallbackWindow.resetAt)
: fallbackWindow.resetAt;
const periodStartAt = hasBudget && summary.periodStartAt != null
? summary.periodStartAt
: fallbackWindow.periodStartAt;
const resetAt = hasBudget && (summary.nextResetAt ?? summary.budgetResetAt) != null
? (summary.nextResetAt ?? summary.budgetResetAt)
: fallbackWindow.resetAt;

Comment on lines +77 to +86
function isoOrNull(value: number | string | null | undefined): string | null {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return new Date(value).toISOString();
}
if (typeof value === "string" && value.trim()) {
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
}
return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The isoOrNull helper currently only handles number and string types. If summary.periodStartAt or other date fields are returned as Date objects from the database/domain layer, isoOrNull will return null. Extending it to support Date instances makes it more robust.

function isoOrNull(value: number | string | Date | null | undefined): string | null {
  if (value instanceof Date) {
    return value.toISOString();
  }
  if (typeof value === "number" && Number.isFinite(value) && value > 0) {
    return new Date(value).toISOString();
  }
  if (typeof value === "string" && value.trim()) {
    const parsed = Date.parse(value);
    return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
  }
  return null;
}

Comment on lines +1936 to +1950
<button
role="switch"
aria-checked={selfUsageEnabled}
onClick={() =>
setSelfUsageEnabled((prev) => {
if (prev) setSelfAccountQuotaEnabled(false);
return !prev;
})
}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
selfUsageEnabled
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The button is missing an explicit type="button" attribute. In HTML/React, buttons inside a <form> default to type="submit". If PermissionsModal is wrapped in a form, clicking this button will trigger an accidental form submission instead of just toggling the state.

Suggested change
<button
role="switch"
aria-checked={selfUsageEnabled}
onClick={() =>
setSelfUsageEnabled((prev) => {
if (prev) setSelfAccountQuotaEnabled(false);
return !prev;
})
}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
selfUsageEnabled
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<button
type="button"
role="switch"
aria-checked={selfUsageEnabled}
onClick={() =>
setSelfUsageEnabled((prev) => {
if (prev) setSelfAccountQuotaEnabled(false);
return !prev;
})
}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
selfUsageEnabled
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>

Comment on lines +1955 to +1965
<button
role="switch"
aria-checked={selfAccountQuotaEnabled}
disabled={!selfUsageEnabled}
onClick={() => setSelfAccountQuotaEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
selfAccountQuotaEnabled
? "bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
} ${!selfUsageEnabled ? "opacity-50 cursor-not-allowed" : ""}`}
>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The button is missing an explicit type="button" attribute. Adding type="button" prevents accidental form submissions if the modal is wrapped in a form.

Suggested change
<button
role="switch"
aria-checked={selfAccountQuotaEnabled}
disabled={!selfUsageEnabled}
onClick={() => setSelfAccountQuotaEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
selfAccountQuotaEnabled
? "bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
} ${!selfUsageEnabled ? "opacity-50 cursor-not-allowed" : ""}`}
>
<button
type="button"
role="switch"
aria-checked={selfAccountQuotaEnabled}
disabled={!selfUsageEnabled}
onClick={() => setSelfAccountQuotaEnabled((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
selfAccountQuotaEnabled
? "bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
} ${!selfUsageEnabled ? "opacity-50 cursor-not-allowed" : ""}`}
>

@diegosouzapw
diegosouzapw changed the base branch from main to release/v3.8.6 May 29, 2026 16:09
…icts, and fix node:sqlite for Node v20 compatibility
@diegosouzapw
diegosouzapw merged commit a15750d into diegosouzapw:release/v3.8.6 May 29, 2026
1 of 2 checks passed
@diegosouzapw

Copy link
Copy Markdown
Owner

Thank you @guanbear for your contribution! This has been successfully merged into the release branch (with migration renumbered to 075 to resolve conflicts, and test compatibility fixes for Node.js v20) and will be included in the next release.

diegosouzapw pushed a commit that referenced this pull request May 29, 2026
Integrated into release/v3.8.6
diegosouzapw added a commit that referenced this pull request May 29, 2026
Hotfixes da release/v3.8.7 (perf RAM #2903, self-service #2908, analytics #2904, bump 3.8.7, docs) na main consolidada com 3.8.6.
@diegosouzapw diegosouzapw mentioned this pull request May 29, 2026
@guanbear

Copy link
Copy Markdown
Contributor Author

Thank you for reviewing and merging this into release/v3.8.6. I appreciate the migration renumbering and the Node.js v20 compatibility fixes.

I also reviewed the automated feedback after merge. I will prepare a small follow-up PR against the release branch to address the timestamp normalization/button type/i18n polish items, and to extend self-service account quota visibility from the single Codex connection case to all allowed provider-limit connections while keeping the same opt-in permission model.

Thanks again for the quick review and merge.

@guanbear

Copy link
Copy Markdown
Contributor Author

Follow-up PR opened here: #2931. It addresses the timestamp normalization/button type/i18n polish items from the review and extends quota visibility to all allowed provider-limit connections while keeping the same opt-in permission model.

HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
Hotfixes da release/v3.8.7 (perf RAM diegosouzapw#2903, self-service diegosouzapw#2908, analytics diegosouzapw#2904, bump 3.8.7, docs) na main consolidada com 3.8.6.
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
Hotfixes da release/v3.8.7 (perf RAM diegosouzapw#2903, self-service diegosouzapw#2908, analytics diegosouzapw#2904, bump 3.8.7, docs) na main consolidada com 3.8.6.
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
Hotfixes da release/v3.8.7 (perf RAM diegosouzapw#2903, self-service diegosouzapw#2908, analytics diegosouzapw#2904, bump 3.8.7, docs) na main consolidada com 3.8.6.
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.

2 participants