@byokit/usage 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.0 (2026-10-01)
6
+
7
+
8
+
9
+ - SECURITY: Claude subscription usage may read credentials only from app-managed folders under the passed stateDir, without refresh or credential writes; default logins and folder escapes are refused, tokens stay in one request and never enter output, errors, logs or stored readings.
10
+ - Share the bounded Codex app-server client between identity and subscription usage reads.
11
+ - Apply shared poll health, scoped quota and hard-limit semantics to managed Claude usage, including cancellable host pacing.
12
+
5
13
  ## 0.3.0 (2026-10-01)
6
14
 
7
15
 
package/README.md CHANGED
@@ -47,6 +47,18 @@ original observation time. Poll 429 (`rate-limited`), host renewal failure
47
47
  (`refresh-failed`) and credential refusals are distinct; usage never changes
48
48
  account health or renews credentials.
49
49
 
50
+ The managed-folder source is `{ provider: 'claude', folder, headers: { 'anthropic-beta', 'User-Agent' } }`. Paths must be absolute; removed or empty keys mean disconnected. Claude headers are app-passed. The Claude folder must resolve inside the reader's `stateDir` as `<stateDir>/claude/<hex>`, matching managed CLI account folders. Default `.claude` roots, paths outside the root, and symlinked folders or credential files are refused. Use the managed plans root as the usage reader's stateDir; its quota store coexists with the account roster. The default login's usage adapter remains with the host.
51
+
52
+ Approved exception: the `./cli` entry reads and runs only app-managed per-account folders under `stateDir` and the absolute CLI binaries the app passes; it never touches the person's default login; tokens never leave the device and are never logged. Under the same managed-folder boundary, usage may read `.credentials.json` only for a single Claude subscription usage request. It does not refresh, write, rename or copy credentials; expired or malformed credentials return `expired` or `not-connected`, requiring sign-in again. The token is held in memory only for that request and never enters readings, stored quotas, errors or logs. Folder and credential metadata supply the cache fingerprint without loading a token, and credential changes invalidate the cached account reading.
53
+
54
+ `read(source, { nowMs? })` returns `{ provider, windows, at, code? }`; `at` and all
55
+ `resetsAt` fields are **epoch milliseconds** in 0.2.0. This changes 0.1.0's seconds
56
+ reset convention. Windows include kind, used percent, optional duration in minutes,
57
+ reset time, limit label and limited flag. Parsers are exported for host integrations:
58
+ `claudeWindows`, `codexWindows` (app-server), `codexTokenWindows`, `goWindows`,
59
+ `zaiWindows`, `copilotWindows`, `grokWindows`, `minimaxWindows`, `geminiWindows`,
60
+ `kimiWindows(raw, nowMs)`.
61
+
50
62
  Windows include kind, optional reported `usedPercent`, duration, reset, limit,
51
63
  `limited` and `scope: { model?, surface? }`. Missing usage is unknown, never zero.
52
64
  Claude `limits[]` session/weekly-all rows override corresponding legacy aggregates,
@@ -197,6 +209,8 @@ and HOME explicitly when needed. No tokens in logs, errors, readings or public h
197
209
  no credential write-back, telemetry, automatic refresh or reset-credit spend.
198
210
 
199
211
  Built-in requests send `User-Agent: byokit/usage/0.3.0`, never another app's identity.
212
+
213
+ Token and explicit-file sources send `User-Agent: byokit/usage/0.2.0`, never another app's identity.
200
214
  A refusal returns a code; with no last-good quota, room is unknown. Fixed endpoints
201
215
  are Anthropic `api/oauth/usage`, ChatGPT `backend-api/wham/usage`, GitHub
202
216
  `copilot_internal/user`, Grok `v1/billing` (weekly credits then monthly when needed),
@@ -269,3 +283,7 @@ When passing normalized windows to `@byokit/accounts`' structural helper, use
269
283
  legacy reset seconds; normalized usage windows in 0.2.0+ already use milliseconds.
270
284
  Alternatively, this package's `roomOf(reading, nowMs)` returns a structural `Room`
271
285
  that the accounts chooser accepts directly. Preserve the original measurement time.
286
+
287
+ `identity(codexSource)` shares the app-server transport, calls `account/read` with a 15-second deadline, never opens a credential file, and returns only `{signedIn,email?,plan?}`. Managed-folder HTTP usage carries only the app-passed headers plus Bearer authorization and JSON accept; it uses the same bounded HTTP transport.
288
+
289
+ Managed-folder Claude usage uses the shared poll-health and normalized quota pipeline, including scoped hard blocks, unknown usage, last-good observation times, account retry policies and cancellable host origin pacing.
@@ -0,0 +1,12 @@
1
+ import { providerHttp, type Answer } from './providers.ts';
2
+ import type { Source } from './types.ts';
3
+ type ClaudeSource = Extract<Source, {
4
+ folder: string;
5
+ }>;
6
+ /** No ambient HOME discovery. Both the lexical and real folder must be managed by this root. */
7
+ export declare function managedClaudeFolder(folder: string, stateDir: string): boolean;
8
+ /** Metadata only, so account/cache probes never load a token. */
9
+ export declare function claudeCredential(folder: string): import("fs").Stats | undefined;
10
+ /** The credential is held only for this request; no refresh, writes or recovery sidecars. */
11
+ export declare function claudeUsage(source: ClaudeSource, fetcher: typeof fetch, nowMs: number, pacing?: Parameters<typeof providerHttp>[5]): Promise<Answer>;
12
+ export {};
package/dist/claude.js ADDED
@@ -0,0 +1,47 @@
1
+ import { lstatSync, realpathSync } from 'node:fs';
2
+ import { join, resolve, sep } from 'node:path';
3
+ import { readJson } from "./store.js";
4
+ import { record } from "./windows.js";
5
+ import { providerHttp } from "./providers.js";
6
+ /** No ambient HOME discovery. Both the lexical and real folder must be managed by this root. */
7
+ export function managedClaudeFolder(folder, stateDir) {
8
+ const root = resolve(stateDir);
9
+ const candidate = resolve(folder);
10
+ if (root.split(sep).some((part) => ['.claude', '.codex', '.pi'].includes(part)))
11
+ return false;
12
+ const parent = join(root, 'claude');
13
+ if (!candidate.startsWith(parent + sep) || !/^[a-f0-9]+$/.test(candidate.slice(parent.length + 1)))
14
+ return false;
15
+ try {
16
+ if (![root, parent, candidate].every((path) => { const s = lstatSync(path); return s.isDirectory() && !s.isSymbolicLink(); }))
17
+ return false;
18
+ const realRoot = realpathSync(root);
19
+ if (realRoot.split(sep).some((part) => ['.claude', '.codex', '.pi'].includes(part)))
20
+ return false;
21
+ const realFolder = realpathSync(candidate);
22
+ return realFolder.startsWith(join(realRoot, 'claude') + sep);
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ /** Metadata only, so account/cache probes never load a token. */
29
+ export function claudeCredential(folder) {
30
+ try {
31
+ const stat = lstatSync(join(folder, '.credentials.json'));
32
+ return stat.isFile() && !stat.isSymbolicLink() && stat.size <= 64 * 1024 ? stat : undefined;
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ /** The credential is held only for this request; no refresh, writes or recovery sidecars. */
39
+ export async function claudeUsage(source, fetcher, nowMs, pacing) {
40
+ const raw = readJson(join(source.folder, '.credentials.json'), 64 * 1024);
41
+ const oauth = record(raw) && record(raw.claudeAiOauth) ? raw.claudeAiOauth : undefined;
42
+ if (!oauth || typeof oauth.accessToken !== 'string' || !oauth.accessToken.trim() || oauth.accessToken.length > 16384 || /[\x00-\x20\x7f]/.test(oauth.accessToken))
43
+ return { code: 'not-connected' };
44
+ if (typeof oauth.expiresAt !== 'number' || !Number.isFinite(oauth.expiresAt) || oauth.expiresAt <= nowMs)
45
+ return { code: 'expired' };
46
+ return providerHttp('https://api.anthropic.com/api/oauth/usage', oauth.accessToken, fetcher, nowMs, { headers: { 'anthropic-beta': source.headers['anthropic-beta'], 'User-Agent': source.headers['User-Agent'] } }, pacing);
47
+ }
@@ -0,0 +1,11 @@
1
+ import type { Source } from './types.ts';
2
+ export type Identity = {
3
+ signedIn: boolean;
4
+ email?: string;
5
+ plan?: string;
6
+ };
7
+ /** Explicit identity fields only: never walk arbitrary token-bearing objects. */
8
+ export declare function publicIdentity(raw: unknown, signedIn: boolean): Identity;
9
+ export declare function codexIdentity(source: Extract<Source, {
10
+ bin: string;
11
+ }>): Promise<Identity>;
@@ -0,0 +1,16 @@
1
+ import { codexRequest } from "./providers.js";
2
+ import { record } from "./windows.js";
3
+ /** Explicit identity fields only: never walk arbitrary token-bearing objects. */
4
+ export function publicIdentity(raw, signedIn) {
5
+ if (!signedIn || !record(raw))
6
+ return { signedIn: false };
7
+ const short = (value) => typeof value === 'string' && value.length > 0 && value.length <= 320 && !/[\x00-\x1f\x7f]/.test(value);
8
+ const email = short(raw.email) && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw.email) ? raw.email : undefined;
9
+ const plan = [raw.planType, raw.subscriptionType, raw.plan, raw.tier, raw.planName].find((value) => short(value) && /^[a-zA-Z][a-zA-Z0-9 _+-]{0,63}$/.test(value));
10
+ return { signedIn: true, ...(email === undefined ? {} : { email }), ...(typeof plan === 'string' ? { plan } : {}) };
11
+ }
12
+ export async function codexIdentity(source) {
13
+ const answer = await codexRequest(source, 'account/read');
14
+ const account = record(answer.raw) ? answer.raw.account : undefined;
15
+ return publicIdentity(account, record(account));
16
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,11 @@
1
- import { type Usage, type UsageOptions } from './types.ts';
1
+ import { type Source, type Usage, type UsageOptions } from './types.ts';
2
+ import { type Identity } from './identity.ts';
2
3
  export * from './types.ts';
4
+ export type { Identity } from './identity.ts';
5
+ /** Identity runs only the named binary, never opens a credential file. */
6
+ export declare function identity(source: Extract<Source, {
7
+ bin: string;
8
+ }>): Promise<Identity>;
3
9
  export { callLedger, normalizeTokens, priceCall, type CallLedger, type CallInput, type CallRecord, type CallQuery, type NormalizedTokens, type ModelPrice, type PriceTable, type CallCost } from './calls.ts';
4
10
  export { tokenLedger, memoryTokenLedgerStore, TokenLedgerError, type TokenLedger, type TokenLedgerStore, type TokenLedgerOptions, type TokenEntry, type TokenQuery } from './ledger.ts';
5
11
  export { roomOf } from './room.ts';
package/dist/index.js CHANGED
@@ -6,7 +6,16 @@ import { fingerprint, readJson, store, memoryUsageStore, safeWindows, safePoll }
6
6
  import { claudeWindows, codexWindows, goWindows, record, zaiWindows } from "./windows.js";
7
7
  import { codexHardLimit, codexTokenWindows, copilotWindows, grokWindows, minimaxWindows, geminiWindows, kimiWindows } from "./quota.js";
8
8
  import { UsageError } from "./types.js";
9
+ import { codexIdentity } from "./identity.js";
10
+ import { claudeUsage as managedClaudeUsage, claudeCredential, managedClaudeFolder } from "./claude.js";
9
11
  export * from "./types.js";
12
+ /** Identity runs only the named binary, never opens a credential file. */
13
+ export async function identity(source) {
14
+ if (!record(source) || !('bin' in source) || 'folder' in source || 'credentialsFile' in source || 'read' in source)
15
+ throw new UsageError();
16
+ validate(source);
17
+ return codexIdentity(source);
18
+ }
10
19
  export { callLedger, normalizeTokens, priceCall } from "./calls.js";
11
20
  export { tokenLedger, memoryTokenLedgerStore, TokenLedgerError } from "./ledger.js";
12
21
  export { roomOf } from "./room.js";
@@ -17,10 +26,16 @@ export { codexHardLimit, codexTokenWindows, copilotWindows, grokWindows, minimax
17
26
  export { WORDS, words, usageWords } from "./words.js";
18
27
  const providers = ['claude', 'codex', 'opencode', 'zai', 'copilot', 'grok', 'minimax', 'gemini', 'kimi'];
19
28
  const validText = (v) => typeof v === 'string' && !v.includes('\0') && !/[\r\n]/.test(v) && v.length <= 16384;
20
- function validate(source) {
29
+ function validate(source, stateDir) {
21
30
  if (!record(source) || !providers.includes(source.provider))
22
31
  throw new UsageError();
23
- if ('credentialsFile' in source) {
32
+ if ('folder' in source) {
33
+ if (source.provider !== 'claude' || !validText(source.folder) || !isAbsolute(source.folder) || !stateDir || !managedClaudeFolder(source.folder, stateDir))
34
+ throw new UsageError();
35
+ if (!record(source.headers) || !['anthropic-beta', 'User-Agent'].every((key) => validText(source.headers[key]) && source.headers[key].length <= 1024))
36
+ throw new UsageError();
37
+ }
38
+ else if ('credentialsFile' in source) {
24
39
  if (source.provider !== 'claude' || ![source.credentialsFile, ...[source.configFile, source.statuslineFile].filter((v) => v !== undefined)].every((v) => validText(v) && isAbsolute(v)))
25
40
  throw new UsageError();
26
41
  }
@@ -84,7 +99,9 @@ export function usage(options) {
84
99
  const failures = new Map();
85
100
  const now = (opts) => opts?.nowMs ?? (options.now ?? Date.now)();
86
101
  function connected(source) {
87
- validate(source);
102
+ validate(source, options.stateDir);
103
+ if ('folder' in source)
104
+ return claudeCredential(source.folder) !== undefined;
88
105
  if ('credentialsFile' in source)
89
106
  return claudeAuth(source) !== undefined;
90
107
  if ('read' in source) {
@@ -107,9 +124,13 @@ export function usage(options) {
107
124
  return ('access' in source ? source.access : source.key).trim() !== '';
108
125
  }
109
126
  function account(source) {
110
- validate(source);
127
+ validate(source, options.stateDir);
111
128
  let id;
112
- if ('credentialsFile' in source)
129
+ if ('folder' in source) {
130
+ const stat = claudeCredential(source.folder);
131
+ id = stat ? `${source.folder}\0${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}` : undefined;
132
+ }
133
+ else if ('credentialsFile' in source)
113
134
  id = claudeAuth(source)?.account;
114
135
  else if ('read' in source)
115
136
  id = source.accountUuid;
@@ -164,7 +185,7 @@ export function usage(options) {
164
185
  }
165
186
  }
166
187
  async function read(source, opts) {
167
- validate(source);
188
+ validate(source, options.stateDir);
168
189
  const clock = now(opts);
169
190
  const empty = (code) => ({ provider: source.provider, windows: [], code, poll: { at: clock, outcome: code ?? 'unavailable' } });
170
191
  if (!connected(source))
@@ -202,7 +223,7 @@ export function usage(options) {
202
223
  let answer;
203
224
  const pacing = { hook: options.pace, provider: source.provider, account: id.key, signal: opts?.signal };
204
225
  try {
205
- answer = 'read' in source ? await customClaude(source, clock, pacing) : 'credentialsFile' in source ? await claudeUsage(source, options.fetch ?? globalThis.fetch, clock, pacing)
226
+ answer = 'folder' in source ? await managedClaudeUsage(source, options.fetch ?? globalThis.fetch, clock, pacing) : 'read' in source ? await customClaude(source, clock, pacing) : 'credentialsFile' in source ? await claudeUsage(source, options.fetch ?? globalThis.fetch, clock, pacing)
206
227
  : 'bin' in source ? await codexUsage(source) : await providerGet(source, options.fetch ?? globalThis.fetch, clock, pacing);
207
228
  }
208
229
  catch {
@@ -5,6 +5,16 @@ type TokenSource = Extract<Source, {
5
5
  } | {
6
6
  key: string;
7
7
  }>;
8
+ /** One bounded request; credentials and response bodies never become errors. */
9
+ export declare function providerHttp(url: string, key: string, fetcher: typeof fetch, nowMs: number, extra?: {
10
+ headers?: Record<string, string>;
11
+ body?: unknown;
12
+ }, pacing?: {
13
+ hook?: PacingHook;
14
+ provider: Source['provider'];
15
+ account: string;
16
+ signal?: AbortSignal;
17
+ }): Promise<Answer>;
8
18
  export declare function providerGet(source: TokenSource, fetcher: typeof fetch, nowMs: number, pacing?: {
9
19
  hook?: PacingHook;
10
20
  provider: Source['provider'];
@@ -22,6 +32,10 @@ export declare function customClaude(source: Extract<Source, {
22
32
  export declare function codexUsage(source: Extract<Source, {
23
33
  bin: string;
24
34
  }>): Promise<Answer>;
35
+ /** The single bounded app-server transport used for identity and usage. */
36
+ export declare function codexRequest(source: Extract<Source, {
37
+ bin: string;
38
+ }>, method: 'account/read' | 'account/rateLimits/read', timeoutMs?: number): Promise<Answer>;
25
39
  /** Re-read the tool's credentials so its own renewal is picked up; never renew or write them. */
26
40
  export declare function claudeAuth(source: Extract<Source, {
27
41
  credentialsFile: string;
package/dist/providers.js CHANGED
@@ -5,7 +5,7 @@ import { claudeWindows, record } from "./windows.js";
5
5
  import { grokWindows } from "./quota.js";
6
6
  const USER_AGENT = 'byokit/usage/0.2.0';
7
7
  /** One bounded request; credentials and response bodies never become errors. */
8
- async function request(url, key, fetcher, nowMs, extra = {}, pacing) {
8
+ export async function providerHttp(url, key, fetcher, nowMs, extra = {}, pacing) {
9
9
  const controller = new AbortController();
10
10
  const abort = () => controller.abort();
11
11
  pacing?.signal?.addEventListener('abort', abort, { once: true });
@@ -69,7 +69,7 @@ async function request(url, key, fetcher, nowMs, extra = {}, pacing) {
69
69
  }
70
70
  export async function providerGet(source, fetcher, nowMs, pacing) {
71
71
  const key = 'access' in source ? source.access : source.key;
72
- const get = (url, extra) => request(url, key, fetcher, nowMs, extra, pacing);
72
+ const get = (url, extra) => providerHttp(url, key, fetcher, nowMs, extra, pacing);
73
73
  switch (source.provider) {
74
74
  case 'claude': return get('https://api.anthropic.com/api/oauth/usage', { headers: { 'anthropic-beta': 'oauth-2025-04-20' } });
75
75
  case 'codex': return get('https://chatgpt.com/backend-api/wham/usage', { headers: { 'ChatGPT-Account-Id': source.accountId } });
@@ -138,6 +138,10 @@ export async function customClaude(source, nowMs, pacing) {
138
138
  }
139
139
  }
140
140
  export function codexUsage(source) {
141
+ return codexRequest(source, 'account/rateLimits/read', 20_000);
142
+ }
143
+ /** The single bounded app-server transport used for identity and usage. */
144
+ export function codexRequest(source, method, timeoutMs = 15_000) {
141
145
  return new Promise((resolve) => {
142
146
  const child = spawn(source.bin, ['app-server'], { env: { ...source.env, CODEX_HOME: source.home }, stdio: ['pipe', 'pipe', 'ignore'] });
143
147
  let buffer = '';
@@ -156,7 +160,7 @@ export function codexUsage(source) {
156
160
  }
157
161
  resolve(answer);
158
162
  };
159
- const timer = setTimeout(() => finish({ code: 'unavailable' }), 20_000);
163
+ const timer = setTimeout(() => finish({ code: 'unavailable' }), timeoutMs);
160
164
  child.once('error', () => finish({ code: 'unavailable' }));
161
165
  child.once('close', () => { clearTimeout(escalation); finish({ code: 'unavailable' }); });
162
166
  child.stdin.on('error', () => finish({ code: 'unavailable' }));
@@ -181,7 +185,7 @@ export function codexUsage(source) {
181
185
  if (!record(message))
182
186
  continue;
183
187
  if (message.id === 1)
184
- child.stdin.write(`${JSON.stringify({ id: 2, method: 'account/rateLimits/read', params: {} })}\n`);
188
+ child.stdin.write(`${JSON.stringify({ id: 2, method, params: {} })}\n`);
185
189
  if (message.id === 2)
186
190
  finish(record(message.result) ? { raw: message.result } : { code: 'incomplete' });
187
191
  }
package/dist/types.d.ts CHANGED
@@ -4,6 +4,13 @@ type Identity = {
4
4
  accountId?: string;
5
5
  };
6
6
  export type Source = {
7
+ provider: 'claude';
8
+ folder: string;
9
+ headers: {
10
+ 'anthropic-beta': string;
11
+ 'User-Agent': string;
12
+ };
13
+ } | {
7
14
  provider: 'codex';
8
15
  bin: string;
9
16
  home: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/usage",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Read subscription usage windows per provider and account.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",