@byokit/accounts 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # @byokit/accounts
2
2
 
3
3
  Sign in with the AI plan you already pay for (ChatGPT, OpenRouter; Grok and GitHub Copilot on request), inside your
4
- own app, into your app's own store. Built on Pi's [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai)
5
- sign-in flows, pinned exactly.
4
+ own app, into your app's own store: on a computer (Node, Electron), in a browser (a PWA, Electron's renderer) and on a
5
+ phone (React Native and Expo, iOS and Android). One import; your bundler picks the platform's side
6
+ (`package.json`'s `react-native` and `browser` conditions).
7
+
8
+ On a computer it uses Pi's [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) sign-in
9
+ flows, pinned exactly:
6
10
 
7
11
  ```ts
8
12
  import { isolate } from '@byokit/accounts/isolate'; // first, before any Pi import
@@ -15,6 +19,34 @@ const shown = await accounts.login(1, 'chatgpt', { via: 'code' }); // { state: '
15
19
  (await accounts.status(1, 'chatgpt')).words; // "ChatGPT is connected."
16
20
  ```
17
21
 
22
+ On a phone or in a browser the same `Accounts` signs in to ChatGPT by device code with `fetch` alone (Pi's flows need
23
+ Node), into the phone's secure storage or the browser's IndexedDB:
24
+
25
+ ```ts
26
+ import * as SecureStore from 'expo-secure-store';
27
+ import { Accounts, secureStore } from '@byokit/accounts';
28
+
29
+ const accounts = new Accounts({ store: (member) => secureStore(SecureStore, `byokit.${member}`) });
30
+ const shown = await accounts.login(1, 'chatgpt'); // { state: 'waiting', via: 'code', code, url }: open url, show code
31
+ ```
32
+
33
+ Examples that sign in end to end: [`examples/expo`](../../examples/expo) (iOS and Android) and
34
+ [`examples/pwa`](../../examples/pwa).
35
+
36
+ ## Which sign-in works where
37
+
38
+ | | Computer (Node, Electron main) | Browser (PWA, Electron renderer) | Phone (React Native: iOS, Android) |
39
+ |---|---|---|---|
40
+ | ChatGPT | Its own page, straight back to this computer (port 1455); a code when asked or stuck | Device code | Device code |
41
+ | OpenRouter | Its own page, back to this computer (Pi's flow) | Not yet | Not yet |
42
+ | Grok, Copilot (hidden) | Pi's flows | No | No |
43
+ | Where sign-ins are kept | `fileStore(path)`, sealed with Electron's `safeStorage` when given | `browserStore(name)` (IndexedDB) | `secureStore(SecureStore, name)` (Keychain, Keystore) |
44
+
45
+ Device code works everywhere: OpenAI's sign-in endpoints answer any web page. The page-straight-back sign-in needs a
46
+ listener on the computer the browser runs on, so it is desktop only: ChatGPT sends the browser back to
47
+ `localhost:1455`, fixed for the client this signs in as. A web page can't call ChatGPT's model endpoint itself (it
48
+ doesn't answer other web pages), so a PWA's model calls go through the app's own server or relay.
49
+
18
50
  - **Catalogue** (`catalogue.json`): each provider with its terms status (`allowed`, `grey`, `partner`), a one-line reason
19
51
  and a source. The kit labels; your app decides what to offer (`new Accounts({ offer: ['chatgpt', 'grok'] })`).
20
52
  Claude plan sign-in is never offered: Anthropic reserves it for its own apps.
@@ -23,10 +55,18 @@ const shown = await accounts.login(1, 'chatgpt', { via: 'code' }); // { state: '
23
55
  true. A code takes over when asked ("Having trouble?"), when the page never comes back, or when the port is taken by
24
56
  another sign-in. A 15-minute cap, nothing kept unless the engine can use it, and every failure is one plain sentence
25
57
  (`words.json`) with a `why` for apps that word it themselves. `plan(member)` tells a work ChatGPT from a personal one.
26
- - **One person, one store**: `memoryStore()` or `fileStore(path)` (0600, the same shape as Pi's `auth.json`). Never a
27
- shared fallback. Using another engine with the same seam (Pi's coding-agent `ModelRuntime`)? Override `open(member)`.
58
+ - **Sign-out**: `logout(member, key)` also ends a ChatGPT sign-in at OpenAI (`POST auth.openai.com/oauth/revoke`, as
59
+ Codex's own sign-out does), then deletes it here whatever OpenAI answers. It runs in the store's own turn, so a refresh
60
+ under way finishes first and the token it rotated to is the one ended.
61
+ - **One person, one store**: `memoryStore()`, `fileStore(path)` (0600, the same shape as Pi's `auth.json`),
62
+ `secureStore(SecureStore, name)` or `browserStore(name)`; any other storage with `recordStore(load, save)`. Every one
63
+ serializes its writes, so a refresh and a sign-out never interleave. Never a shared fallback. Using another engine
64
+ with the same seam (Pi's coding-agent `ModelRuntime`)? Override `open(member)`.
28
65
  - **Limits**: `failed(member, key, error)` rests an account until the provider said (or a default), marks a plan that
29
66
  doesn't include this use, and signs out only a sign-in that no longer refreshes. `ladder()` picks the next usable
30
67
  account; `keepFresh()` refreshes ahead of expiry. Limits come from errors only; no undocumented usage endpoint is read.
31
68
  - **Isolation**: ambient discovery is off (no environment variable or credential file is ever consulted), and
32
69
  `@byokit/accounts/testing` has the decoy-HOME harness and fs tracer to prove it in your own tests.
70
+ - **A stand-in OpenAI**: `mockOpenAI()` from `@byokit/accounts/testing` (or `node .../testing/mock-openai.ts 1455`)
71
+ answers device code, its page where a person types the code, token exchange, refresh and revoke, so tests and demos
72
+ sign in end to end with no account. Point the kit at it with `new Accounts({ authBase })`.
@@ -23,6 +23,21 @@ export type Status = {
23
23
  until?: number;
24
24
  words: string;
25
25
  };
26
+ /** Listens on this computer for the provider's page coming back: each request's path in, the page to answer with out. */
27
+ export type Loopback = (port: number, handle: (path: string) => Promise<{
28
+ status: number;
29
+ html: string;
30
+ }>) => Promise<{
31
+ close(): void;
32
+ }>;
33
+ /** What differs by platform: the engine that signs in, which providers it can, and (on a computer) a loopback listener. */
34
+ export type Platform = {
35
+ engine: (credentials: CredentialStore, authBase?: string) => AuthHost;
36
+ signsIn: (pi: string) => boolean;
37
+ loopback?: Loopback;
38
+ };
39
+ /** Phones and browsers: ChatGPT by device code, no listener. */
40
+ export declare const portable: Platform;
26
41
  export type AccountsOptions<M extends Member = Member> = {
27
42
  /** The accounts this app offers, in order. Default: every provider not hidden (ChatGPT, OpenRouter). */
28
43
  offer?: readonly string[];
@@ -36,6 +51,10 @@ export type AccountsOptions<M extends Member = Member> = {
36
51
  redirectMs?: number;
37
52
  /** Listen here for the provider's redirect instead of its fixed port (tests, so they never meet a real sign-in). */
38
53
  callbackPort?: number;
54
+ /** Where OpenAI's sign-in lives, for a stand-in in tests and demos (`mockOpenAI()` from `@byokit/accounts/testing`).
55
+ * Phones and browsers sign in and sign out there; on a computer Pi's engine always calls OpenAI, and only sign-out's
56
+ * revoke goes here. */
57
+ authBase?: string;
39
58
  };
40
59
  /** The ChatGPT plan behind a sign-in, from its own token: a work plan (Business, Enterprise, Edu) follows the employer's rules. */
41
60
  export declare function planOf(access: string): {
@@ -60,7 +79,9 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
60
79
  onSignedIn?: (member: M, key: string) => void;
61
80
  /** Said once when a sign-in can no longer be refreshed. */
62
81
  onExpired?: (member: M, key: string) => void;
63
- constructor(opts?: AccountsOptions<M>);
82
+ private platform;
83
+ /** Offered: the providers named in `offer`, else every provider not hidden that this platform can sign in to. */
84
+ constructor(opts?: AccountsOptions<M>, platform?: Platform);
64
85
  /** A member's own store. */
65
86
  protected store(member: M): CredentialStore;
66
87
  /** A member's engine, holding only their own sign-ins (`store(member)`). Override to use another engine with the same seam. */
@@ -120,6 +141,9 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
120
141
  keepFresh(members: readonly M[]): Promise<void>;
121
142
  /** After the account turned a request away: true if its sign-in still refreshes; if not, it is signed out for good. */
122
143
  recheck(member: M, key: string): Promise<boolean>;
144
+ /** Signs out here, and at the provider too where it can end a sign-in (ChatGPT), best effort: the sign-in is deleted
145
+ * here whatever the provider answers. The revoke runs in the store's own turn, so a refresh under way finishes first
146
+ * and the token it rotated to is the one ended; a refresh after it is refused. */
123
147
  logout(member: M, key: string): Promise<void>;
124
148
  view(member: M, key: string): SignIn | null;
125
149
  stop(): void;
package/dist/accounts.js CHANGED
@@ -1,24 +1,34 @@
1
- // Sign in with the AI plan you already pay for, one person at a time, into that person's own store. Sharing one
2
- // person's plan breaks the vendors' terms, so every sign-in, rest and refresh is keyed by member and account.
3
- // Pi's own sign-in flows do the work; the app only shows the provider's page to open or the code to type.
4
- import { createServer } from 'node:http';
5
- import { builtinModels } from '@earendil-works/pi-ai/providers/all';
6
1
  import { offered, provider } from "./catalogue.js";
7
- import { emptyAuthContext } from "./isolate.js";
2
+ import { claims, PORTABLE, portableEngine } from "./engine.js";
8
3
  import { classify, REST_MS } from "./limits.js";
9
4
  import { memoryStore } from "./stores.js";
10
5
  import { callbackPage, clock, failure, say, signInError } from "./words.js";
6
+ /** Phones and browsers: ChatGPT by device code, no listener. */
7
+ export const portable = { engine: (c, base) => portableEngine(c, { base }), signsIn: (pi) => PORTABLE.includes(pi) };
11
8
  /** The ChatGPT plan behind a sign-in, from its own token: a work plan (Business, Enterprise, Edu) follows the employer's rules. */
12
9
  export function planOf(access) {
13
- let claims = {};
10
+ let c = {};
14
11
  try {
15
- claims = JSON.parse(Buffer.from(access.split('.')[1] ?? '', 'base64url').toString());
12
+ c = claims(access);
16
13
  }
17
14
  catch { }
18
- const plan = String(claims['https://api.openai.com/auth']?.chatgpt_plan_type ?? '').toLowerCase();
19
- return { plan, email: String(claims['https://api.openai.com/profile']?.email ?? claims.email ?? ''), work: /^(team|business|enterprise|edu|education|k12)/.test(plan) };
15
+ const plan = String(c['https://api.openai.com/auth']?.chatgpt_plan_type ?? '').toLowerCase();
16
+ return { plan, email: String(c['https://api.openai.com/profile']?.email ?? c.email ?? ''), work: /^(team|business|enterprise|edu|education|k12)/.test(plan) };
20
17
  }
21
18
  const offline = (e) => failure(String(e?.message)) === 'offline';
19
+ /** Ends a sign-in on the provider's side, as Codex's own logout does (openai/codex#17825): the refresh token, else the
20
+ * access token, never retried (fixtures/conformance/revoke.json). */
21
+ async function revoke(url, clientId, c) {
22
+ const body = c.refresh ? { token: c.refresh, token_type_hint: 'refresh_token', client_id: clientId } : { token: c.access, token_type_hint: 'access_token' };
23
+ const stop = new AbortController();
24
+ const t = setTimeout(() => stop.abort(), 10_000);
25
+ try {
26
+ await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: stop.signal });
27
+ }
28
+ finally {
29
+ clearTimeout(t);
30
+ }
31
+ }
22
32
  export class Accounts {
23
33
  providers;
24
34
  opts;
@@ -36,7 +46,13 @@ export class Accounts {
36
46
  onSignedIn;
37
47
  /** Said once when a sign-in can no longer be refreshed. */
38
48
  onExpired;
39
- constructor(opts = {}) { this.opts = opts; this.providers = offered(opts.offer); }
49
+ platform;
50
+ /** Offered: the providers named in `offer`, else every provider not hidden that this platform can sign in to. */
51
+ constructor(opts = {}, platform = portable) {
52
+ this.opts = opts;
53
+ this.platform = platform;
54
+ this.providers = opts.offer ? offered(opts.offer) : offered().filter((p) => platform.signsIn(p.pi));
55
+ }
40
56
  /** A member's own store. */
41
57
  store(member) {
42
58
  let s = this.stores.get(String(member));
@@ -46,7 +62,7 @@ export class Accounts {
46
62
  }
47
63
  /** A member's engine, holding only their own sign-ins (`store(member)`). Override to use another engine with the same seam. */
48
64
  open(member) {
49
- return Promise.resolve(builtinModels({ credentials: this.store(member), authContext: emptyAuthContext }));
65
+ return Promise.resolve(this.platform.engine(this.store(member), this.opts.authBase));
50
66
  }
51
67
  runtime(member) {
52
68
  let r = this.runtimes.get(String(member));
@@ -207,7 +223,7 @@ export class Accounts {
207
223
  const stuck = setTimeout(() => this.toCode(flow), this.opts.redirectMs ?? 3 * 60_000);
208
224
  // Listen where the provider sends the browser back (the engine then finds the port taken and waits to be handed the address).
209
225
  const port = p.callbackPort && (this.opts.callbackPort ?? p.callbackPort);
210
- const catcher = port && body.via !== 'code' ? await this.catchRedirect(flow, p.name, port).catch(() => null) : undefined;
226
+ const catcher = port && body.via !== 'code' && this.platform.loopback ? await this.catchRedirect(this.platform.loopback, flow, p.name, port).catch(() => null) : undefined;
211
227
  try {
212
228
  if (catcher === null)
213
229
  throw Object.assign(new Error('port busy'), { why: 'busy' });
@@ -220,7 +236,6 @@ export class Accounts {
220
236
  throw e;
221
237
  Object.assign(flow, { url: undefined, code: undefined, via: 'code' });
222
238
  catcher?.close();
223
- catcher?.closeIdleConnections();
224
239
  await attempt('code');
225
240
  }
226
241
  // Never half signed in: only a sign-in the engine can use counts.
@@ -248,34 +263,31 @@ export class Accounts {
248
263
  clearTimeout(timer);
249
264
  clearTimeout(stuck);
250
265
  catcher?.close();
251
- catcher?.closeIdleConnections();
252
266
  this.onChange?.(member, key);
253
267
  }
254
268
  }
255
269
  /** Listen where the provider sends the browser back; rejects if something else on this computer already listens there. */
256
- catchRedirect(flow, name, port) {
270
+ catchRedirect(loopback, flow, name, port) {
257
271
  const app = this.opts.app ?? 'the app';
258
- const server = createServer(async (req, res) => {
259
- const q = new URL(req.url ?? '/', 'http://localhost').searchParams;
260
- // No keep-alive: a browser must never land on a listener from an earlier try.
261
- const page = (status, words, close = false) => { res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', connection: 'close' }); res.end(callbackPage(this.opts.app ?? name, words, close)); };
272
+ const page = (status, words, close = false) => ({ status, html: callbackPage(this.opts.app ?? name, words, close) });
273
+ return loopback(port, async (path) => {
274
+ const q = new URL(path, 'http://localhost').searchParams;
262
275
  if (!flow.oauthState || q.get('state') !== flow.oauthState || flow.state !== 'waiting')
263
276
  return page(400, say('callback.outOfDate', { app, name }));
264
277
  if (q.get('error'))
265
278
  flow.refuse?.(new Error(q.get('error')));
266
279
  // The engine only reads the query; the address it expects is the provider's registered one, on its fixed port.
267
280
  else
268
- flow.paste?.(`http://localhost:${port}${req.url}`);
281
+ flow.paste?.(`http://localhost:${port}${path}`);
269
282
  // The tab waits for the real outcome (a few seconds at most), so it never says "signed in" before it is.
270
- await Promise.race([flow.done, new Promise((r) => setTimeout(r, 30_000).unref())]);
283
+ await Promise.race([flow.done, new Promise((r) => setTimeout(r, 30_000).unref?.())]);
271
284
  const end = flow.state;
272
285
  if (end === 'done')
273
286
  return page(200, say('callback.done', { app }), true);
274
287
  if (flow.why === 'declined')
275
288
  return page(200, say('callback.declined', { app }), true);
276
- page(200, end === 'failed' ? say('callback.failed', { app, error: flow.error ?? '' }) : say('callback.nearly', { app }));
289
+ return page(200, end === 'failed' ? say('callback.failed', { app, error: flow.error ?? '' }) : say('callback.nearly', { app }));
277
290
  });
278
- return new Promise((resolve, reject) => { server.once('error', reject).listen(port, '127.0.0.1', () => resolve(server)); });
279
291
  }
280
292
  /** The redirect address (or a code) pasted back, for when the browser couldn't return to this computer by itself. */
281
293
  paste(member, key, text) {
@@ -311,15 +323,25 @@ export class Accounts {
311
323
  }
312
324
  /** After the account turned a request away: true if its sign-in still refreshes; if not, it is signed out for good. */
313
325
  async recheck(member, key) {
314
- const ok = await (await this.runtime(member)).getAuth(this.offer(key).pi, { minOAuthValidityMs: 365 * 86_400_000 }).then(Boolean, offline);
326
+ // Asking for a year's validity forces a refresh; the fresh token then "expires too soon" for that year, which is fine.
327
+ const ok = await (await this.runtime(member)).getAuth(this.offer(key).pi, { minOAuthValidityMs: 365 * 86_400_000 })
328
+ .then(Boolean, (e) => offline(e) || /expires too soon/.test(String(e?.message)));
315
329
  if (!ok) {
316
330
  await this.logout(member, key).catch(() => { });
317
331
  this.forget(member, key);
318
332
  }
319
333
  return ok;
320
334
  }
335
+ /** Signs out here, and at the provider too where it can end a sign-in (ChatGPT), best effort: the sign-in is deleted
336
+ * here whatever the provider answers. The revoke runs in the store's own turn, so a refresh under way finishes first
337
+ * and the token it rotated to is the one ended; a refresh after it is refused. */
321
338
  async logout(member, key) {
322
- await (await this.runtime(member)).logout(this.offer(key).pi);
339
+ const p = this.offer(key);
340
+ const url = p.revoke && (this.opts.authBase ? `${this.opts.authBase}/oauth/revoke` : p.revoke);
341
+ if (url)
342
+ await this.store(member).modify(p.pi, async (c) => { if (c?.type === 'oauth')
343
+ await revoke(url, p.clientId, c).catch(() => { }); return undefined; }).catch(() => { });
344
+ await (await this.runtime(member)).logout(p.pi);
323
345
  this.ready.set(`${member}:${key}`, false);
324
346
  this.onChange?.(member, key);
325
347
  }
@@ -1,5 +1,6 @@
1
1
  export type Terms = 'allowed' | 'grey' | 'partner' | 'forbidden';
2
- /** `callbackPort`: where the provider sends the browser back after its own sign-in page, fixed for the client Pi signs in as. */
2
+ /** `callbackPort`: where the provider sends the browser back after its own sign-in page, fixed for the client Pi signs in as.
3
+ * `revoke`: where signing out ends the sign-in on the provider's side too, for the client `clientId`. */
3
4
  export type Provider = {
4
5
  key: string;
5
6
  pi: string;
@@ -10,6 +11,8 @@ export type Provider = {
10
11
  fast?: string;
11
12
  };
12
13
  callbackPort?: number;
14
+ clientId?: string;
15
+ revoke?: string;
13
16
  terms: Terms;
14
17
  hidden: boolean;
15
18
  why: string;
@@ -8,6 +8,8 @@
8
8
  "fast": "gpt-6-luna"
9
9
  },
10
10
  "callbackPort": 1455,
11
+ "clientId": "app_EMoamEEZ73f0CkXaXp7hrann",
12
+ "revoke": "https://auth.openai.com/oauth/revoke",
11
13
  "terms": "grey",
12
14
  "hidden": false,
13
15
  "why": "Signs in through Codex's own sign-in. OpenAI documents it for Codex, not for other apps, and has endorsed one other app using it.",
@@ -0,0 +1,30 @@
1
+ import type { CredentialStore, OAuthCredential } from '@earendil-works/pi-ai';
2
+ import type { AuthHost } from './accounts.ts';
3
+ /** The Pi provider ids this engine signs in to. */
4
+ export declare const PORTABLE: string[];
5
+ /** A JWT's claims, on any platform (no Buffer). */
6
+ export declare function claims(token: string): any;
7
+ export declare function deviceStart(status: number, body: string): {
8
+ deviceAuthId: string;
9
+ userCode: string;
10
+ intervalSeconds: number;
11
+ };
12
+ export type Poll = {
13
+ status: 'complete';
14
+ authorizationCode: string;
15
+ codeVerifier: string;
16
+ } | {
17
+ status: 'pending' | 'slow_down';
18
+ } | {
19
+ status: 'failed';
20
+ message: string;
21
+ };
22
+ export declare function devicePoll(status: number, body: string): Poll;
23
+ /** A token response as the stored credential, the same shape Pi keeps. */
24
+ export declare function credentialOf(j: any, now?: number): OAuthCredential;
25
+ export type EngineOptions = {
26
+ /** Where OpenAI's sign-in lives; a stand-in for tests and demos (`mockOpenAI()` from `@byokit/accounts/testing`). */
27
+ base?: string;
28
+ };
29
+ /** A member's engine on phones and in browsers: ChatGPT's device-code sign-in, refresh and sign-out, into `credentials`. */
30
+ export declare function portableEngine(credentials: CredentialStore, { base }?: EngineOptions): AuthHost;
package/dist/engine.js ADDED
@@ -0,0 +1,157 @@
1
+ const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
2
+ const CODE_LIVES_S = 15 * 60;
3
+ /** The Pi provider ids this engine signs in to. */
4
+ export const PORTABLE = ['openai-codex'];
5
+ /** A JWT's claims, on any platform (no Buffer). */
6
+ export function claims(token) {
7
+ const b64 = (token.split('.')[1] ?? '').replace(/-/g, '+').replace(/_/g, '/');
8
+ return JSON.parse(decodeURIComponent(atob(b64).replace(/[\s\S]/g, (c) => '%' + c.charCodeAt(0).toString(16).padStart(2, '0'))));
9
+ }
10
+ const json = (body) => { try {
11
+ return JSON.parse(body);
12
+ }
13
+ catch {
14
+ return undefined;
15
+ } };
16
+ export function deviceStart(status, body) {
17
+ if (status === 404)
18
+ throw new Error('OpenAI Codex device code login is not enabled for this server. Use browser login or verify the server URL.');
19
+ if (status < 200 || status > 299)
20
+ throw new Error(`OpenAI Codex device code request failed with status ${status}${body ? `: ${body}` : ''}`);
21
+ const j = json(body);
22
+ const intervalSeconds = typeof j?.interval === 'string' ? Number(j.interval.trim()) : j?.interval;
23
+ if (!j?.device_auth_id || !j.user_code || typeof intervalSeconds !== 'number' || !Number.isFinite(intervalSeconds) || intervalSeconds < 0)
24
+ throw new Error(`Invalid OpenAI Codex device code response: ${body}`);
25
+ return { deviceAuthId: String(j.device_auth_id), userCode: String(j.user_code), intervalSeconds };
26
+ }
27
+ export function devicePoll(status, body) {
28
+ if (status >= 200 && status <= 299) {
29
+ const j = json(body);
30
+ return j?.authorization_code && j.code_verifier
31
+ ? { status: 'complete', authorizationCode: j.authorization_code, codeVerifier: j.code_verifier }
32
+ : { status: 'failed', message: `Invalid OpenAI Codex device auth token response: ${body}` };
33
+ }
34
+ if (status === 403 || status === 404)
35
+ return { status: 'pending' };
36
+ const error = json(body)?.error;
37
+ const code = typeof error === 'object' ? error?.code : error;
38
+ if (code === 'deviceauth_authorization_pending')
39
+ return { status: 'pending' };
40
+ if (code === 'slow_down')
41
+ return { status: 'slow_down' };
42
+ return { status: 'failed', message: `OpenAI Codex device auth failed with status ${status}${body ? `: ${body}` : ''}` };
43
+ }
44
+ /** A token response as the stored credential, the same shape Pi keeps. */
45
+ export function credentialOf(j, now = Date.now()) {
46
+ if (typeof j?.access_token !== 'string' || typeof j.refresh_token !== 'string' || typeof j.expires_in !== 'number')
47
+ throw new Error(`OpenAI Codex token response missing fields: ${JSON.stringify(j)}`);
48
+ let accountId;
49
+ try {
50
+ accountId = claims(j.access_token)['https://api.openai.com/auth']?.chatgpt_account_id;
51
+ }
52
+ catch { }
53
+ if (typeof accountId !== 'string' || !accountId)
54
+ throw new Error('Failed to extract accountId from token');
55
+ return { type: 'oauth', access: j.access_token, refresh: j.refresh_token, expires: now + j.expires_in * 1000, accountId };
56
+ }
57
+ /** Wait, unless the sign-in is cancelled first. */
58
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
59
+ if (signal?.aborted)
60
+ return reject(new Error('Login cancelled'));
61
+ const t = setTimeout(() => { signal?.removeEventListener('abort', stop); resolve(); }, ms);
62
+ const stop = () => { clearTimeout(t); reject(new Error('Login cancelled')); };
63
+ signal?.addEventListener('abort', stop, { once: true });
64
+ });
65
+ /** A member's engine on phones and in browsers: ChatGPT's device-code sign-in, refresh and sign-out, into `credentials`. */
66
+ export function portableEngine(credentials, { base = 'https://auth.openai.com' } = {}) {
67
+ const post = async (path, body, form = false, signal) => {
68
+ try {
69
+ const res = await fetch(base + path, {
70
+ method: 'POST', signal,
71
+ headers: { 'content-type': form ? 'application/x-www-form-urlencoded' : 'application/json' },
72
+ body: form ? new URLSearchParams(body).toString() : JSON.stringify(body),
73
+ });
74
+ return { status: res.status, body: await res.text() };
75
+ }
76
+ catch (e) {
77
+ if (signal?.aborted)
78
+ throw new Error('Login cancelled');
79
+ throw e;
80
+ }
81
+ };
82
+ const tokens = async (what, r) => {
83
+ if (r.status < 200 || r.status > 299)
84
+ throw new Error(`OpenAI Codex token ${what} failed (${r.status}): ${r.body}`);
85
+ return credentialOf(json(r.body));
86
+ };
87
+ const refresh = async (c) => {
88
+ const stop = new AbortController();
89
+ const t = setTimeout(() => stop.abort(), 15_000);
90
+ try {
91
+ return await tokens('refresh', await post('/oauth/token', { grant_type: 'refresh_token', refresh_token: c.refresh, client_id: CLIENT_ID }, true, stop.signal));
92
+ }
93
+ catch (e) {
94
+ throw new Error(`OAuth refresh failed for openai-codex: ${e?.message ?? e}`);
95
+ }
96
+ finally {
97
+ clearTimeout(t);
98
+ }
99
+ };
100
+ const known = (id) => { if (!PORTABLE.includes(id))
101
+ throw new Error(`${id} can't be signed in to on this device`); };
102
+ const engine = {
103
+ async login(id, _type, { signal, notify }) {
104
+ known(id);
105
+ const asked = await post('/api/accounts/deviceauth/usercode', { client_id: CLIENT_ID }, false, signal);
106
+ const start = deviceStart(asked.status, asked.body);
107
+ notify({ type: 'device_code', userCode: start.userCode, verificationUri: `${base}/codex/device`, intervalSeconds: start.intervalSeconds, expiresInSeconds: CODE_LIVES_S });
108
+ let interval = Math.max(1000, start.intervalSeconds * 1000);
109
+ for (const deadline = Date.now() + CODE_LIVES_S * 1000;;) {
110
+ if (Date.now() >= deadline)
111
+ throw new Error('Device flow timed out');
112
+ // A poll that can't get through waits for the next: a phone cuts a backgrounded app's network while the person
113
+ // is typing the code in the browser (Android 15 and later), which isn't the sign-in failing.
114
+ const r = await post('/api/accounts/deviceauth/token', { device_auth_id: start.deviceAuthId, user_code: start.userCode }, false, signal)
115
+ .catch((e) => { if (signal?.aborted)
116
+ throw e; return { status: 0, body: '' }; });
117
+ const p = r.status ? devicePoll(r.status, r.body) : { status: 'pending' };
118
+ if (p.status === 'failed')
119
+ throw new Error(p.message);
120
+ if (p.status === 'complete') {
121
+ const c = await tokens('exchange', await post('/oauth/token', {
122
+ grant_type: 'authorization_code', client_id: CLIENT_ID, code: p.authorizationCode, code_verifier: p.codeVerifier, redirect_uri: `${base}/deviceauth/callback`,
123
+ }, true, signal));
124
+ if (signal?.aborted)
125
+ throw new Error('Login cancelled');
126
+ await credentials.modify(id, async () => c);
127
+ return c;
128
+ }
129
+ if (p.status === 'slow_down')
130
+ interval += 5000;
131
+ await sleep(interval, signal);
132
+ }
133
+ },
134
+ checkAuth: async (id) => (PORTABLE.includes(id) && (await credentials.read(id))?.type === 'oauth' ? { source: 'OAuth', type: 'oauth' } : undefined),
135
+ /** Pi's rule: refresh under the store's lock when under 5 minutes (or `minOAuthValidityMs`) remain, re-checked there,
136
+ * so a sign-out or another refresh in between wins; undefined once signed out. */
137
+ async getAuth(id, { minOAuthValidityMs } = {}) {
138
+ if (!PORTABLE.includes(id))
139
+ return undefined;
140
+ const min = Math.max(5 * 60_000, minOAuthValidityMs ?? 0);
141
+ const soon = (c) => Date.now() + min >= c.expires;
142
+ let c = await credentials.read(id);
143
+ if (c?.type !== 'oauth')
144
+ return undefined;
145
+ if (soon(c)) {
146
+ c = await credentials.modify(id, async (now) => (now?.type === 'oauth' && soon(now) ? refresh(now) : undefined));
147
+ if (c?.type !== 'oauth')
148
+ return undefined;
149
+ if (minOAuthValidityMs !== undefined && soon(c))
150
+ throw new Error('OAuth refresh returned a token that expires too soon for openai-codex');
151
+ }
152
+ return { auth: { apiKey: c.access }, source: 'OAuth' };
153
+ },
154
+ logout: (id) => credentials.delete(id),
155
+ };
156
+ return engine;
157
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,11 @@
1
- export { Accounts, planOf, type AccountsOptions, type AuthHost, type Member, type SignIn, type Status } from './accounts.ts';
2
- export { PROVIDERS, offered, provider, type Provider, type Terms } from './catalogue.ts';
1
+ import { Accounts as Portable, type AccountsOptions, type AuthHost, type Loopback, type Member, type Platform } from './accounts.ts';
2
+ /** Listen on 127.0.0.1 only; no keep-alive, so a browser never lands on a listener from an earlier try. */
3
+ export declare const loopback: Loopback;
4
+ /** A computer: every provider Pi signs in to, and the loopback listener. */
5
+ export declare const computer: Platform;
6
+ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member = Member> extends Portable<R, M> {
7
+ constructor(opts?: AccountsOptions<M>, platform?: Platform);
8
+ }
9
+ export * from './portable.ts';
3
10
  export { INHERITED, emptyAuthContext, isolate } from './isolate.ts';
4
- export { REST_MS, classify, type Kind } from './limits.ts';
5
- export { fileStore, memoryStore } from './stores.ts';
6
- export { WORDS, callbackPage, clock, failure, say, signInError, type WordKey, type Why } from './words.ts';
11
+ export { fileStore, type SafeStorageLike } from './node-stores.ts';
package/dist/index.js CHANGED
@@ -1,6 +1,28 @@
1
- export { Accounts, planOf } from "./accounts.js";
2
- export { PROVIDERS, offered, provider } from "./catalogue.js";
1
+ // @byokit/accounts on a computer (Node, Electron's main process): Pi's own sign-in flows, and a listener for ChatGPT's
2
+ // page coming back to this computer, so its tab shows the app's words. Phones and browsers get portable.ts instead
3
+ // (package.json's "react-native" and "browser" conditions).
4
+ import { createServer } from 'node:http';
5
+ import { builtinModels } from '@earendil-works/pi-ai/providers/all';
6
+ import { Accounts as Portable } from "./accounts.js";
7
+ import { emptyAuthContext } from "./isolate.js";
8
+ /** Listen on 127.0.0.1 only; no keep-alive, so a browser never lands on a listener from an earlier try. */
9
+ export const loopback = (port, handle) => new Promise((resolve, reject) => {
10
+ const server = createServer(async (req, res) => {
11
+ const { status, html } = await handle(req.url ?? '/');
12
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', connection: 'close' });
13
+ res.end(html);
14
+ });
15
+ server.once('error', reject).listen(port, '127.0.0.1', () => resolve({ close: () => { server.close(); server.closeIdleConnections(); } }));
16
+ });
17
+ /** A computer: every provider Pi signs in to, and the loopback listener. */
18
+ export const computer = {
19
+ engine: (credentials) => builtinModels({ credentials, authContext: emptyAuthContext }),
20
+ signsIn: () => true,
21
+ loopback,
22
+ };
23
+ export class Accounts extends Portable {
24
+ constructor(opts = {}, platform = computer) { super(opts, platform); }
25
+ }
26
+ export * from "./portable.js";
3
27
  export { INHERITED, emptyAuthContext, isolate } from "./isolate.js";
4
- export { REST_MS, classify } from "./limits.js";
5
- export { fileStore, memoryStore } from "./stores.js";
6
- export { WORDS, callbackPage, clock, failure, say, signInError } from "./words.js";
28
+ export { fileStore } from "./node-stores.js";
@@ -0,0 +1,10 @@
1
+ import type { CredentialStore } from '@earendil-works/pi-ai';
2
+ /** The parts of Electron's `safeStorage` this uses; pass `safeStorage` from 'electron' (main process, after `ready`). */
3
+ export type SafeStorageLike = {
4
+ encryptString(text: string): Uint8Array;
5
+ decryptString(data: Buffer): string;
6
+ };
7
+ /** One person's sign-ins in a JSON file the app chooses (0600, in a 0700 folder), in the same shape as Pi's auth.json.
8
+ * With Electron's `safeStorage` the file is sealed with the OS keychain's key instead of plain JSON.
9
+ * ponytail: writes are serialized within this process only; add a file lock if two processes ever share one file. */
10
+ export declare function fileStore(path: string, safeStorage?: SafeStorageLike): CredentialStore;
@@ -0,0 +1,27 @@
1
+ // Desktop stores: a file the app chooses, optionally sealed with Electron's safeStorage (the OS keychain's key).
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { recordStore } from "./stores.js";
5
+ /** One person's sign-ins in a JSON file the app chooses (0600, in a 0700 folder), in the same shape as Pi's auth.json.
6
+ * With Electron's `safeStorage` the file is sealed with the OS keychain's key instead of plain JSON.
7
+ * ponytail: writes are serialized within this process only; add a file lock if two processes ever share one file. */
8
+ export function fileStore(path, safeStorage) {
9
+ const load = async () => {
10
+ try {
11
+ const raw = readFileSync(path);
12
+ return JSON.parse(safeStorage ? safeStorage.decryptString(raw) : raw.toString('utf8'));
13
+ }
14
+ catch (e) {
15
+ if (e?.code === 'ENOENT')
16
+ return {};
17
+ throw e;
18
+ }
19
+ };
20
+ const save = async (data) => {
21
+ const text = JSON.stringify(data, null, 2);
22
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
23
+ writeFileSync(`${path}.tmp`, safeStorage ? safeStorage.encryptString(text) : text, { mode: 0o600 });
24
+ renameSync(`${path}.tmp`, path);
25
+ };
26
+ return recordStore(load, save);
27
+ }
@@ -0,0 +1,6 @@
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 Provider, type Terms } from './catalogue.ts';
3
+ export { PORTABLE, claims, credentialOf, devicePoll, deviceStart, portableEngine, type EngineOptions, type Poll } from './engine.ts';
4
+ export { REST_MS, classify, type Kind } from './limits.ts';
5
+ export { browserStore, memoryStore, recordStore, secureStore, type SecureStoreLike } from './stores.ts';
6
+ export { WORDS, callbackPage, clock, failure, say, signInError, type WordKey, type Why } from './words.ts';
@@ -0,0 +1,8 @@
1
+ // @byokit/accounts on phones (React Native, Expo) and in browsers (a PWA, Electron's renderer): the same Accounts, with
2
+ // ChatGPT by device code (portableEngine) and the phone's or browser's own storage. No Node module is imported.
3
+ export { Accounts, planOf, portable } from "./accounts.js";
4
+ export { PROVIDERS, offered, provider } from "./catalogue.js";
5
+ export { PORTABLE, claims, credentialOf, devicePoll, deviceStart, portableEngine } from "./engine.js";
6
+ export { REST_MS, classify } from "./limits.js";
7
+ export { browserStore, memoryStore, recordStore, secureStore } from "./stores.js";
8
+ export { WORDS, callbackPage, clock, failure, say, signInError } from "./words.js";
package/dist/stores.d.ts CHANGED
@@ -1,5 +1,21 @@
1
- import { type CredentialStore } from '@earendil-works/pi-ai';
2
- export declare const memoryStore: () => CredentialStore;
3
- /** One person's sign-ins in a JSON file the app chooses (0600, in a 0700 folder), in the same shape as Pi's auth.json.
4
- * ponytail: writes are serialized within this process only; add a file lock if two processes ever share one file. */
5
- export declare function fileStore(path: string): CredentialStore;
1
+ import type { Credential, CredentialStore } from '@earendil-works/pi-ai';
2
+ export type Record = {
3
+ [providerId: string]: Credential;
4
+ };
5
+ /** A store over one whole record the platform loads and saves. Writes are serialized within this process; a write
6
+ * re-reads first, so a sign-in that took minutes never overwrites a provider that changed meanwhile. */
7
+ export declare function recordStore(load: () => Promise<Record>, save: (data: Record) => Promise<void>): CredentialStore;
8
+ export declare function memoryStore(): CredentialStore;
9
+ /** The parts of `expo-secure-store` this uses (Keychain on iOS, Keystore-encrypted on Android); pass the module itself. */
10
+ export type SecureStoreLike = {
11
+ getItemAsync(key: string): Promise<string | null>;
12
+ setItemAsync(key: string, value: string): Promise<void>;
13
+ deleteItemAsync(key: string): Promise<void>;
14
+ };
15
+ /** One person's sign-ins in the phone's secure storage: `secureStore(SecureStore, 'byokit.1')`. Keys may hold letters,
16
+ * digits, `.`, `-` and `_`. The record is split into pieces under the 2048 bytes expo-secure-store warns about, written
17
+ * as a new generation, then `name` is pointed at it: a crash mid-write leaves the old sign-ins whole. */
18
+ export declare function secureStore(secure: SecureStoreLike, name: string): CredentialStore;
19
+ /** One person's sign-ins in the browser's IndexedDB (a PWA, or Electron's renderer), under `name`. A browser has no
20
+ * keychain: anything running on this page could read them, so keep the page free of scripts you don't control. */
21
+ export declare function browserStore(name: string, db?: string): CredentialStore;
package/dist/stores.js CHANGED
@@ -1,42 +1,78 @@
1
- // Credential stores behind Pi's own CredentialStore seam: one per person, never a shared fallback.
2
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
- import { dirname } from 'node:path';
4
- import { InMemoryCredentialStore } from '@earendil-works/pi-ai';
5
- export const memoryStore = () => new InMemoryCredentialStore();
6
- /** One person's sign-ins in a JSON file the app chooses (0600, in a 0700 folder), in the same shape as Pi's auth.json.
7
- * ponytail: writes are serialized within this process only; add a file lock if two processes ever share one file. */
8
- export function fileStore(path) {
1
+ /** A store over one whole record the platform loads and saves. Writes are serialized within this process; a write
2
+ * re-reads first, so a sign-in that took minutes never overwrites a provider that changed meanwhile. */
3
+ export function recordStore(load, save) {
9
4
  let chain = Promise.resolve();
10
5
  const serial = (fn) => { const r = chain.then(fn); chain = r.catch(() => { }); return r; };
11
- const load = () => {
12
- try {
13
- return JSON.parse(readFileSync(path, 'utf8'));
14
- }
15
- catch (e) {
16
- if (e?.code === 'ENOENT')
17
- return {};
18
- throw e;
19
- }
20
- };
21
- const save = (data) => {
22
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
23
- writeFileSync(`${path}.tmp`, JSON.stringify(data, null, 2), { mode: 0o600 });
24
- renameSync(`${path}.tmp`, path);
25
- };
26
6
  return {
27
- read: async (id) => load()[id],
28
- list: async () => Object.entries(load()).map(([providerId, c]) => ({ providerId, type: c.type })),
7
+ read: async (id) => (await load())[id],
8
+ list: async () => Object.entries(await load()).map(([providerId, c]) => ({ providerId, type: c.type })),
29
9
  modify: (id, fn) => serial(async () => {
30
- const current = load()[id];
10
+ const current = (await load())[id];
31
11
  const next = await fn(current);
32
12
  if (next === undefined)
33
13
  return current;
34
- save({ ...load(), [id]: next }); // re-read: a sign-in can take minutes, and other providers may have changed meanwhile
14
+ await save({ ...(await load()), [id]: next });
35
15
  return next;
36
16
  }),
37
- delete: (id) => serial(async () => { const data = load(); if (id in data) {
17
+ delete: (id) => serial(async () => { const data = await load(); if (id in data) {
38
18
  delete data[id];
39
- save(data);
19
+ await save(data);
40
20
  } }),
41
21
  };
42
22
  }
23
+ export function memoryStore() {
24
+ let data = {};
25
+ return recordStore(async () => ({ ...data }), async (d) => { data = d; });
26
+ }
27
+ /** One person's sign-ins in the phone's secure storage: `secureStore(SecureStore, 'byokit.1')`. Keys may hold letters,
28
+ * digits, `.`, `-` and `_`. The record is split into pieces under the 2048 bytes expo-secure-store warns about, written
29
+ * as a new generation, then `name` is pointed at it: a crash mid-write leaves the old sign-ins whole. */
30
+ export function secureStore(secure, name) {
31
+ const head = async () => { const [gen = '0', n = '0'] = (await secure.getItemAsync(name))?.split(':') ?? []; return { gen: Number(gen), n: Number(n) }; };
32
+ const load = async () => {
33
+ const { gen, n } = await head();
34
+ let text = '';
35
+ for (let i = 0; i < n; i++)
36
+ text += (await secure.getItemAsync(`${name}.${gen}.${i}`)) ?? '';
37
+ return text ? JSON.parse(text) : {};
38
+ };
39
+ const save = async (data) => {
40
+ const old = await head();
41
+ const gen = old.gen + 1;
42
+ const parts = (Object.keys(data).length ? JSON.stringify(data) : '').match(/[\s\S]{1,1800}/g) ?? [];
43
+ for (const [i, part] of parts.entries())
44
+ await secure.setItemAsync(`${name}.${gen}.${i}`, part);
45
+ await secure.setItemAsync(name, `${gen}:${parts.length}`);
46
+ for (let i = 0; i < old.n; i++)
47
+ await secure.deleteItemAsync(`${name}.${old.gen}.${i}`);
48
+ // Leftovers of a write that crashed at this generation before.
49
+ for (let i = parts.length; (await secure.getItemAsync(`${name}.${gen}.${i}`)) !== null; i++)
50
+ await secure.deleteItemAsync(`${name}.${gen}.${i}`);
51
+ };
52
+ return recordStore(load, save);
53
+ }
54
+ /** One person's sign-ins in the browser's IndexedDB (a PWA, or Electron's renderer), under `name`. A browser has no
55
+ * keychain: anything running on this page could read them, so keep the page free of scripts you don't control. */
56
+ export function browserStore(name, db = 'byokit') {
57
+ const open = () => new Promise((resolve, reject) => {
58
+ const r = indexedDB.open(db, 1);
59
+ r.onupgradeneeded = () => r.result.createObjectStore('signins');
60
+ r.onsuccess = () => resolve(r.result);
61
+ r.onerror = () => reject(r.error);
62
+ });
63
+ const run = async (mode, fn) => {
64
+ const d = await open();
65
+ try {
66
+ return await new Promise((resolve, reject) => {
67
+ const t = d.transaction('signins', mode);
68
+ const r = fn(t.objectStore('signins'));
69
+ t.oncomplete = () => resolve(r.result);
70
+ t.onerror = t.onabort = () => reject(t.error);
71
+ });
72
+ }
73
+ finally {
74
+ d.close();
75
+ }
76
+ };
77
+ return recordStore(async () => (await run('readonly', (s) => s.get(name))) ?? {}, (data) => run('readwrite', (s) => s.put(data, name)));
78
+ }
@@ -17,3 +17,4 @@ export declare function decoy(root?: string): {
17
17
  /** Marks left by the decoy's extension or CLIs: something ran code from someone else's setup. */
18
18
  ran: () => string[];
19
19
  };
20
+ export { mockJwt, mockOpenAI, type MockOpenAIOptions } from './mock-openai.ts';
@@ -48,3 +48,4 @@ export function decoy(root = mkdtempSync(join(tmpdir(), 'byokit-decoy-'))) {
48
48
  ran: () => readdirSync(marks),
49
49
  };
50
50
  }
51
+ export { mockJwt, mockOpenAI } from "./mock-openai.js";
@@ -0,0 +1,31 @@
1
+ export type MockOpenAIOptions = {
2
+ port?: number;
3
+ host?: string;
4
+ plan?: string;
5
+ email?: string;
6
+ expiresIn?: number;
7
+ log?: (line: string) => void;
8
+ };
9
+ /** An access token as OpenAI shapes it: the account, the plan and the email in its claims. */
10
+ export declare const mockJwt: (plan?: string, email?: string, n?: number) => string;
11
+ export declare function mockOpenAI({ port, host, plan, email, expiresIn, log }?: MockOpenAIOptions): Promise<{
12
+ base: string;
13
+ state: {
14
+ /** Refresh tokens OpenAI still honours; a refresh spends the old one (rotation), sign-out revokes one. */
15
+ live: Set<string>;
16
+ requests: {
17
+ path: string;
18
+ body: string;
19
+ }[];
20
+ /** Refuse every refresh, as when the person signed out elsewhere. */
21
+ refuse: boolean;
22
+ /** Seconds each issued token lives. */
23
+ expiresIn: number;
24
+ /** Drop this many device-code polls on the floor, as a phone does to a backgrounded app. */
25
+ dropPolls: number;
26
+ };
27
+ approve: (userCode: string, deny?: boolean) => boolean;
28
+ /** The code most recently handed out. */
29
+ lastCode: () => string | undefined;
30
+ close: () => Promise<void>;
31
+ }>;
@@ -0,0 +1,112 @@
1
+ // OpenAI's sign-in, stood in for: device code, its page where a person types the code, token exchange and refresh
2
+ // (rotating), revoke, and the same CORS answer the real endpoints give, so a web page, a phone app or a test signs in
3
+ // end to end with no account and no real network. Run it alone for a demo or an emulator:
4
+ // node packages/accounts/src/testing/mock-openai.ts [port] (then approve on its /codex/device page)
5
+ import { createServer } from 'node:http';
6
+ import { fileURLToPath } from 'node:url';
7
+ /** An access token as OpenAI shapes it: the account, the plan and the email in its claims. */
8
+ export const mockJwt = (plan = 'plus', email = 'sara@example.com', n = 0) => ['eyJhbGciOiJub25lIn0', Buffer.from(JSON.stringify({
9
+ 'https://api.openai.com/auth': { chatgpt_account_id: 'acct-1', chatgpt_plan_type: plan }, 'https://api.openai.com/profile': { email }, n,
10
+ // As long as a real one, whose claims fill about 1.5 kB.
11
+ scp: ['openid', 'profile', 'email', 'offline_access'], pad: 'x'.repeat(1200),
12
+ })).toString('base64url'), 'sig'].join('.');
13
+ export async function mockOpenAI({ port = 0, host = '127.0.0.1', plan = 'plus', email = 'sara@example.com', expiresIn = 864_000, log } = {}) {
14
+ const codes = new Map();
15
+ let issued = 0, asked = 0;
16
+ const state = {
17
+ /** Refresh tokens OpenAI still honours; a refresh spends the old one (rotation), sign-out revokes one. */
18
+ live: new Set(),
19
+ requests: [],
20
+ /** Refuse every refresh, as when the person signed out elsewhere. */
21
+ refuse: false,
22
+ /** Seconds each issued token lives. */
23
+ expiresIn,
24
+ /** Drop this many device-code polls on the floor, as a phone does to a backgrounded app. */
25
+ dropPolls: 0,
26
+ };
27
+ const issue = () => {
28
+ const refresh = `rt_${++issued}`;
29
+ state.live.add(refresh);
30
+ return { access_token: mockJwt(plan, email, issued), refresh_token: refresh, expires_in: state.expiresIn, id_token: 'x' };
31
+ };
32
+ const approve = (userCode, deny = false) => {
33
+ const c = codes.get(userCode.trim().toUpperCase());
34
+ if (c)
35
+ Object.assign(c, deny ? { denied: true } : { approved: true });
36
+ return !!c;
37
+ };
38
+ const page = (words, form = true) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Stand-in OpenAI</title>
39
+ <body style="font:18px system-ui;max-width:28em;margin:3em auto;padding:0 1em"><h1>Stand-in OpenAI</h1><p id="words">${words}</p>${form ? `<form method="post">
40
+ <input name="user_code" id="code" autocomplete="off" placeholder="XXXX-XXXXX" style="font:inherit;padding:.4em"> <button id="continue" style="font:inherit;padding:.4em 1em">Continue</button></form>` : ''}</body>`;
41
+ const server = createServer(async (req, res) => {
42
+ let body = '';
43
+ for await (const chunk of req)
44
+ body += chunk;
45
+ const url = new URL(req.url ?? '/', 'http://x');
46
+ const form = new URLSearchParams(body);
47
+ state.requests.push({ path: url.pathname, body });
48
+ log?.(`${req.method} ${url.pathname} ${form.get('grant_type') ?? ''}`.trim());
49
+ const send = (status, data, type = 'application/json') => {
50
+ res.writeHead(status, { 'content-type': type, 'access-control-allow-origin': '*', 'access-control-allow-headers': 'content-type', 'access-control-allow-methods': 'POST, GET, OPTIONS' });
51
+ res.end(typeof data === 'string' ? data : JSON.stringify(data));
52
+ };
53
+ const json = () => { try {
54
+ return JSON.parse(body);
55
+ }
56
+ catch {
57
+ return {};
58
+ } };
59
+ if (req.method === 'OPTIONS')
60
+ return send(200, '');
61
+ switch (url.pathname) {
62
+ case '/api/accounts/deviceauth/usercode': {
63
+ const userCode = `MOCK-${String(10000 + ++asked).slice(-5)}`;
64
+ codes.set(userCode, { device: `da_${asked}` });
65
+ return send(200, { device_auth_id: codes.get(userCode).device, user_code: userCode, interval: '1' });
66
+ }
67
+ case '/api/accounts/deviceauth/token': {
68
+ if (state.dropPolls > 0 && state.dropPolls--)
69
+ return req.socket.destroy();
70
+ const { device_auth_id, user_code } = json();
71
+ const c = codes.get(user_code);
72
+ if (!c || c.device !== device_auth_id)
73
+ return send(400, { error: { code: 'deviceauth_invalid' } });
74
+ if (c.denied)
75
+ return send(400, { error: { code: 'access_denied', message: 'The user declined' } });
76
+ return c.approved ? send(200, { authorization_code: `ac_${user_code}`, code_verifier: 'cv' }) : send(403, { error: { code: 'deviceauth_authorization_pending' } });
77
+ }
78
+ case '/oauth/token':
79
+ if (form.get('grant_type') === 'authorization_code') {
80
+ const c = codes.get(form.get('code')?.replace(/^ac_/, '') ?? '');
81
+ if (!c?.approved)
82
+ return send(401, { error: { code: 'token_expired' } });
83
+ codes.delete(form.get('code').slice(3));
84
+ return send(200, issue());
85
+ }
86
+ if (state.refuse || !state.live.delete(form.get('refresh_token') ?? ''))
87
+ return send(401, { error: { code: 'refresh_token_reused', message: 'invalid_grant' } });
88
+ return send(200, issue());
89
+ case '/oauth/revoke':
90
+ state.live.delete(json().token);
91
+ return send(200, {});
92
+ case '/codex/device':
93
+ if (req.method !== 'POST')
94
+ return send(200, page('Type the code the app shows you.'), 'text/html; charset=utf-8');
95
+ return send(200, approve(form.get('user_code') ?? '') ? page('Signed in. Go back to the app.', false) : page("That code doesn't match. Try again."), 'text/html; charset=utf-8');
96
+ default:
97
+ return send(404, { error: 'not found' });
98
+ }
99
+ });
100
+ await new Promise((r) => server.listen(port, host, r));
101
+ const base = `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${server.address().port}`;
102
+ return {
103
+ base, state, approve,
104
+ /** The code most recently handed out. */
105
+ lastCode: () => [...codes.keys()].at(-1),
106
+ close: () => new Promise((r) => { server.close(() => r()); server.closeAllConnections(); }),
107
+ };
108
+ }
109
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
110
+ const m = await mockOpenAI({ port: Number(process.argv[2] ?? 1455), host: '0.0.0.0', log: (line) => console.log(new Date().toISOString().slice(11, 19), line) });
111
+ console.log(`stand-in OpenAI on ${m.base}; approve codes at ${m.base}/codex/device`);
112
+ }
package/dist/words.json CHANGED
@@ -1,4 +1,9 @@
1
1
  {
2
+ "signIn.opening": "Opening {name}…",
3
+ "signIn.waitingUrl": "Sign in on the {name} page that just opened.",
4
+ "signIn.waitingCode": "On the {name} page, type this code: {code}",
5
+ "signIn.pasteHint": "Having trouble? Copy the address from the browser and paste it here.",
6
+ "signIn.cancelled": "Sign-in stopped. Nothing was kept.",
2
7
  "signIn.expired": "The code expired before it was used. Tap Sign in with {name} for a new one.",
3
8
  "signIn.declined": "The sign-in was declined on the {name} page. Tap Sign in with {name} to try again.",
4
9
  "signIn.offline": "Couldn't reach {name}. Check the internet connection, then tap Sign in again.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/accounts",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -14,9 +14,21 @@
14
14
  },
15
15
  "exports": {
16
16
  ".": {
17
+ "react-native": {
18
+ "types": "./dist/portable.d.ts",
19
+ "default": "./dist/portable.js"
20
+ },
21
+ "browser": {
22
+ "types": "./dist/portable.d.ts",
23
+ "default": "./dist/portable.js"
24
+ },
17
25
  "types": "./dist/index.d.ts",
18
26
  "default": "./dist/index.js"
19
27
  },
28
+ "./portable": {
29
+ "types": "./dist/portable.d.ts",
30
+ "default": "./dist/portable.js"
31
+ },
20
32
  "./isolate": {
21
33
  "types": "./dist/isolate.d.ts",
22
34
  "default": "./dist/isolate.js"