@byokit/accounts 0.11.0 → 0.13.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.
@@ -0,0 +1,51 @@
1
+ import type { CredentialStore } from '@earendil-works/pi-ai';
2
+ import type { AuthHost } from './accounts.ts';
3
+ export declare const CLAUDE_PLAN_ID = "byokit-claude-plan";
4
+ export declare const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
5
+ /** A refused/uncertain rotation requires a new sign-in; its old refresh token must not be tried again. */
6
+ export declare class ClaudePlanExpiredError extends Error {
7
+ readonly code = "CLAUDE_PLAN_EXPIRED";
8
+ readonly status = 401;
9
+ constructor();
10
+ }
11
+ export declare class ClaudePlanPlatformError extends Error {
12
+ readonly code = "CLAUDE_PLAN_UNSUPPORTED_PLATFORM";
13
+ constructor();
14
+ }
15
+ export type ClaudePlanOptions = {
16
+ fetch?: typeof fetch;
17
+ /** Stand-ins for offline tests. Production uses the Hermes-reference endpoints directly. */
18
+ authorizeUrl?: string;
19
+ tokenUrl?: string;
20
+ /** On React Native, pass a Web Crypto implementation supplied by the app. */
21
+ crypto?: Pick<Crypto, 'getRandomValues' | 'subtle'>;
22
+ now?: () => number;
23
+ };
24
+ /** Pending state/verifier never enter persistent storage. */
25
+ export declare function claudeAuthorization(options?: ClaudePlanOptions): Promise<{
26
+ url: string;
27
+ verifier: string;
28
+ state: string;
29
+ redirect: string;
30
+ }>;
31
+ export declare function claudeCode(paste: string, state: string): string;
32
+ /** Adds only BYOKit's own Claude route; every other provider still belongs to the supplied engine. */
33
+ export declare function withClaudePlan(engine: AuthHost, credentials: CredentialStore, lockKey: CredentialStore, options?: ClaudePlanOptions): AuthHost;
34
+ /** Hermes's baseline native-client fingerprint. No installed CLI is consulted for its version. */
35
+ export declare function claudePlanMessages(access: string, options?: {
36
+ fetch?: typeof fetch;
37
+ }): {
38
+ respond: {
39
+ (o: import("./anthropic.ts").AnthropicAsk & {
40
+ result: true;
41
+ }): Promise<import("./anthropic.ts").AnthropicResult>;
42
+ (o: import("./anthropic.ts").AnthropicAsk & {
43
+ tools: import("./anthropic.ts").AnthropicTool[];
44
+ }): Promise<import("./anthropic.ts").AnthropicResult>;
45
+ (o: import("./anthropic.ts").AnthropicAsk & {
46
+ tools?: undefined;
47
+ result?: false;
48
+ }): Promise<string>;
49
+ (o: import("./anthropic.ts").AnthropicAsk): Promise<string | import("./anthropic.ts").AnthropicResult>;
50
+ };
51
+ };
@@ -0,0 +1,167 @@
1
+ import { anthropicMessages } from "./anthropic.js";
2
+ import { needsReauth, refreshCredential } from "./stores.js";
3
+ export const CLAUDE_PLAN_ID = 'byokit-claude-plan';
4
+ export const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
5
+ const REDIRECT = 'https://console.anthropic.com/oauth/code/callback';
6
+ const SCOPES = 'org:create_api_key user:profile user:inference';
7
+ /** A refused/uncertain rotation requires a new sign-in; its old refresh token must not be tried again. */
8
+ export class ClaudePlanExpiredError extends Error {
9
+ code = 'CLAUDE_PLAN_EXPIRED';
10
+ status = 401;
11
+ constructor() { super('Sign in with Claude again.'); this.name = 'ClaudePlanExpiredError'; }
12
+ }
13
+ export class ClaudePlanPlatformError extends Error {
14
+ code = 'CLAUDE_PLAN_UNSUPPORTED_PLATFORM';
15
+ constructor() { super('Claude sign-in needs secure random bytes and SHA-256 on this device.'); this.name = 'ClaudePlanPlatformError'; }
16
+ }
17
+ const base64url = (bytes) => btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join('')).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
18
+ /** Pending state/verifier never enter persistent storage. */
19
+ export async function claudeAuthorization(options = {}) {
20
+ const crypto = options.crypto ?? globalThis.crypto;
21
+ if (!crypto?.getRandomValues || !crypto.subtle?.digest)
22
+ throw new ClaudePlanPlatformError();
23
+ const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
24
+ const state = base64url(crypto.getRandomValues(new Uint8Array(32)));
25
+ const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));
26
+ const url = new URL(options.authorizeUrl ?? 'https://claude.ai/oauth/authorize');
27
+ url.search = new URLSearchParams({ code: 'true', response_type: 'code', client_id: CLAUDE_CLIENT_ID,
28
+ redirect_uri: REDIRECT, scope: SCOPES, state, code_challenge: challenge, code_challenge_method: 'S256' }).toString();
29
+ return { url: url.toString(), verifier, state, redirect: REDIRECT };
30
+ }
31
+ export function claudeCode(paste, state) {
32
+ let code, returned;
33
+ if (/^https?:\/\//.test(paste)) {
34
+ const url = new URL(paste);
35
+ code = url.searchParams.get('code');
36
+ returned = url.searchParams.get('state');
37
+ }
38
+ else {
39
+ const parts = paste.trim().split('#');
40
+ code = parts.length === 2 ? parts[0] : null;
41
+ returned = parts.length === 2 ? parts[1] : null;
42
+ }
43
+ if (!code || !returned || returned !== state)
44
+ throw new Error('The Claude sign-in code does not match this sign-in. Try signing in again.');
45
+ return code;
46
+ }
47
+ const credential = (j, now, previous) => {
48
+ if (typeof j?.access_token !== 'string' || !j.access_token || typeof j.expires_in !== 'number'
49
+ || !Number.isFinite(j.expires_in) || j.expires_in <= 0 || !Number.isFinite(now + j.expires_in * 1000)
50
+ || (j.refresh_token !== undefined && (typeof j.refresh_token !== 'string' || !j.refresh_token)))
51
+ throw new Error('Claude could not complete the sign-in. Try signing in again.');
52
+ return { type: 'oauth', access: j.access_token, refresh: j.refresh_token ?? previous?.refresh ?? '', expires: now + j.expires_in * 1000 };
53
+ };
54
+ const states = new WeakMap();
55
+ /** Adds only BYOKit's own Claude route; every other provider still belongs to the supplied engine. */
56
+ export function withClaudePlan(engine, credentials, lockKey, options = {}) {
57
+ let shared = states.get(lockKey);
58
+ if (!shared)
59
+ states.set(lockKey, shared = { spent: new Set() });
60
+ const state = shared;
61
+ const now = options.now ?? Date.now;
62
+ const post = async (body, signal) => {
63
+ let res;
64
+ try {
65
+ res = await (options.fetch ?? fetch)(options.tokenUrl ?? 'https://platform.claude.com/v1/oauth/token', {
66
+ method: 'POST', signal: signal ?? AbortSignal.timeout(15_000),
67
+ headers: { 'content-type': 'application/json', 'user-agent': 'axios/1.7.9' }, body: JSON.stringify(body),
68
+ });
69
+ }
70
+ catch {
71
+ throw new Error('Claude could not complete the sign-in on this connection.');
72
+ }
73
+ if (!res.ok) {
74
+ if ([400, 401, 403].includes(res.status))
75
+ throw new ClaudePlanExpiredError();
76
+ throw new Error('Claude could not complete the sign-in. Try again later.');
77
+ }
78
+ try {
79
+ return await res.json();
80
+ }
81
+ catch {
82
+ throw new Error('Claude could not complete the sign-in. Try signing in again.');
83
+ }
84
+ };
85
+ const login = async ({ signal, notify, prompt }) => {
86
+ const pending = await claudeAuthorization(options);
87
+ if (signal?.aborted)
88
+ throw new Error('Login cancelled');
89
+ // Register the paste receiver before publishing the URL, so an immediate paste cannot be lost.
90
+ const pasted = prompt({ type: 'manual_code', message: 'Paste the code from the Claude page.', signal });
91
+ notify({ type: 'auth_url', url: pending.url });
92
+ const code = claudeCode(await pasted, pending.state);
93
+ if (signal?.aborted)
94
+ throw new Error('Login cancelled');
95
+ const c = credential(await post({ grant_type: 'authorization_code', client_id: CLAUDE_CLIENT_ID,
96
+ code, state: pending.state, redirect_uri: pending.redirect, code_verifier: pending.verifier }, signal), now());
97
+ if (signal?.aborted)
98
+ throw new Error('Login cancelled');
99
+ try {
100
+ await credentials.modify(CLAUDE_PLAN_ID, async () => c, { signal });
101
+ }
102
+ catch {
103
+ throw new Error('Claude sign-in could not be saved on this device. Try signing in again.');
104
+ }
105
+ if (signal?.aborted) {
106
+ await credentials.delete(CLAUDE_PLAN_ID);
107
+ throw new Error('Login cancelled');
108
+ }
109
+ return c;
110
+ };
111
+ const access = async (minOAuthValidityMs = 0) => {
112
+ const min = Math.max(300_000, minOAuthValidityMs);
113
+ const due = (c) => now() + min >= c.expires;
114
+ const c = await credentials.read(CLAUDE_PLAN_ID);
115
+ if (c?.type !== 'oauth')
116
+ return undefined;
117
+ if (state.flight)
118
+ return state.flight;
119
+ if (needsReauth(c) || state.spent.has(c.refresh))
120
+ throw new ClaudePlanExpiredError();
121
+ if (!due(c))
122
+ return c;
123
+ const work = refreshCredential(credentials, CLAUDE_PLAN_ID, due, async (current) => {
124
+ if (!current.refresh || state.spent.has(current.refresh))
125
+ throw new ClaudePlanExpiredError();
126
+ state.spent.add(current.refresh);
127
+ return credential(await post({ grant_type: 'refresh_token', client_id: CLAUDE_CLIENT_ID, refresh_token: current.refresh }), now(), current);
128
+ }).catch(async () => {
129
+ await credentials.delete(CLAUDE_PLAN_ID).catch(() => { });
130
+ throw new ClaudePlanExpiredError();
131
+ });
132
+ state.flight = work;
133
+ try {
134
+ const next = await work;
135
+ if (next)
136
+ state.spent.delete(next.refresh);
137
+ return next;
138
+ }
139
+ finally {
140
+ if (state.flight === work)
141
+ state.flight = undefined;
142
+ }
143
+ };
144
+ return Object.assign(engine, {
145
+ login: ((original) => (id, type, interaction) => id === CLAUDE_PLAN_ID ? login(interaction) : original.call(engine, id, type, interaction))(engine.login),
146
+ logout: ((original) => (id) => id === CLAUDE_PLAN_ID ? credentials.delete(id) : original.call(engine, id))(engine.logout),
147
+ checkAuth: ((original) => async (id) => id === CLAUDE_PLAN_ID
148
+ ? await (async () => { const c = await credentials.read(id); return c?.type === 'oauth' && !needsReauth(c) ? { source: 'OAuth', type: 'oauth' } : undefined; })()
149
+ : original.call(engine, id))(engine.checkAuth),
150
+ getAuth: ((original) => async (id, overrides) => {
151
+ if (id !== CLAUDE_PLAN_ID)
152
+ return original.call(engine, id, overrides);
153
+ const c = await access(overrides?.minOAuthValidityMs);
154
+ return c ? { auth: { apiKey: c.access }, source: 'OAuth' } : undefined;
155
+ })(engine.getAuth),
156
+ });
157
+ }
158
+ /** Hermes's baseline native-client fingerprint. No installed CLI is consulted for its version. */
159
+ export function claudePlanMessages(access, options = {}) {
160
+ return anthropicMessages({ ...options, headers: {
161
+ authorization: `Bearer ${access}`, 'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20',
162
+ 'user-agent': 'claude-code/2.1.74 (external, cli)', 'x-app': 'cli', 'anthropic-dangerous-direct-browser-access': 'true',
163
+ }, prepare: (request) => ({ ...request, system: [
164
+ { type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." },
165
+ ...(typeof request.system === 'string' ? [{ type: 'text', text: request.system }] : request.system ?? []),
166
+ ] }), safeErrors: true });
167
+ }
@@ -0,0 +1,77 @@
1
+ export type SignInState = 'ready' | 'signing' | 'resting' | 'signed_out' | 'needs_again' | 'not_included';
2
+ export type AccountLike = {
3
+ id: string;
4
+ provider: string;
5
+ name: string;
6
+ state: SignInState;
7
+ billing: 'subscription' | 'api';
8
+ until?: number;
9
+ };
10
+ export type RoomSpan = 'session' | 'week' | 'month' | 'tightest';
11
+ /** Every time here is epoch milliseconds; `at` is the source measurement time, never its receipt time. */
12
+ export type Room = {
13
+ left: number;
14
+ span: RoomSpan;
15
+ resetsAt?: number;
16
+ at?: number;
17
+ } | {
18
+ left: 'unknown';
19
+ at?: number;
20
+ };
21
+ export type RunSelection = {
22
+ account: string | 'default' | 'auto';
23
+ model?: string;
24
+ needs?: string[];
25
+ };
26
+ export type Defaults = {
27
+ account?: string;
28
+ model?: string;
29
+ auto?: boolean;
30
+ };
31
+ export type PickWhy = 'chosen' | 'default' | 'first_ready' | 'only' | 'most_room' | 'earlier_reset' | 'list_order' | 'no_reading' | 'refills_first';
32
+ export type Considered = {
33
+ id: string;
34
+ out?: 'state' | 'resting' | 'billing' | 'model' | 'provider';
35
+ until?: number;
36
+ missing?: string;
37
+ tier?: 'room' | 'unknown' | 'exhausted';
38
+ left: number | 'unknown';
39
+ span?: RoomSpan;
40
+ resetsAt?: number;
41
+ age: number | 'unknown';
42
+ confidence: 'known' | 'stale' | 'unknown';
43
+ reason: PickWhy | 'state' | 'resting' | 'billing' | 'model' | 'provider';
44
+ };
45
+ export type AccountPick<A extends AccountLike = AccountLike> = {
46
+ ok: true;
47
+ account: A;
48
+ model: string;
49
+ how: 'chosen' | 'default' | 'auto';
50
+ why: PickWhy;
51
+ reason: string;
52
+ considered: Considered[];
53
+ } | {
54
+ ok: false;
55
+ code: 'none' | 'not_included' | 'unknown_account';
56
+ reason: string;
57
+ considered: Considered[];
58
+ };
59
+ type Models<A> = (a: A) => readonly {
60
+ id: string;
61
+ available: boolean;
62
+ }[];
63
+ /** Structural windows: legacy reset seconds by default; usage 0.2.0+ must pass milliseconds explicitly. */
64
+ export declare function roomOf(windows: readonly {
65
+ usedPercent: number;
66
+ kind: string;
67
+ resetsAt?: number;
68
+ }[], at?: number, resetUnit?: 'seconds' | 'milliseconds'): Room;
69
+ export declare function roomWords(room: Room): string;
70
+ /** Auto's winner only. Pure and portable; no timers, reads, refreshes or mid-run switching. */
71
+ export declare function chooseAccount<A extends AccountLike>(accounts: readonly A[], room: (a: A) => Room, nowMs: number): {
72
+ account: A;
73
+ why: PickWhy;
74
+ } | undefined;
75
+ /** Resolve once before starting. Without `models`, membership is host-validated and a missing model is ''. */
76
+ export declare function resolveSelection<A extends AccountLike>(accounts: readonly A[], defaults: Defaults, sel: RunSelection, room: (a: A, demand: readonly string[]) => Room, nowMs: number, models?: Models<A>): AccountPick<A>;
77
+ export {};
package/dist/multi.js ADDED
@@ -0,0 +1,134 @@
1
+ // Pure start-of-run selection. Callers own identities and credentials and keep the returned account for the run.
2
+ import { clock, say } from "./words.js";
3
+ const DAY_MS = 24 * 60 * 60 * 1000;
4
+ const finite = (n) => typeof n === 'number' && Number.isFinite(n);
5
+ const reset = (r) => r.resetsAt ?? Infinity;
6
+ /** Structural windows: legacy reset seconds by default; usage 0.2.0+ must pass milliseconds explicitly. */
7
+ export function roomOf(windows, at, resetUnit = 'seconds') {
8
+ const known = windows.filter((w) => finite(w.usedPercent));
9
+ if (!known.length)
10
+ return { left: 'unknown', ...(finite(at) ? { at } : {}) };
11
+ const w = known.reduce((a, b) => b.usedPercent > a.usedPercent ? b : a);
12
+ const span = w.kind === 'session' ? 'session' : w.kind === 'weekly' ? 'week' : w.kind === 'monthly' ? 'month' : 'tightest';
13
+ return { left: Math.max(0, Math.min(100, 100 - w.usedPercent)), span,
14
+ ...(finite(w.resetsAt) ? { resetsAt: w.resetsAt * (resetUnit === 'seconds' ? 1000 : 1) } : {}), ...(finite(at) ? { at } : {}) };
15
+ }
16
+ export function roomWords(room) {
17
+ return room.left === 'unknown' ? say('room.unknown') : say(`room.${room.span}`, { left: `${Math.round(room.left)}%` });
18
+ }
19
+ // One shared eligibility predicate for direct picks, Auto and all explanation rows. Room is read once per row.
20
+ function consider(accounts, room, nowMs, demand = [], models, provider, named) {
21
+ return accounts.map((account) => {
22
+ const r = room(account);
23
+ const age = finite(r.at) ? Math.max(0, nowMs - r.at) : 'unknown';
24
+ const stale = typeof age === 'number' && age > DAY_MS;
25
+ const left = r.left === 'unknown' || !finite(r.left) || stale ? 'unknown' : Math.max(0, Math.min(100, r.left));
26
+ const row = { id: account.id, left, age,
27
+ confidence: stale ? 'stale' : left === 'unknown' || age === 'unknown' ? 'unknown' : 'known', reason: 'no_reading' };
28
+ if (r.left !== 'unknown') {
29
+ row.span = r.span;
30
+ if (finite(r.resetsAt))
31
+ row.resetsAt = r.resetsAt;
32
+ }
33
+ // Explicit ids bypass state and billing: the host verifies readiness before starting, never falls back.
34
+ if (account.id !== named) {
35
+ if (account.state === 'resting' && finite(account.until) && account.until > nowMs) {
36
+ row.out = 'resting';
37
+ row.until = account.until;
38
+ }
39
+ else if (account.state !== 'ready' && !(account.state === 'resting' && finite(account.until) && account.until <= nowMs))
40
+ row.out = 'state';
41
+ else if (account.billing !== 'subscription')
42
+ row.out = 'billing';
43
+ }
44
+ if (!row.out && demand.length && models) {
45
+ const available = models(account);
46
+ const missing = demand.find((id) => !available.some((m) => m.id === id && m.available));
47
+ if (missing !== undefined) {
48
+ row.out = 'model';
49
+ row.missing = missing;
50
+ }
51
+ }
52
+ if (!row.out && !demand.length && provider !== undefined && account.provider !== provider && account.id !== named)
53
+ row.out = 'provider';
54
+ if (row.out)
55
+ row.reason = row.out;
56
+ else
57
+ row.tier = left === 'unknown' ? 'unknown' : left > 0 || reset(row) <= nowMs ? 'room' : 'exhausted';
58
+ return { account, row };
59
+ });
60
+ }
61
+ const tierOrder = { room: 0, unknown: 1, exhausted: 2 };
62
+ function compare(a, b) {
63
+ const tier = tierOrder[a.row.tier] - tierOrder[b.row.tier];
64
+ if (tier)
65
+ return tier;
66
+ if (a.row.tier === 'unknown')
67
+ return 0;
68
+ if (a.row.tier === 'room' && a.row.left !== b.row.left)
69
+ return Number(b.row.left) - Number(a.row.left);
70
+ // Avoid Infinity - Infinity (NaN); stable sort preserves list order for ties.
71
+ return reset(a.row) === reset(b.row) ? 0 : reset(a.row) < reset(b.row) ? -1 : 1;
72
+ }
73
+ function rankingReason(winner, next) {
74
+ if (winner.row.tier === 'unknown')
75
+ return 'no_reading';
76
+ if (winner.row.tier === 'exhausted')
77
+ return 'refills_first';
78
+ if (!next || next.row.tier !== 'room' || winner.row.left !== next.row.left)
79
+ return 'most_room';
80
+ return reset(winner.row) < reset(next.row) ? 'earlier_reset' : 'list_order';
81
+ }
82
+ function rank(rows) {
83
+ const ranked = rows.filter((c) => !c.row.out).sort(compare);
84
+ const winner = ranked[0];
85
+ if (!winner)
86
+ return undefined;
87
+ const why = ranked.length === 1 ? 'only' : rankingReason(winner, ranked[1]);
88
+ for (const c of ranked)
89
+ c.row.reason = c === winner ? why : rankingReason(winner, c);
90
+ return { account: winner.account, why, row: winner.row };
91
+ }
92
+ /** Auto's winner only. Pure and portable; no timers, reads, refreshes or mid-run switching. */
93
+ export function chooseAccount(accounts, room, nowMs) {
94
+ const pick = rank(consider(accounts, room, nowMs));
95
+ return pick && { account: pick.account, why: pick.why };
96
+ }
97
+ /** Resolve once before starting. Without `models`, membership is host-validated and a missing model is ''. */
98
+ export function resolveSelection(accounts, defaults, sel, room, nowMs, models) {
99
+ const demand = [...new Set([...(sel.model ? [sel.model] : []), ...(sel.needs ?? [])])];
100
+ const defaultAccount = accounts.find((a) => a.id === defaults.account);
101
+ const named = sel.account === 'default' ? defaultAccount?.state === 'ready' ? defaultAccount.id : undefined : sel.account === 'auto' ? undefined : sel.account;
102
+ const provider = defaultAccount?.provider ?? accounts[0]?.provider;
103
+ const rows = consider(accounts, (a) => room(a, demand), nowMs, demand, models, provider, named);
104
+ const considered = rows.map((c) => c.row);
105
+ const auto = rank(rows); // also fills explanations for every eligible row, even on explicit selection
106
+ if (named !== undefined) {
107
+ const chosen = rows.find((c) => c.account.id === named);
108
+ if (!chosen)
109
+ return { ok: false, code: 'unknown_account', reason: say('pick.unknownAccount'), considered };
110
+ if (chosen.row.out)
111
+ return { ok: false, code: 'not_included', reason: say('pick.out.model', { model: chosen.row.missing ?? '' }), considered };
112
+ return finish(chosen.account, chosen.row, sel.account === 'default' ? 'default' : 'chosen', sel.account === 'default' ? 'default' : 'chosen');
113
+ }
114
+ if (sel.account === 'default' && defaults.auto === false) {
115
+ const first = rows.find((c) => !c.row.out);
116
+ if (first)
117
+ return finish(first.account, first.row, 'default', 'first_ready');
118
+ }
119
+ else if (auto)
120
+ return finish(auto.account, auto.row, 'auto', auto.why);
121
+ return { ok: false, code: 'none', reason: say('auto.none', { provider: provider ?? '' }), considered };
122
+ function finish(account, row, how, why) {
123
+ const available = models?.(account).filter((m) => m.available);
124
+ const model = sel.model ?? (defaults.model && (!available || available.some((m) => m.id === defaults.model)) ? defaults.model : available?.[0]?.id) ?? '';
125
+ if (models && !model)
126
+ return { ok: false, code: 'not_included', reason: say('pick.noModel'), considered };
127
+ row.reason = why;
128
+ const slots = { name: account.name, provider: account.provider };
129
+ const reason = how !== 'auto' ? say(`pick.why.${why}`, slots) : row.tier === 'unknown' ? say('auto.unknown', slots) : row.tier === 'exhausted' ?
130
+ say(finite(row.resetsAt) ? 'auto.refills' : 'auto.refillsNoTime', { ...slots, time: finite(row.resetsAt) ? clock(row.resetsAt) : '' }) :
131
+ say('auto.room', { ...slots, room: roomWords({ left: Number(row.left), span: row.span ?? 'tightest' }) });
132
+ return { ok: true, account, model, how, why, reason, considered };
133
+ }
134
+ }
@@ -4,6 +4,8 @@ import { type EndingStore } from './stores.ts';
4
4
  export type SafeStorageLike = {
5
5
  encryptString(text: string): Uint8Array;
6
6
  decryptString(data: Buffer): string;
7
+ /** Optional authenticated format upgrade; fileStore verifies and atomically replaces it. */
8
+ upgrade?(data: Buffer): Uint8Array | undefined;
7
9
  isEncryptionAvailable?(): boolean;
8
10
  getSelectedStorageBackend?(): string;
9
11
  };
@@ -38,7 +38,16 @@ export function fileStore(path, safeStorage) {
38
38
  const st = fstatSync(fd);
39
39
  if (!st.isFile() || (st.mode & 0o077))
40
40
  throw new Error('Credential storage requires a private regular file');
41
- return JSON.parse(safeStorage.decryptString(readFileSync(fd)));
41
+ const bytes = readFileSync(fd);
42
+ const text = safeStorage.decryptString(bytes);
43
+ const data = JSON.parse(text);
44
+ const upgraded = safeStorage.upgrade?.(bytes);
45
+ if (upgraded) {
46
+ if (safeStorage.decryptString(Buffer.from(upgraded)) !== text)
47
+ throw new Error('Credential upgrade verification failed');
48
+ replace(upgraded);
49
+ }
50
+ return data;
42
51
  }
43
52
  finally {
44
53
  closeSync(fd);
@@ -47,6 +56,9 @@ export function fileStore(path, safeStorage) {
47
56
  const save = async (data) => {
48
57
  ready();
49
58
  const sealed = safeStorage.encryptString(JSON.stringify(data, null, 2));
59
+ replace(sealed);
60
+ };
61
+ const replace = (sealed) => {
50
62
  mkdirSync(folder, { recursive: true, mode: 0o700 });
51
63
  privateFolder();
52
64
  const tmp = `${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`;
@@ -1,8 +1,11 @@
1
- export { Accounts, planOf, portable, type AccountsOptions, type AuthHost, type Loopback, type Member, type Platform, type SignIn, type Status } from './accounts.ts';
2
- export { PROVIDERS, offered, provider, type Billing, type Provider, type Terms } from './catalogue.ts';
1
+ export { Accounts, planOf, portable, type AccountsOptions, type ClaudePlanAsk, type AnthropicAccountAsk, type AuthHost, type Loopback, type Member, type Platform, type SignIn, type Status } from './accounts.ts';
2
+ export { PROVIDERS, offered, provider, type Billing, type MultiAccountTerms, type Provider, type Terms } from './catalogue.ts';
3
3
  export { PORTABLE, claims, credentialOf, devicePoll, deviceStart, portableEngine, type EngineOptions, type Poll } from './engine.ts';
4
4
  export { REST_MS, classify, classifyFailure, type Failure, type Kind } from './limits.ts';
5
5
  export { IncompleteError, ResponseError, isFunctionCall, limitResponse, respond, sseReader, type Ask, type ResponseFunctionCall, type ResponseInputItem, type ResponseOutputItem, type ResponseOutputMessage, type ResponseReasoning, type ResponseResult, type ResponseStreamEvent, type ResponseText, type ResponseTextFormat, type ResponseTool, type ResponseToolChoice } from './responses.ts';
6
- export { browserStore, memoryStore, recordStore, secureStore, RefreshRequiredError, type RefreshStore, type SecureStoreLike } from './stores.ts';
6
+ export { browserStore, keystoreStore, memoryStore, recordStore, secureStore, RefreshRequiredError, type RefreshStore, type SecureStoreLike } from './stores.ts';
7
7
  export { WORDS, billingWords, callbackPage, clock, failure, say, signInError, type WordKey, type Why } from './words.ts';
8
8
  export { chatgptPlan, UnsupportedAccountError, type ChatGPTPlanAccount, type ChatGPTPlanSession } from './chatgpt-plan.ts';
9
+ export { anthropic, anthropicSseReader, AnthropicIncompleteError, type AnthropicAsk, type AnthropicCacheControl, type AnthropicContent, type AnthropicImage, type AnthropicMessage, type AnthropicOptions, type AnthropicRequest, type AnthropicResponse, type AnthropicResult, type AnthropicStreamEvent, type AnthropicText, type AnthropicThinking, type AnthropicTool, type AnthropicToolChoice, type AnthropicToolUse, type AnthropicUsage } from './anthropic.ts';
10
+ export { ClaudePlanExpiredError, ClaudePlanPlatformError, claudeAuthorization, claudeCode, type ClaudePlanOptions } from './claude-plan.ts';
11
+ export { chooseAccount, resolveSelection, roomOf, roomWords, type AccountLike, type AccountPick, type Considered, type Defaults, type PickWhy, type Room, type RoomSpan, type RunSelection, type SignInState } from './multi.ts';
package/dist/portable.js CHANGED
@@ -5,6 +5,9 @@ export { PROVIDERS, offered, provider } from "./catalogue.js";
5
5
  export { PORTABLE, claims, credentialOf, devicePoll, deviceStart, portableEngine } from "./engine.js";
6
6
  export { REST_MS, classify, classifyFailure } from "./limits.js";
7
7
  export { IncompleteError, ResponseError, isFunctionCall, limitResponse, respond, sseReader } from "./responses.js";
8
- export { browserStore, memoryStore, recordStore, secureStore, RefreshRequiredError } from "./stores.js";
8
+ export { browserStore, keystoreStore, memoryStore, recordStore, secureStore, RefreshRequiredError } from "./stores.js";
9
9
  export { WORDS, billingWords, callbackPage, clock, failure, say, signInError } from "./words.js";
10
10
  export { chatgptPlan, UnsupportedAccountError } from "./chatgpt-plan.js";
11
+ export { anthropic, anthropicSseReader, AnthropicIncompleteError } from "./anthropic.js";
12
+ export { ClaudePlanExpiredError, ClaudePlanPlatformError, claudeAuthorization, claudeCode } from "./claude-plan.js";
13
+ export { chooseAccount, resolveSelection, roomOf, roomWords } from "./multi.js";
package/dist/stores.d.ts CHANGED
@@ -39,3 +39,8 @@ export declare function secureStore(secure: SecureStoreLike, name: string, optio
39
39
  /** One person's sign-ins in the browser's IndexedDB (a PWA, or Electron's renderer), under `name`. A browser has no
40
40
  * keychain: anything running on this page could read them, so keep the page free of scripts you don't control. */
41
41
  export declare function browserStore(name: string, db?: string): EndingStore;
42
+ /** A device-owned @byokit/secrets backend, supplied by the host; no runtime Node import. One store per member/name. */
43
+ export declare function keystoreStore(keystore: {
44
+ get(name: string): Promise<string | null>;
45
+ set(name: string, secret: string): Promise<void>;
46
+ }, name: string): CredentialStore;
package/dist/stores.js CHANGED
@@ -164,3 +164,7 @@ export function browserStore(name, db = 'byokit') {
164
164
  end: (id, fn) => locked(() => store.end(id, fn)),
165
165
  };
166
166
  }
167
+ /** A device-owned @byokit/secrets backend, supplied by the host; no runtime Node import. One store per member/name. */
168
+ export function keystoreStore(keystore, name) {
169
+ return recordStore(async () => JSON.parse(await keystore.get(name) ?? '{}'), async (data) => keystore.set(name, JSON.stringify(data)));
170
+ }
package/dist/words.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "signIn.opening": "Opening {name}…",
2
+ "signIn.opening": "Opening {name}\u2026",
3
3
  "signIn.waitingUrl": "Sign in on the {name} page that just opened.",
4
4
  "signIn.waitingCode": "On the {name} page, type this code: {code}",
5
5
  "signIn.pasteHint": "Having trouble? Copy the address from the browser and paste it here.",
@@ -12,7 +12,7 @@
12
12
  "signIn.busy": "Something else on this computer is signing in to {name}. Try again in a minute.",
13
13
  "signIn.failed": "{name} didn't finish the sign-in. Tap Sign in with {name} to try again.",
14
14
  "status.ready": "{name} is connected.",
15
- "status.signing": "Signing in to {name}…",
15
+ "status.signing": "Signing in to {name}\u2026",
16
16
  "status.resting": "{name} is resting until {until}.",
17
17
  "status.busy": "{name} is busy right now.",
18
18
  "status.signedOut": "{name} isn't signed in yet.",
@@ -25,5 +25,37 @@
25
25
  "callback.declined": "No problem. Nothing was changed. You can go back to {app}.",
26
26
  "callback.failed": "{error} Go back to {app}.",
27
27
  "callback.nearly": "Nearly there. Go back to {app} to finish.",
28
- "callback.outOfDate": "This sign-in page is out of date. Go back to {app} and tap Sign in with {name} again."
28
+ "callback.outOfDate": "This sign-in page is out of date. Go back to {app} and tap Sign in with {name} again.",
29
+ "auto.terms": "Auto may use either of a provider's accounts.",
30
+ "auto.room": "Right now that's {name}: {room}",
31
+ "auto.unknown": "Right now that's {name} (no recent reading)",
32
+ "auto.refills": "All {provider} accounts are out of room until {time}. {name} refills first.",
33
+ "auto.refillsNoTime": "All {provider} accounts are out of room. {name} refills first.",
34
+ "auto.none": "No signed-in {provider} account.",
35
+ "room.unknown": "Room left unknown",
36
+ "room.session": "{left} left this session",
37
+ "room.week": "{left} left this week",
38
+ "room.month": "{left} left this month",
39
+ "room.tightest": "{left} left for now",
40
+ "pick.out.state": "Not signed in right now",
41
+ "pick.out.resting": "Taking a break until {time}",
42
+ "pick.out.billing": "Billed per use, so used only when you choose it",
43
+ "pick.out.model": "Doesn't include {model}",
44
+ "pick.out.provider": "A different service",
45
+ "pick.out.bound": "This conversation uses another account",
46
+ "pick.unknownAccount": "That account is no longer in your list.",
47
+ "pick.noModel": "That account doesn't include a model for this run.",
48
+ "pick.why.chosen": "You chose {name}.",
49
+ "pick.why.default": "{name} is your default.",
50
+ "pick.why.first_ready": "Your default isn't ready, so {name}, the first ready account.",
51
+ "pick.why.only": "{name} is the only account that can take this.",
52
+ "pick.why.most_room": "{name} has the most room left.",
53
+ "pick.why.earlier_reset": "{name} has as much room left and refills sooner.",
54
+ "pick.why.list_order": "{name} is tied for room and comes first in your list.",
55
+ "pick.why.no_reading": "No account has a recent reading, so {name}, first in your list.",
56
+ "pick.why.refills_first": "All accounts are out of room; {name} refills first.",
57
+ "pick.age": "Read {ago} ago",
58
+ "pick.ageUnknown": "Reading time unknown",
59
+ "ago.minutes": "{n} minutes",
60
+ "ago.hours": "{n} hours"
29
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/accounts",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Sign in with the AI plan you already pay for, into your app's own store.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -41,7 +41,8 @@
41
41
  "files": [
42
42
  "dist",
43
43
  "CHANGELOG.md",
44
- "SECURITY.md"
44
+ "SECURITY.md",
45
+ "NOTICE"
45
46
  ],
46
47
  "scripts": {
47
48
  "prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"