feat: implemented credential lifecycle tracking and management APIs
- Added `listDisabledCredentials` and `refreshSnapshot` methods to credential stores along with API endpoints and wire schemas. - Added `authorizedAt` timestamps and Anthropic OAuth grant TTL constants to track credential lifecycles. - Updated the usage CLI to render auto-disabled credential tombstones and grant expiration warnings. - Added comprehensive unit and broker integration tests covering the new credential management features.
This commit is contained in:
@@ -34,9 +34,12 @@ ENV BUN_INSTALL=/opt/bun \
|
||||
PATH=/opt/bun/bin:/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin \
|
||||
CARGO_TERM_COLOR=never
|
||||
|
||||
# clang/libclang-dev: bindgen for maudio-sys (miniaudio); cmake/make/ninja-build:
|
||||
# audiopus_sys builds bundled libopus via CMake (native audio stack, 17.1.1+).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates pkg-config libssl-dev unzip git \
|
||||
clang libclang-dev cmake make ninja-build \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" \
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- OAuth logins now stamp `authorizedAt` (epoch ms of the interactive login) on the stored credential, and every refresh-persist path preserves it. Anthropic expires the whole OAuth grant family ~30 days after authorization regardless of refresh-token rotation (observed as `invalid_grant: "Refresh token expired"` on the latest rotated token, exactly 30 days after login, across four production accounts), so the login anchor is what makes re-login deadlines computable. Exported `ANTHROPIC_OAUTH_GRANT_TTL_MS` alongside the anthropic OAuth flow.
|
||||
- Added `GET /v1/credentials/disabled` to the auth broker and `AuthBrokerClient.listDisabledCredentials`: disabled-credential tombstones (`DisabledCredentialSummary` — identity, verbatim disable cause, disable timestamp; never token material) so auto-disabled accounts stay visible to clients instead of silently vanishing from the snapshot. `AuthStorage.listDisabledCredentials` serves the same data locally from SQLite; clients of brokers predating the endpoint get an empty list (404 mapped, no error).
|
||||
- Added `AuthStorage.revalidateCredentials()` and the optional `AuthCredentialStore.refreshSnapshot` hook: remote broker stores re-fetch `GET /v1/snapshot` on demand so callers pairing live per-credential data with stored identities (`omp usage`) never render against the up-to-an-hour-stale disk-cached snapshot; local SQLite stores are always current and only reload.
|
||||
|
||||
## [17.1.3] - 2026-07-24
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import { readSseEvents } from "@oh-my-pi/pi-utils";
|
||||
import { type } from "arktype";
|
||||
import type { AuthCredential } from "../auth-storage";
|
||||
import type { AuthCredential, DisabledCredentialSummary } from "../auth-storage";
|
||||
import type {
|
||||
ClientUsageReportRequest,
|
||||
ClientUsageReportResponse,
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
CredentialRefreshResponse,
|
||||
CredentialUploadRequest,
|
||||
CredentialUploadResponse,
|
||||
DisabledCredentialsResponse,
|
||||
HealthzResponse,
|
||||
SnapshotResponse,
|
||||
SnapshotStreamEvent,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
credentialDisableResponseSchema,
|
||||
credentialRefreshResponseSchema,
|
||||
credentialUploadResponseSchema,
|
||||
disabledCredentialsResponseSchema,
|
||||
healthzResponseSchema,
|
||||
snapshotResponseSchema,
|
||||
snapshotStreamEventSchema,
|
||||
@@ -306,6 +308,27 @@ export class AuthBrokerClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disabled-credential tombstones (identity + cause, no token material).
|
||||
* Returns an empty list against brokers predating `GET
|
||||
* /v1/credentials/disabled` (404).
|
||||
*/
|
||||
async listDisabledCredentials(provider?: string, signal?: AbortSignal): Promise<DisabledCredentialSummary[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (provider) params.set("provider", provider);
|
||||
const path = `/v1/credentials/disabled${params.size > 0 ? `?${params.toString()}` : ""}`;
|
||||
try {
|
||||
const response = await this.#request<DisabledCredentialsResponse>("GET", path, {
|
||||
schema: disabledCredentialsResponseSchema,
|
||||
signal,
|
||||
});
|
||||
return response.disabled;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthBrokerError && error.status === 404) return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadCredential(
|
||||
provider: string,
|
||||
credential: AuthCredential,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type AuthCredential,
|
||||
type AuthCredentialSnapshotEntry,
|
||||
type AuthCredentialStore,
|
||||
type DisabledCredentialSummary,
|
||||
type OAuthCredential,
|
||||
REMOTE_REFRESH_SENTINEL,
|
||||
type StoredAuthCredential,
|
||||
@@ -471,6 +472,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Broker-backed disabled tombstones; empty against brokers predating the endpoint. */
|
||||
listDisabledCredentials(provider?: string, signal?: AbortSignal): Promise<DisabledCredentialSummary[]> {
|
||||
return this.#client.listDisabledCredentials(provider, signal);
|
||||
}
|
||||
|
||||
getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined {
|
||||
const nowMs = Date.now();
|
||||
this.cleanExpiredCredentialBlocks(nowMs);
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
CredentialDisableResponse,
|
||||
CredentialRefreshResponse,
|
||||
CredentialUploadResponse,
|
||||
DisabledCredentialsResponse,
|
||||
HealthzResponse,
|
||||
RefresherSchedule,
|
||||
SnapshotEntry,
|
||||
@@ -659,6 +660,12 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
|
||||
return json(500, { error: message });
|
||||
}
|
||||
}
|
||||
if (req.method === "GET" && pathname === "/v1/credentials/disabled") {
|
||||
const provider = url.searchParams.get("provider") ?? undefined;
|
||||
const disabled = await opts.storage.listDisabledCredentials(provider, req.signal);
|
||||
const body: DisabledCredentialsResponse = { generatedAt: Date.now(), disabled };
|
||||
return json(200, body);
|
||||
}
|
||||
const refreshMatch = req.method === "POST" ? pathname.match(REFRESH_ROUTE) : null;
|
||||
if (refreshMatch) {
|
||||
const id = Number.parseInt(refreshMatch[1], 10);
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
AuthCredential,
|
||||
AuthCredentialSnapshot,
|
||||
AuthCredentialSnapshotEntry,
|
||||
DisabledCredentialSummary,
|
||||
StoredCredentialBlock,
|
||||
} from "../auth-storage";
|
||||
import type { ClientUsageClientSummary, ClientUsageReport, UsageHistoryEntry, UsageReport } from "../usage";
|
||||
@@ -86,6 +87,12 @@ export interface CredentialDisableResponse {
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
/** GET /v1/credentials/disabled response body — tombstones of auto-disabled rows. */
|
||||
export interface DisabledCredentialsResponse {
|
||||
generatedAt: number;
|
||||
disabled: DisabledCredentialSummary[];
|
||||
}
|
||||
|
||||
/** POST /v1/credential/:id/block request body. */
|
||||
export type CredentialBlockRequest = CredentialBlockSnapshot;
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ export const oauthCredentialSchema = type({
|
||||
"accountId?": "string",
|
||||
"orgId?": "string",
|
||||
"orgName?": "string",
|
||||
"authorizedAt?": "number",
|
||||
});
|
||||
|
||||
/** OAuth credential as it appears in broker snapshots — refresh replaced with sentinel. */
|
||||
@@ -49,6 +50,7 @@ export const remoteOauthCredentialSchema = type({
|
||||
"accountId?": "string",
|
||||
"orgId?": "string",
|
||||
"orgName?": "string",
|
||||
"authorizedAt?": "number",
|
||||
});
|
||||
|
||||
export const apiKeyCredentialSchema = type({
|
||||
@@ -320,6 +322,27 @@ export const credentialDisableResponseSchema = type({
|
||||
ok: "boolean",
|
||||
});
|
||||
|
||||
/** One disabled-credential tombstone — identity + cause, never token material. */
|
||||
export const disabledCredentialSummarySchema = type({
|
||||
"+": "reject",
|
||||
id: "number.integer",
|
||||
provider: type("string").atLeastLength(1),
|
||||
type: "'oauth' | 'api_key'",
|
||||
"email?": "string",
|
||||
"accountId?": "string",
|
||||
"orgId?": "string",
|
||||
"orgName?": "string",
|
||||
cause: "string",
|
||||
"disabledAtMs?": "number",
|
||||
});
|
||||
|
||||
/** Broker `GET /v1/credentials/disabled` response. */
|
||||
export const disabledCredentialsResponseSchema = type({
|
||||
"+": "reject",
|
||||
generatedAt: "number",
|
||||
disabled: disabledCredentialSummarySchema.array(),
|
||||
});
|
||||
|
||||
// ─── Credential blocks ──────────────────────────────────────────────────────
|
||||
|
||||
export const credentialBlockRequestSchema = credentialBlockSnapshotSchema;
|
||||
|
||||
@@ -170,6 +170,28 @@ export interface StoredCredentialBlock {
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity slice of a disabled (soft-deleted) credential tombstone — cause and
|
||||
* account identity only, never token material. Surfaced so auto-disabled
|
||||
* accounts (e.g. an expired Anthropic OAuth grant) stay visible in `omp usage`
|
||||
* instead of silently vanishing until the user notices missing quota.
|
||||
*/
|
||||
export interface DisabledCredentialSummary {
|
||||
/** Database row id (matches {@link StoredAuthCredential.id}). */
|
||||
id: number;
|
||||
provider: string;
|
||||
type: AuthCredential["type"];
|
||||
email?: string;
|
||||
accountId?: string;
|
||||
/** Organization/workspace the credential was scoped to (Anthropic/ChatGPT multi-subscription). */
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
/** Verbatim disable cause captured when the row was torn down. */
|
||||
cause: string;
|
||||
/** Epoch ms the row was disabled (SQLite `updated_at`), when known. */
|
||||
disabledAtMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-credential health record returned by {@link AuthStorage.checkCredentials}.
|
||||
*
|
||||
@@ -353,6 +375,21 @@ export interface AuthCredentialStore {
|
||||
/** Optional hook to notify the underlying store that usage report cache is stale. */
|
||||
invalidateUsageCache?(signal?: AbortSignal): Promise<void>;
|
||||
listAuthCredentials(provider?: string): StoredAuthCredential[];
|
||||
/**
|
||||
* Optional store hook to re-hydrate the credential snapshot from its
|
||||
* backing source. Remote broker stores re-fetch `GET /v1/snapshot` so a
|
||||
* disk-cached snapshot (up to an hour stale) cannot be paired with live
|
||||
* per-credential data; local SQLite stores omit it — their reads are
|
||||
* always current.
|
||||
*/
|
||||
refreshSnapshot?(): Promise<unknown>;
|
||||
/**
|
||||
* Disabled credential tombstones (see {@link DisabledCredentialSummary}).
|
||||
* Optional: remote stores forward to the broker's
|
||||
* `GET /v1/credentials/disabled` (empty list when the broker predates the
|
||||
* endpoint); stores without tombstones omit it.
|
||||
*/
|
||||
listDisabledCredentials?(provider?: string, signal?: AbortSignal): Promise<DisabledCredentialSummary[]>;
|
||||
updateAuthCredential(id: number, credential: AuthCredential): void;
|
||||
deleteAuthCredential(id: number, disabledCause: string): void;
|
||||
tryDisableAuthCredentialIfMatches(
|
||||
@@ -2708,7 +2745,10 @@ export class AuthStorage {
|
||||
this.#resetProviderAssignments(provider);
|
||||
return { type: "api_key" };
|
||||
}
|
||||
const newCredential: OAuthCredential = { type: "oauth", ...result };
|
||||
// Stamp the interactive-login instant: providers with an absolute grant
|
||||
// lifetime (Anthropic) need it to surface re-login deadlines, and token
|
||||
// refreshes only ever merge over this credential without clearing it.
|
||||
const newCredential: OAuthCredential = { type: "oauth", ...result, authorizedAt: Date.now() };
|
||||
// Use #upsertOAuthCredential to upsert the new credential.
|
||||
// Any legacy api_key rows from older versions will be cleaned up so they do not
|
||||
// shadow the new OAuth row, while preserving other active OAuth credentials.
|
||||
@@ -2927,6 +2967,9 @@ export class AuthStorage {
|
||||
apiEndpoint: next.apiEndpoint,
|
||||
orgId: next.orgId ?? entry.credential.orgId,
|
||||
orgName: next.orgName ?? entry.credential.orgName,
|
||||
// Not part of UsageCredential — carried from the stored row so the
|
||||
// interactive-login anchor survives usage-path refresh persists.
|
||||
authorizedAt: entry.credential.authorizedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4933,6 +4976,7 @@ export class AuthStorage {
|
||||
apiEndpoint: result.newCredentials.apiEndpoint ?? selection.credential.apiEndpoint,
|
||||
orgId: result.newCredentials.orgId ?? selection.credential.orgId,
|
||||
orgName: result.newCredentials.orgName ?? selection.credential.orgName,
|
||||
authorizedAt: result.newCredentials.authorizedAt ?? selection.credential.authorizedAt,
|
||||
};
|
||||
if (credentialId !== undefined) {
|
||||
const idx = this.#replaceCredentialById(provider, credentialId, updated);
|
||||
@@ -5892,6 +5936,28 @@ export class AuthStorage {
|
||||
return { generation: this.#generation, generatedAt: Date.now(), credentials: entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Disabled credential tombstones for display surfaces (`omp usage`,
|
||||
* broker `GET /v1/credentials/disabled`). Empty when the backing store
|
||||
* keeps no tombstones or the remote broker predates the endpoint.
|
||||
*/
|
||||
async listDisabledCredentials(provider?: string, signal?: AbortSignal): Promise<DisabledCredentialSummary[]> {
|
||||
if (!this.#store.listDisabledCredentials) return [];
|
||||
return this.#store.listDisabledCredentials(provider, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the backing store to revalidate its credential snapshot, then
|
||||
* reload. Remote broker stores re-fetch the snapshot; local stores are
|
||||
* always current, so only the reload runs. Callers that pair live
|
||||
* per-credential data with stored identities (`omp usage`) use this so a
|
||||
* disk-cached snapshot cannot misattribute fresh reports.
|
||||
*/
|
||||
async revalidateCredentials(): Promise<void> {
|
||||
if (this.#store.refreshSnapshot) await this.#store.refreshSnapshot();
|
||||
await this.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the OAuth credential with the given id through a per-credential
|
||||
* single-flight. Concurrent callers for the same row await the same upstream
|
||||
@@ -5982,6 +6048,7 @@ export class AuthStorage {
|
||||
apiEndpoint: refreshed.apiEndpoint ?? attempted.apiEndpoint,
|
||||
orgId: refreshed.orgId ?? attempted.orgId,
|
||||
orgName: refreshed.orgName ?? attempted.orgName,
|
||||
authorizedAt: refreshed.authorizedAt ?? attempted.authorizedAt,
|
||||
};
|
||||
// Persist by id: the array may have been reordered/shrunk while the
|
||||
// refresh was in flight, so the pre-await positional index is unsafe. A
|
||||
@@ -6152,6 +6219,9 @@ type AuthRow = {
|
||||
identity_key: string | null;
|
||||
};
|
||||
|
||||
/** {@link AuthRow} plus `updated_at` — disabled-tombstone queries surface when the row was torn down. */
|
||||
type DisabledAuthRow = AuthRow & { updated_at: number | null };
|
||||
|
||||
type CredentialBlockRow = {
|
||||
credential_id: number;
|
||||
provider_key: string;
|
||||
@@ -6426,6 +6496,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
#db: Database;
|
||||
#listActiveStmt: Statement;
|
||||
#listActiveByProviderStmt: Statement;
|
||||
#listDisabledStmt: Statement;
|
||||
#listDisabledByProviderStmt: Statement;
|
||||
#insertStmt: Statement;
|
||||
#updateStmt: Statement;
|
||||
@@ -6469,8 +6540,11 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
this.#listActiveByProviderStmt = this.#db.prepare(
|
||||
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
|
||||
);
|
||||
this.#listDisabledStmt = this.#db.prepare(
|
||||
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE disabled_cause IS NOT NULL ORDER BY id ASC",
|
||||
);
|
||||
this.#listDisabledByProviderStmt = this.#db.prepare(
|
||||
"SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
|
||||
"SELECT id, provider, credential_type, data, disabled_cause, identity_key, updated_at FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
|
||||
);
|
||||
this.#insertStmt = this.#db.prepare(
|
||||
`INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`,
|
||||
@@ -6979,6 +7053,34 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
return results;
|
||||
}
|
||||
|
||||
async listDisabledCredentials(provider?: string): Promise<DisabledCredentialSummary[]> {
|
||||
const rows =
|
||||
(provider
|
||||
? (this.#listDisabledByProviderStmt.all(provider) as DisabledAuthRow[])
|
||||
: (this.#listDisabledStmt.all() as DisabledAuthRow[])) ?? [];
|
||||
const results: DisabledCredentialSummary[] = [];
|
||||
for (const row of rows) {
|
||||
const credential = deserializeCredential(row);
|
||||
const summary: DisabledCredentialSummary = {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
type: row.credential_type === "api_key" ? "api_key" : "oauth",
|
||||
cause: row.disabled_cause ?? "disabled",
|
||||
};
|
||||
if (credential?.type === "oauth") {
|
||||
if (credential.email) summary.email = credential.email;
|
||||
if (credential.accountId) summary.accountId = credential.accountId;
|
||||
if (credential.orgId) summary.orgId = credential.orgId;
|
||||
if (credential.orgName) summary.orgName = credential.orgName;
|
||||
}
|
||||
if (typeof row.updated_at === "number" && Number.isFinite(row.updated_at)) {
|
||||
summary.disabledAtMs = row.updated_at * 1000;
|
||||
}
|
||||
results.push(summary);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
|
||||
const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => {
|
||||
const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
|
||||
@@ -7651,6 +7753,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
this.#closed = true;
|
||||
this.#listActiveStmt.finalize();
|
||||
this.#listActiveByProviderStmt.finalize();
|
||||
this.#listDisabledStmt.finalize();
|
||||
this.#listDisabledByProviderStmt.finalize();
|
||||
this.#insertStmt.finalize();
|
||||
this.#updateStmt.finalize();
|
||||
|
||||
@@ -24,6 +24,19 @@ const CALLBACK_PATH = "/callback";
|
||||
const SCOPES =
|
||||
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
||||
|
||||
/**
|
||||
* Absolute lifetime of an Anthropic OAuth grant family, anchored at the
|
||||
* interactive login. Refresh-token rotation does NOT extend it: ~30 days
|
||||
* after authorization the token endpoint returns
|
||||
* `invalid_grant: "Refresh token expired"` for the latest rotated token and
|
||||
* only a fresh interactive login recovers the account. Observed against
|
||||
* production (grants authorized 2026-06-20/06-25 died 30d later to the hour
|
||||
* despite healthy 8h rotations); matches Claude Code's documented monthly
|
||||
* re-login. Consumers use this to warn before the deadline — it is a display
|
||||
* heuristic, not a wire contract.
|
||||
*/
|
||||
export const ANTHROPIC_OAUTH_GRANT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function formatErrorDetails(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const details: string[] = [`${error.name}: ${error.message}`];
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
OAuthProviderInterface,
|
||||
} from "./types";
|
||||
|
||||
export * from "./anthropic";
|
||||
export * from "./device-code";
|
||||
export type * from "./types";
|
||||
|
||||
|
||||
@@ -19,6 +19,14 @@ export type OAuthCredentials = {
|
||||
orgId?: string;
|
||||
/** Human-readable organization name for display (may embed the email). */
|
||||
orgName?: string;
|
||||
/**
|
||||
* Epoch ms of the interactive login that minted this grant. Set by
|
||||
* `AuthStorage.login`; token refreshes preserve it. Providers with an
|
||||
* absolute grant lifetime (Anthropic expires the whole refresh-token
|
||||
* family ~30 days after authorization regardless of rotation) use it to
|
||||
* surface re-login deadlines before the grant dies.
|
||||
*/
|
||||
authorizedAt?: number;
|
||||
};
|
||||
|
||||
export type OAuthProvider = OAuthProviderUnion;
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
AuthStorage,
|
||||
type OAuthCredential,
|
||||
registerOAuthProvider,
|
||||
SqliteAuthCredentialStore,
|
||||
unregisterOAuthProviders,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import {
|
||||
AuthBrokerClient,
|
||||
type AuthBrokerServerHandle,
|
||||
RemoteAuthCredentialStore,
|
||||
startAuthBroker,
|
||||
} from "@oh-my-pi/pi-ai/auth-broker";
|
||||
import { removeWithRetries } from "../../utils/src/temp";
|
||||
|
||||
const DISABLE_CAUSE =
|
||||
'oauth refresh failed: OAuthError: Anthropic token refresh request failed. url=https://api.anthropic.com/v1/oauth/token; body={"error": "invalid_grant", "error_description": "Refresh token expired"}';
|
||||
|
||||
function mintOAuth(email: string): OAuthCredential {
|
||||
return {
|
||||
type: "oauth",
|
||||
access: `access-${email}`,
|
||||
refresh: `refresh-${email}`,
|
||||
expires: Date.now() + 60_000,
|
||||
email,
|
||||
accountId: `account-${email}`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("disabled credential tombstones", () => {
|
||||
let tempDir = "";
|
||||
let store: SqliteAuthCredentialStore | undefined;
|
||||
let storage: AuthStorage | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auth-disabled-creds-"));
|
||||
store = await SqliteAuthCredentialStore.open(path.join(tempDir, "agent.db"));
|
||||
storage = new AuthStorage(store);
|
||||
await storage.reload();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
storage?.close();
|
||||
await removeWithRetries(tempDir);
|
||||
});
|
||||
|
||||
test("sqlite store lists identity + cause + disabledAtMs and never token material", async () => {
|
||||
store!.saveOAuth("anthropic", mintOAuth("dead@example.test"));
|
||||
store!.saveOAuth("openai-codex", mintOAuth("alive@example.test"));
|
||||
const row = store!.listAuthCredentials("anthropic")[0];
|
||||
store!.deleteAuthCredential(row.id, DISABLE_CAUSE);
|
||||
|
||||
const all = await storage!.listDisabledCredentials();
|
||||
expect(all).toHaveLength(1);
|
||||
const summary = all[0];
|
||||
expect(summary).toMatchObject({
|
||||
id: row.id,
|
||||
provider: "anthropic",
|
||||
type: "oauth",
|
||||
email: "dead@example.test",
|
||||
accountId: "account-dead@example.test",
|
||||
cause: DISABLE_CAUSE,
|
||||
});
|
||||
expect(typeof summary.disabledAtMs).toBe("number");
|
||||
// Tombstones are display-only: no token bytes may leak through them.
|
||||
const serialized = JSON.stringify(summary);
|
||||
expect(serialized).not.toContain("access-dead");
|
||||
expect(serialized).not.toContain("refresh-dead");
|
||||
|
||||
// Provider filter is exact; a provider with only active rows yields [].
|
||||
expect(await storage!.listDisabledCredentials("anthropic")).toHaveLength(1);
|
||||
expect(await storage!.listDisabledCredentials("openai-codex")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("client maps a broker without the endpoint (404) to an empty list", async () => {
|
||||
const fetchImpl: typeof fetch = Object.assign(async () => new Response("not found", { status: 404 }), {
|
||||
preconnect: fetch.preconnect,
|
||||
});
|
||||
const client = new AuthBrokerClient({ url: "http://127.0.0.1:9", token: "unused", fetchImpl });
|
||||
expect(await client.listDisabledCredentials()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("broker /v1/credentials/disabled round-trip", () => {
|
||||
let tempDir = "";
|
||||
let serverStore: SqliteAuthCredentialStore | undefined;
|
||||
let serverStorage: AuthStorage | undefined;
|
||||
let handle: AuthBrokerServerHandle | undefined;
|
||||
let clientStorage: AuthStorage | undefined;
|
||||
const token = "disabled-creds-bearer";
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auth-broker-disabled-"));
|
||||
serverStore = await SqliteAuthCredentialStore.open(path.join(tempDir, "broker.db"));
|
||||
serverStorage = new AuthStorage(serverStore);
|
||||
await serverStorage.reload();
|
||||
handle = startAuthBroker({
|
||||
storage: serverStorage,
|
||||
bind: "127.0.0.1:0",
|
||||
bearerTokens: [token],
|
||||
disableRefresher: true,
|
||||
});
|
||||
clientStorage = new AuthStorage(
|
||||
new RemoteAuthCredentialStore({
|
||||
client: new AuthBrokerClient({ url: handle.url, token }),
|
||||
streamSnapshots: false,
|
||||
}),
|
||||
);
|
||||
await clientStorage.reload();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
clientStorage?.close();
|
||||
await handle?.close();
|
||||
serverStorage?.close();
|
||||
await removeWithRetries(tempDir);
|
||||
});
|
||||
|
||||
test("a row disabled on the broker surfaces to remote clients as a tombstone", async () => {
|
||||
serverStore!.saveOAuth("anthropic", mintOAuth("gone@example.test"));
|
||||
const row = serverStore!.listAuthCredentials("anthropic")[0];
|
||||
serverStore!.deleteAuthCredential(row.id, DISABLE_CAUSE);
|
||||
|
||||
const disabled = await clientStorage!.listDisabledCredentials("anthropic");
|
||||
expect(disabled).toHaveLength(1);
|
||||
expect(disabled[0]).toMatchObject({
|
||||
id: row.id,
|
||||
provider: "anthropic",
|
||||
type: "oauth",
|
||||
email: "gone@example.test",
|
||||
cause: DISABLE_CAUSE,
|
||||
});
|
||||
expect(JSON.stringify(disabled[0])).not.toContain("refresh-gone");
|
||||
});
|
||||
|
||||
test("revalidateCredentials re-hydrates broker-side identity changes past a stale snapshot", async () => {
|
||||
// Client connected before this credential existed (e.g. a re-login that
|
||||
// swapped an org-less row for an org-scoped one while a disk-cached
|
||||
// snapshot was still fresh).
|
||||
serverStore!.saveOAuth("anthropic", { ...mintOAuth("late@example.test"), orgId: "org-late" });
|
||||
await clientStorage!.revalidateCredentials();
|
||||
const rows = clientStorage!.getAll().anthropic;
|
||||
const list = Array.isArray(rows) ? rows : [rows];
|
||||
const late = list.find(entry => entry?.type === "oauth" && entry.email === "late@example.test");
|
||||
if (late?.type !== "oauth") throw new Error("expected refreshed oauth credential");
|
||||
expect(late.orgId).toBe("org-late");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth login stamps authorizedAt", () => {
|
||||
const PROVIDER_ID = "test-authorized-at-oauth";
|
||||
let tempDir = "";
|
||||
let store: SqliteAuthCredentialStore | undefined;
|
||||
let storage: AuthStorage | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auth-authorized-at-"));
|
||||
store = await SqliteAuthCredentialStore.open(path.join(tempDir, "agent.db"));
|
||||
storage = new AuthStorage(store);
|
||||
await storage.reload();
|
||||
registerOAuthProvider({
|
||||
id: PROVIDER_ID,
|
||||
name: "AuthorizedAt Test",
|
||||
sourceId: "authorized-at-test",
|
||||
login: async () => ({
|
||||
refresh: "refresh-initial",
|
||||
access: "access-initial",
|
||||
expires: Date.now() + 60_000,
|
||||
email: "stamped@example.test",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
unregisterOAuthProviders("authorized-at-test");
|
||||
storage?.close();
|
||||
await removeWithRetries(tempDir);
|
||||
});
|
||||
|
||||
test("login records the interactive-login instant; refresh persists keep it while rotating tokens", async () => {
|
||||
const before = Date.now();
|
||||
await storage!.login(PROVIDER_ID, {
|
||||
onAuth: () => {},
|
||||
onPrompt: async () => "",
|
||||
});
|
||||
const stored = store!.listAuthCredentials(PROVIDER_ID)[0];
|
||||
if (stored.credential.type !== "oauth") throw new Error("expected oauth credential");
|
||||
const authorizedAt = stored.credential.authorizedAt;
|
||||
expect(typeof authorizedAt).toBe("number");
|
||||
expect(authorizedAt!).toBeGreaterThanOrEqual(before);
|
||||
expect(authorizedAt!).toBeLessThanOrEqual(Date.now());
|
||||
|
||||
// Refresh rotates tokens but must not touch the login anchor — the
|
||||
// rebuild in refreshCredentialById previously dropped unknown fields.
|
||||
const refreshingStorage = new AuthStorage(store!, {
|
||||
refreshOAuthCredential: async () => ({
|
||||
access: "access-rotated",
|
||||
refresh: "refresh-rotated",
|
||||
expires: Date.now() + 120_000,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
await refreshingStorage.reload();
|
||||
await refreshingStorage.forceRefreshCredentialById(stored.id);
|
||||
const after = store!.listAuthCredentials(PROVIDER_ID)[0];
|
||||
if (after.credential.type !== "oauth") throw new Error("expected oauth credential");
|
||||
expect(after.credential.refresh).toBe("refresh-rotated");
|
||||
expect(after.credential.authorizedAt).toBe(authorizedAt);
|
||||
} finally {
|
||||
refreshingStorage.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- `omp usage` now surfaces auto-disabled credentials as red `✗` tombstone rows (identity, how long ago, the shortened upstream cause — e.g. `Refresh token expired` — and a re-login hint), including a provider section when no active credential remains. User-driven tombstones (`replaced by newer credential`, `deleted by user`) and API-key rows stay hidden. Requires a broker with `GET /v1/credentials/disabled`; older brokers degrade to no tombstone rows.
|
||||
- `omp usage` warns about Anthropic's ~30-day OAuth grant lifetime: accounts whose interactive login (`authorizedAt`) is within a week of the deadline get a yellow `⚠ re-login within <time>` line, and past-deadline accounts a red one. Grants die server-side exactly ~30 days after login regardless of refresh rotation, so this is the only warning before the broker auto-disables the row.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the Docker `natives-builder` stage failing to build releases ≥ 17.1.1: the native audio stack added bindgen (miniaudio needs libclang) and a bundled-opus CMake build (needs cmake + make), none of which were installed in the slim builder image.
|
||||
- Fixed `omp usage` duplicating org-less legacy accounts as "no usage data" rows whenever any sibling report carried an organization (mixed pools of pre-org-capture rows and fresh org-scoped logins): an org-less account is now covered by its own org-less report, while org-attributed sibling reports still never count as its coverage.
|
||||
- `omp usage` revalidates the broker credential snapshot before rendering: live usage reports were previously paired with a disk-cached account list up to an hour old, so a just-completed re-login (org-less row upserted to org-scoped) rendered as a phantom duplicate until the cache expired.
|
||||
|
||||
## [17.1.3] - 2026-07-24
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
* always covers the full credential pool.
|
||||
*/
|
||||
import {
|
||||
ANTHROPIC_OAUTH_GRANT_TTL_MS,
|
||||
type AuthStorage,
|
||||
type DisabledCredentialSummary,
|
||||
resolveUsedFraction,
|
||||
type UsageHistoryEntry,
|
||||
type UsageLimit,
|
||||
@@ -44,6 +46,8 @@ export interface UsageAccountIdentity {
|
||||
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
||||
orgId?: string;
|
||||
orgName?: string;
|
||||
/** Epoch ms of the interactive login that minted the OAuth grant (see `OAuthCredentials.authorizedAt`). */
|
||||
authorizedAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +130,11 @@ function findDistinguishingInfix(value: string, peers: string[]): string | undef
|
||||
}
|
||||
|
||||
/** Every identity string the output could surface — input for {@link buildRedactionMap}. */
|
||||
function collectIdentityStrings(reports: UsageReport[], accounts: UsageAccountIdentity[]): string[] {
|
||||
function collectIdentityStrings(
|
||||
reports: UsageReport[],
|
||||
accounts: UsageAccountIdentity[],
|
||||
disabled: DisabledCredentialSummary[] = [],
|
||||
): string[] {
|
||||
const values: string[] = [];
|
||||
const add = (value: unknown): void => {
|
||||
if (typeof value === "string" && value) values.push(value);
|
||||
@@ -152,6 +160,12 @@ function collectIdentityStrings(reports: UsageReport[], accounts: UsageAccountId
|
||||
add(account.orgName);
|
||||
add(account.enterpriseUrl);
|
||||
}
|
||||
for (const summary of disabled) {
|
||||
add(summary.email);
|
||||
add(summary.accountId);
|
||||
add(summary.orgId);
|
||||
add(summary.orgName);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
@@ -312,13 +326,15 @@ export function collectUnreportedAccounts(
|
||||
// multi-subscription): two orgs share every other identifier, so an
|
||||
// org-scoped account is covered only by its own org's report, and an
|
||||
// org-less legacy account is never covered by an org-attributed sibling
|
||||
// report — its own fetch failing must surface as "no usage data". The
|
||||
// shared org is a GATE, not a match: two Team members share the org id
|
||||
// while drawing on per-user pools, so coverage also requires the
|
||||
// account's own base identity inside the same-org subset (an org-only
|
||||
// account, with no base identifiers, is covered by any same-org
|
||||
// report). The email/account fallback below applies only when both
|
||||
// sides are org-less.
|
||||
// report — its own fetch failing must surface as "no usage data". Its
|
||||
// own ORG-LESS report still covers it, though: a mixed pool (fresh
|
||||
// org-scoped logins beside pre-org-capture rows) must not duplicate
|
||||
// every legacy account. The shared org is a GATE, not a match: two Team
|
||||
// members share the org id while drawing on per-user pools, so coverage
|
||||
// also requires the account's own base identity inside the same-org
|
||||
// subset (an org-only account, with no base identifiers, is covered by
|
||||
// any same-org report). The email/account fallback below applies only
|
||||
// when both sides are org-less.
|
||||
const accountOrg = account.orgId?.toLowerCase();
|
||||
const ids = [account.email, account.accountId, account.projectId]
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
@@ -333,9 +349,15 @@ export function collectUnreportedAccounts(
|
||||
}
|
||||
}
|
||||
if (accountOrg || sawReportOrg) {
|
||||
if (!accountOrg || sameOrgReports.length === 0) return true;
|
||||
const candidates = accountOrg
|
||||
? sameOrgReports
|
||||
: providerReports.filter(report => {
|
||||
const metaOrg = report.metadata?.orgId;
|
||||
return !(typeof metaOrg === "string" && metaOrg);
|
||||
});
|
||||
if (candidates.length === 0) return true;
|
||||
if (ids.length === 0) return false;
|
||||
return !sameOrgReports.some(report => {
|
||||
return !candidates.some(report => {
|
||||
const identifiers = reportIdentifiers(report);
|
||||
return ids.some(id => identifiers.has(id));
|
||||
});
|
||||
@@ -509,6 +531,58 @@ export function computeProviderWindowStats(reports: UsageReport[]): ProviderWind
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-login warnings render once remaining grant life drops below this. */
|
||||
const RELOGIN_WARN_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Re-login deadline line for providers whose OAuth grants expire a fixed
|
||||
* period after the interactive login (today: Anthropic, ~30 days regardless
|
||||
* of refresh rotation). Silent until the deadline is under a week out — a
|
||||
* nudge before the broker auto-disables the row, not a permanent countdown.
|
||||
*/
|
||||
function formatReloginDeadline(
|
||||
account: UsageAccountIdentity,
|
||||
nowMs: number,
|
||||
redaction?: Map<string, string>,
|
||||
): string | undefined {
|
||||
if (account.provider !== "anthropic" || account.type !== "oauth" || !account.authorizedAt) return undefined;
|
||||
const remaining = account.authorizedAt + ANTHROPIC_OAUTH_GRANT_TTL_MS - nowMs;
|
||||
if (remaining > RELOGIN_WARN_WINDOW_MS) return undefined;
|
||||
const label = accountIdentityLabel(account, redaction);
|
||||
if (remaining <= 0) {
|
||||
return ` ${chalk.red(`⚠ ${label} — grant is past Anthropic's ~30d lifetime; re-login now`)}`;
|
||||
}
|
||||
return ` ${chalk.yellow(`⚠ ${label} — re-login within ${formatDuration(remaining)} (Anthropic expires OAuth grants ~30d after login)`)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tombstones worth a row in `omp usage`: OAuth credentials torn down
|
||||
* automatically (refresh failure, upstream invalidation). Rows the user
|
||||
* replaced or deleted deliberately are lifecycle noise, not lost capacity.
|
||||
*/
|
||||
function isActionableDisable(summary: DisabledCredentialSummary): boolean {
|
||||
if (summary.type !== "oauth") return false;
|
||||
return !/^(replaced by|deleted by user)/i.test(summary.cause);
|
||||
}
|
||||
|
||||
/** Human-sized disable cause: the upstream `error_description` when embedded, else the first clause. */
|
||||
function shortDisableCause(cause: string): string {
|
||||
const description = cause.match(/\\?"error_description\\?"\s*:\s*\\?"([^"\\]+)/)?.[1];
|
||||
if (description) return description;
|
||||
const stripped = cause.replace(/^oauth refresh failed:\s*/i, "");
|
||||
const clause = stripped.split(/[;\n]/, 1)[0] ?? stripped;
|
||||
return clause.length > 80 ? `${clause.slice(0, 77)}…` : clause;
|
||||
}
|
||||
|
||||
/** Label for a disabled tombstone, masking each identity part under `--redact`. */
|
||||
function disabledIdentityLabel(summary: DisabledCredentialSummary, redaction?: Map<string, string>): string {
|
||||
const base = summary.email ?? summary.accountId ?? "OAuth account";
|
||||
const masked = redaction?.get(base) ?? base;
|
||||
const org = summary.orgName ?? summary.orgId;
|
||||
if (!org || org === base) return masked;
|
||||
return `${masked} · ${redaction?.get(org) ?? org}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the full text breakdown: per provider, per account, every limit
|
||||
* with a bar, amounts, and reset times; unattributed credentials trail
|
||||
@@ -519,6 +593,7 @@ export function formatUsageBreakdown(
|
||||
accounts: UsageAccountIdentity[],
|
||||
nowMs: number,
|
||||
redaction?: Map<string, string>,
|
||||
disabled: DisabledCredentialSummary[] = [],
|
||||
): string {
|
||||
const reportsByProvider = new Map<string, UsageReport[]>();
|
||||
for (const report of reports) {
|
||||
@@ -533,10 +608,17 @@ export function formatUsageBreakdown(
|
||||
list.push(account);
|
||||
unreportedByProvider.set(account.provider, list);
|
||||
}
|
||||
const disabledByProvider = new Map<string, DisabledCredentialSummary[]>();
|
||||
for (const summary of disabled) {
|
||||
if (!isActionableDisable(summary)) continue;
|
||||
const list = disabledByProvider.get(summary.provider) ?? [];
|
||||
list.push(summary);
|
||||
disabledByProvider.set(summary.provider, list);
|
||||
}
|
||||
|
||||
const providers = [...new Set([...reportsByProvider.keys(), ...unreportedByProvider.keys()])].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
const providers = [
|
||||
...new Set([...reportsByProvider.keys(), ...unreportedByProvider.keys(), ...disabledByProvider.keys()]),
|
||||
].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const lines: string[] = [];
|
||||
const latestFetchedAt = Math.max(0, ...reports.map(report => report.fetchedAt ?? 0));
|
||||
@@ -582,6 +664,20 @@ export function formatUsageBreakdown(
|
||||
lines.push(` ${chalk.dim("○")} ${chalk.dim(`${label} — no usage data`)}`);
|
||||
}
|
||||
|
||||
for (const summary of disabledByProvider.get(provider) ?? []) {
|
||||
const label = disabledIdentityLabel(summary, redaction);
|
||||
const ago = summary.disabledAtMs !== undefined ? ` ${formatDuration(nowMs - summary.disabledAtMs)} ago` : "";
|
||||
lines.push(
|
||||
` ${chalk.red(`✗ ${label} — disabled${ago}: ${sanitizeText(shortDisableCause(summary.cause))}`)} ${chalk.dim("(re-login to restore)")}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const account of accounts) {
|
||||
if (account.provider !== provider) continue;
|
||||
const warning = formatReloginDeadline(account, nowMs, redaction);
|
||||
if (warning) lines.push(warning);
|
||||
}
|
||||
|
||||
const stats = computeProviderWindowStats(providerReports);
|
||||
if (stats.length > 0) {
|
||||
const parts = stats.map(
|
||||
@@ -750,6 +846,7 @@ function collectStoredAccounts(authStorage: AuthStorage): UsageAccountIdentity[]
|
||||
enterpriseUrl: credential.enterpriseUrl,
|
||||
orgId: credential.orgId,
|
||||
orgName: credential.orgName,
|
||||
authorizedAt: credential.authorizedAt,
|
||||
});
|
||||
} else {
|
||||
accounts.push({ provider, type: "api_key" });
|
||||
@@ -862,20 +959,41 @@ export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
|
||||
(await authStorage.fetchUsageReports({
|
||||
baseUrlResolver: provider => modelRegistry.getProviderBaseUrl(provider),
|
||||
})) ?? [];
|
||||
// Reports are always fresh (broker-side fetch) but the account list can
|
||||
// come from a disk-cached snapshot up to an hour old — revalidate so a
|
||||
// just-logged-in (or just-rotated-identity) credential isn't rendered
|
||||
// as a stale duplicate. Best-effort: offline broker keeps the cache.
|
||||
try {
|
||||
await authStorage.revalidateCredentials();
|
||||
} catch {
|
||||
// Stale identities beat no output.
|
||||
}
|
||||
const storedAccounts = collectStoredAccounts(authStorage);
|
||||
let accounts = selectReportableAccounts(
|
||||
storedAccounts,
|
||||
provider => authStorage.usageProviderFor(provider) !== undefined,
|
||||
cmd.provider,
|
||||
);
|
||||
// Tombstones ride alongside the live pool so an auto-disabled account
|
||||
// (e.g. an expired Anthropic grant) is loudly visible instead of just
|
||||
// missing. Best-effort: a broker predating the endpoint yields [].
|
||||
let disabled: DisabledCredentialSummary[] = [];
|
||||
try {
|
||||
disabled = await authStorage.listDisabledCredentials();
|
||||
} catch {
|
||||
// Usage output must not fail because tombstone listing did.
|
||||
}
|
||||
let filteredReports = reports;
|
||||
if (cmd.provider) {
|
||||
const wanted = cmd.provider.toLowerCase();
|
||||
filteredReports = reports.filter(report => report.provider.toLowerCase() === wanted);
|
||||
accounts = accounts.filter(account => account.provider.toLowerCase() === wanted);
|
||||
disabled = disabled.filter(summary => summary.provider.toLowerCase() === wanted);
|
||||
}
|
||||
|
||||
const redaction = cmd.redact ? buildRedactionMap(collectIdentityStrings(filteredReports, accounts)) : undefined;
|
||||
const redaction = cmd.redact
|
||||
? buildRedactionMap(collectIdentityStrings(filteredReports, accounts, disabled))
|
||||
: undefined;
|
||||
|
||||
if (cmd.json) {
|
||||
// Drop the heavy provider-specific `raw` payload — same shape as the
|
||||
@@ -900,10 +1018,21 @@ export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
|
||||
const stats = computeProviderWindowStats(filteredReports.filter(peer => peer.provider === report.provider));
|
||||
if (stats.length > 0) capacity[report.provider] = stats;
|
||||
}
|
||||
let disabledForJson = disabled.filter(isActionableDisable);
|
||||
if (redaction) {
|
||||
disabledForJson = disabledForJson.map(summary => ({
|
||||
...summary,
|
||||
email: maskIdentity(redaction, summary.email),
|
||||
accountId: maskIdentity(redaction, summary.accountId),
|
||||
orgId: maskIdentity(redaction, summary.orgId),
|
||||
orgName: maskIdentity(redaction, summary.orgName),
|
||||
}));
|
||||
}
|
||||
const payload = {
|
||||
generatedAt: Date.now(),
|
||||
reports: trimmed,
|
||||
accountsWithoutUsage: unreportedAccounts,
|
||||
disabledCredentials: disabledForJson,
|
||||
capacity,
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
@@ -923,7 +1052,7 @@ export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`${formatUsageBreakdown(filteredReports, accounts, Date.now(), redaction)}\n`);
|
||||
process.stdout.write(`${formatUsageBreakdown(filteredReports, accounts, Date.now(), redaction, disabled)}\n`);
|
||||
} finally {
|
||||
authStorage.close();
|
||||
}
|
||||
|
||||
@@ -191,6 +191,36 @@ describe("collectUnreportedAccounts", () => {
|
||||
// stays covered by any same-org report.
|
||||
expect(collectUnreportedAccounts([aliceReport], [alice, bob, orgOnly])).toEqual([bob]);
|
||||
});
|
||||
|
||||
it("keeps an org-less account covered by its own org-less report when org-scoped siblings exist", () => {
|
||||
// Live incident shape: legacy org-less rows (pre-org-capture logins)
|
||||
// beside fresh org-scoped logins. Every account fetched successfully —
|
||||
// nobody may be duplicated into a "no usage data" row.
|
||||
const legacy: UsageAccountIdentity = {
|
||||
provider: "anthropic",
|
||||
type: "oauth",
|
||||
email: "legacy@example.test",
|
||||
accountId: "account-legacy",
|
||||
};
|
||||
const fresh: UsageAccountIdentity = {
|
||||
provider: "anthropic",
|
||||
type: "oauth",
|
||||
email: "fresh@example.test",
|
||||
accountId: "account-fresh",
|
||||
orgId: "org-fresh",
|
||||
};
|
||||
const legacyReport = {
|
||||
...makeReport("anthropic", legacy.email!, []),
|
||||
metadata: { email: legacy.email, accountId: legacy.accountId },
|
||||
};
|
||||
const freshReport = {
|
||||
...makeReport("anthropic", fresh.email!, []),
|
||||
metadata: { email: fresh.email, accountId: fresh.accountId, orgId: "org-fresh" },
|
||||
};
|
||||
expect(collectUnreportedAccounts([legacyReport, freshReport], [legacy, fresh])).toEqual([]);
|
||||
// The org-attributed sibling alone still does NOT cover the legacy row.
|
||||
expect(collectUnreportedAccounts([freshReport], [legacy, fresh])).toEqual([legacy]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUsageBreakdown", () => {
|
||||
@@ -290,6 +320,78 @@ describe("formatUsageBreakdown", () => {
|
||||
for (const mask of redaction.values()) expect(text).toContain(mask);
|
||||
});
|
||||
|
||||
it("renders auto-disabled tombstones with the upstream error_description and hides lifecycle noise", () => {
|
||||
const now = Date.now();
|
||||
const disabled = [
|
||||
{
|
||||
id: 26,
|
||||
provider: "anthropic",
|
||||
type: "oauth" as const,
|
||||
email: "dead@example.test",
|
||||
cause: 'oauth refresh failed: OAuthError: refresh request failed; body={"error": "invalid_grant", "error_description": "Refresh token expired"}',
|
||||
disabledAtMs: now - 4 * HOUR,
|
||||
},
|
||||
{
|
||||
id: 27,
|
||||
provider: "anthropic",
|
||||
type: "oauth" as const,
|
||||
email: "rotated@example.test",
|
||||
cause: "replaced by newer credential",
|
||||
},
|
||||
{
|
||||
id: 28,
|
||||
provider: "fireworks",
|
||||
type: "api_key" as const,
|
||||
cause: "oauth refresh failed: whatever",
|
||||
},
|
||||
];
|
||||
const text = stripVTControlCharacters(formatUsageBreakdown(reports, accounts, now, undefined, disabled));
|
||||
// Auto-disabled OAuth row: identity, age, shortened upstream cause, and the fix.
|
||||
expect(text).toContain("✗ dead@example.test — disabled 4h ago: Refresh token expired (re-login to restore)");
|
||||
// User-driven replacement and api_key tombstones are lifecycle noise, not lost capacity.
|
||||
expect(text).not.toContain("rotated@example.test");
|
||||
expect(text).not.toContain("Fireworks");
|
||||
});
|
||||
|
||||
it("renders a tombstone-only provider section even when no active credential remains", () => {
|
||||
const disabled = [
|
||||
{
|
||||
id: 50,
|
||||
provider: "anthropic",
|
||||
type: "oauth" as const,
|
||||
email: "last@example.test",
|
||||
cause: "oauth refresh failed: token endpoint said no",
|
||||
},
|
||||
];
|
||||
const text = stripVTControlCharacters(formatUsageBreakdown([], [], Date.now(), undefined, disabled));
|
||||
expect(text).toContain("Anthropic");
|
||||
expect(text).toContain("✗ last@example.test — disabled: token endpoint said no (re-login to restore)");
|
||||
});
|
||||
|
||||
it("warns about Anthropic's ~30d grant lifetime only inside the final week", () => {
|
||||
const now = Date.now();
|
||||
const DAY = 24 * HOUR;
|
||||
const withAge = (email: string, ageDays: number): UsageAccountIdentity => ({
|
||||
provider: "anthropic",
|
||||
type: "oauth",
|
||||
email,
|
||||
authorizedAt: now - ageDays * DAY,
|
||||
});
|
||||
const text = stripVTControlCharacters(
|
||||
formatUsageBreakdown(
|
||||
[],
|
||||
[withAge("fresh@example.test", 10), withAge("closing@example.test", 27), withAge("dead@example.test", 31)],
|
||||
now,
|
||||
),
|
||||
);
|
||||
// 10d-old grant: no countdown noise.
|
||||
expect(text).not.toContain("fresh@example.test — re-login");
|
||||
// 27d-old grant: 3 days left.
|
||||
expect(text).toContain("⚠ closing@example.test — re-login within 3d");
|
||||
// Past the lifetime: hard warning.
|
||||
expect(text).toContain("⚠ dead@example.test — grant is past Anthropic's ~30d lifetime; re-login now");
|
||||
});
|
||||
|
||||
it("renders provider-level notes once per provider, not duplicated per account or limit", () => {
|
||||
const disclaimer = "OMP-observed spend only; OpenCode usage outside OMP is not included.";
|
||||
const multiAccount = [
|
||||
|
||||
Reference in New Issue
Block a user