@byokit/accounts 0.10.0 → 0.12.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,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
+ }
@@ -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,10 @@
1
- export { Accounts, planOf, portable, type AccountsOptions, type AuthHost, type Loopback, type Member, type Platform, type SignIn, type Status } from './accounts.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
2
  export { PROVIDERS, offered, provider, type Billing, 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';
package/dist/portable.js CHANGED
@@ -5,6 +5,8 @@ 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";
@@ -175,6 +175,8 @@ export type Ask = {
175
175
  model?: string;
176
176
  /** The tools the model may call. Passed, the result carries the output items next to the text. */
177
177
  tools?: ResponseTool[];
178
+ /** Whether ChatGPT may call tools in parallel. Omitted: the provider's default. */
179
+ parallelToolCalls?: boolean;
178
180
  /** Which tool the model must use. Default: whatever it wants. */
179
181
  tool_choice?: ResponseToolChoice;
180
182
  /** How hard the model thinks. Default: none. */
package/dist/responses.js CHANGED
@@ -182,6 +182,7 @@ export async function respond(o) {
182
182
  body: JSON.stringify({
183
183
  model: o.model, store: false, stream: true, instructions: o.instructions, input,
184
184
  ...(o.tools ? { tools: o.tools } : {}),
185
+ ...(o.parallelToolCalls !== undefined ? { parallel_tool_calls: o.parallelToolCalls } : {}),
185
186
  ...(o.tool_choice !== undefined ? { tool_choice: o.tool_choice } : {}),
186
187
  text: { verbosity, ...(format ? { format } : {}), ...textRest },
187
188
  reasoning: { effort, ...reasoningRest },
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/accounts",
3
- "version": "0.10.0",
3
+ "version": "0.12.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"