@byokit/accounts 0.1.0 → 0.2.1

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,13 @@
1
1
  # @byokit/accounts
2
2
 
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.
3
+ Sign in with the AI plan you already pay for (ChatGPT on every platform; OpenRouter on computers; Grok and GitHub
4
+ Copilot hidden by default), inside your own app, into your app's own store: on a computer (Node, Electron), in a browser
5
+ (a PWA, Electron's renderer) and on a
6
+ phone (React Native and Expo, iOS and Android). One import; your bundler picks the platform's side
7
+ (`package.json`'s `react-native` and `browser` conditions).
8
+
9
+ On a computer it uses Pi's [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) sign-in
10
+ flows, pinned exactly:
6
11
 
7
12
  ```ts
8
13
  import { isolate } from '@byokit/accounts/isolate'; // first, before any Pi import
@@ -15,18 +20,59 @@ const shown = await accounts.login(1, 'chatgpt', { via: 'code' }); // { state: '
15
20
  (await accounts.status(1, 'chatgpt')).words; // "ChatGPT is connected."
16
21
  ```
17
22
 
23
+ On a phone or in a browser the same `Accounts` signs in to ChatGPT by device code with `fetch` alone (Pi's flows need
24
+ Node), into the phone's secure storage or the browser's IndexedDB:
25
+
26
+ ```ts
27
+ import * as SecureStore from 'expo-secure-store';
28
+ import { Accounts, secureStore } from '@byokit/accounts';
29
+
30
+ const accounts = new Accounts({ store: (member) => secureStore(SecureStore, `byokit.${member}`) });
31
+ const shown = await accounts.login(1, 'chatgpt'); // { state: 'waiting', via: 'code', code, url }: open url, show code
32
+ ```
33
+
34
+ Examples: [`examples/expo`](../../examples/expo) (iOS and Android bundles; Android emulator sign-in) and
35
+ [`examples/pwa`](../../examples/pwa) (browser sign-in).
36
+
37
+ ## Which sign-in works where
38
+
39
+ | | Computer (Node, Electron main) | Browser (PWA, Electron renderer) | Phone (React Native: iOS, Android) |
40
+ |---|---|---|---|
41
+ | ChatGPT | Its own page, straight back to this computer (port 1455); a code when asked or stuck | Device code | Device code |
42
+ | OpenRouter | Its own page, back to this computer (Pi's flow) | Not yet | Not yet |
43
+ | Grok, Copilot (hidden) | Pi's flows | No | No |
44
+ | Where sign-ins are kept | `fileStore(path)`, sealed with Electron's `safeStorage` when given | `browserStore(name)` (IndexedDB) | `secureStore(SecureStore, name)` (Keychain, Keystore) |
45
+
46
+ Device code works everywhere: OpenAI's sign-in endpoints answer any web page. The page-straight-back sign-in needs a
47
+ listener on the computer the browser runs on, so it is desktop only: ChatGPT sends the browser back to
48
+ `localhost:1455`, fixed for the client this signs in as. A web page can't call ChatGPT's model endpoint itself (it
49
+ doesn't answer other web pages), so a PWA's model calls go through the app's own server or relay.
50
+
18
51
  - **Catalogue** (`catalogue.json`): each provider with its terms status (`allowed`, `grey`, `partner`), a one-line reason
19
- and a source. The kit labels; your app decides what to offer (`new Accounts({ offer: ['chatgpt', 'grok'] })`).
20
- Claude plan sign-in is never offered: Anthropic reserves it for its own apps.
21
- - **Sign-in**: the provider's own page by default. For ChatGPT, whose page returns to this computer's port 1455, the kit
22
- listens there itself, so the tab shows your app's words (`new Accounts({ app: 'My App' })`) and only once they are
52
+ and a source. The kit labels; your app decides what to offer (`new Accounts({ offer: ['chatgpt'] })`). Without an
53
+ explicit `offer`, only sign-ins supported on this platform are shown; an explicit list is not platform-filtered, so
54
+ choose from the table above. Claude plan sign-in is never offered: Anthropic reserves it for its own apps.
55
+ - **Sign-in**: on computers, the provider's own page by default. For ChatGPT, whose page returns to this computer's
56
+ port 1455, the kit listens there itself, so the tab shows your app's words (`new Accounts({ app: 'My App' })`) and only once they are
23
57
  true. A code takes over when asked ("Having trouble?"), when the page never comes back, or when the port is taken by
24
58
  another sign-in. A 15-minute cap, nothing kept unless the engine can use it, and every failure is one plain sentence
25
59
  (`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)`.
60
+ - **Sign-out**: `logout(member, key)` attempts to revoke a ChatGPT token at OpenAI (`POST auth.openai.com/oauth/revoke`),
61
+ then deletes the local sign-in even if the revoke fails. A failed revoke rejects after local deletion; report it because
62
+ the remote sign-in may remain active. Within one store instance, a refresh already in progress finishes first, so
63
+ sign-out uses its rotated token. If a cancelled sign-in finishes late, `onSignOutError` reports a failed revoke of its
64
+ discarded credential (or it is logged when no handler is set).
65
+ - **One person, one store**: `memoryStore()`, `fileStore(path)` (0600, the same shape as Pi's `auth.json`),
66
+ `secureStore(SecureStore, name)` or `browserStore(name)`; any other storage with `recordStore(load, save)`. Writes are
67
+ serialized within a store instance; `browserStore` also uses Web Locks across tabs for the same provider when available.
68
+ Never a shared fallback. Browser storage is readable by scripts on your page: avoid untrusted scripts. Using another
69
+ engine with the same seam (Pi's coding-agent `ModelRuntime`)? Override `open(member)` with an engine whose
70
+ `credentialStore` is made with `boundStore(member, engineStore)` and whose `readCredential(id)` reads that store.
28
71
  - **Limits**: `failed(member, key, error)` rests an account until the provider said (or a default), marks a plan that
29
72
  doesn't include this use, and signs out only a sign-in that no longer refreshes. `ladder()` picks the next usable
30
73
  account; `keepFresh()` refreshes ahead of expiry. Limits come from errors only; no undocumented usage endpoint is read.
31
74
  - **Isolation**: ambient discovery is off (no environment variable or credential file is ever consulted), and
32
75
  `@byokit/accounts/testing` has the decoy-HOME harness and fs tracer to prove it in your own tests.
76
+ - **A stand-in OpenAI**: `mockOpenAI()` from `@byokit/accounts/testing` (or `node .../testing/mock-openai.ts [port]`)
77
+ answers device code, its page where a person types the code, token exchange, refresh and revoke, so tests and demos
78
+ sign in end to end with no account. Point the kit at it with `new Accounts({ authBase })`.
@@ -1,9 +1,16 @@
1
1
  import type { CredentialStore, Models } from '@earendil-works/pi-ai';
2
2
  import { type Provider } from './catalogue.ts';
3
3
  import { type Kind } from './limits.ts';
4
+ import { type EndingStore } from './stores.ts';
4
5
  import { type Why } from './words.ts';
5
6
  /** What signing in needs from an engine: Pi's `Models`, or anything shaped like it (the coding agent's `ModelRuntime`). */
6
- export type AuthHost = Pick<Models, 'login' | 'logout' | 'checkAuth' | 'getAuth'>;
7
+ type BoundStore = CredentialStore & {
8
+ signOut: (id: string, p: Provider) => Promise<void>;
9
+ };
10
+ export type AuthHost = Pick<Models, 'login' | 'logout' | 'checkAuth' | 'getAuth'> & {
11
+ readCredential: CredentialStore['read'];
12
+ credentialStore: BoundStore;
13
+ };
7
14
  export type Member = string | number;
8
15
  /** What the person sees while signing in: the provider's own page to open (`via: 'browser'`), or a code to type there
9
16
  * (`via: 'code'`), never the engine's own prompts. `why` names how a failed one failed, for apps that word it themselves. */
@@ -23,6 +30,21 @@ export type Status = {
23
30
  until?: number;
24
31
  words: string;
25
32
  };
33
+ /** Listens on this computer for the provider's page coming back: each request's path in, the page to answer with out. */
34
+ export type Loopback = (port: number, handle: (path: string) => Promise<{
35
+ status: number;
36
+ html: string;
37
+ }>) => Promise<{
38
+ close(): void;
39
+ }>;
40
+ /** What differs by platform: the engine that signs in, which providers it can, and (on a computer) a loopback listener. */
41
+ export type Platform = {
42
+ engine: (credentials: CredentialStore, authBase?: string) => AuthHost;
43
+ signsIn: (pi: string) => boolean;
44
+ loopback?: Loopback;
45
+ };
46
+ /** Phones and browsers: ChatGPT by device code, no listener. */
47
+ export declare const portable: Platform;
26
48
  export type AccountsOptions<M extends Member = Member> = {
27
49
  /** The accounts this app offers, in order. Default: every provider not hidden (ChatGPT, OpenRouter). */
28
50
  offer?: readonly string[];
@@ -36,6 +58,10 @@ export type AccountsOptions<M extends Member = Member> = {
36
58
  redirectMs?: number;
37
59
  /** Listen here for the provider's redirect instead of its fixed port (tests, so they never meet a real sign-in). */
38
60
  callbackPort?: number;
61
+ /** Where OpenAI's sign-in lives, for a stand-in in tests and demos (`mockOpenAI()` from `@byokit/accounts/testing`).
62
+ * Phones and browsers sign in and sign out there; on a computer Pi's engine always calls OpenAI, and only sign-out's
63
+ * revoke goes here. */
64
+ authBase?: string;
39
65
  };
40
66
  /** The ChatGPT plan behind a sign-in, from its own token: a work plan (Business, Enterprise, Edu) follows the employer's rules. */
41
67
  export declare function planOf(access: string): {
@@ -48,6 +74,10 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
48
74
  private opts;
49
75
  private runtimes;
50
76
  private stores;
77
+ private generations;
78
+ private signals;
79
+ private chains;
80
+ private signingOut;
51
81
  private flows;
52
82
  private ready;
53
83
  private lapsed;
@@ -60,9 +90,15 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
60
90
  onSignedIn?: (member: M, key: string) => void;
61
91
  /** Said once when a sign-in can no longer be refreshed. */
62
92
  onExpired?: (member: M, key: string) => void;
63
- constructor(opts?: AccountsOptions<M>);
93
+ onSignOutError?: (member: M, key: string, error: Error) => void;
94
+ private platform;
95
+ /** Offered: the providers named in `offer`, else every provider not hidden that this platform can sign in to. */
96
+ constructor(opts?: AccountsOptions<M>, platform?: Platform);
64
97
  /** A member's own store. */
65
- protected store(member: M): CredentialStore;
98
+ protected store(member: M): EndingStore;
99
+ private serial;
100
+ protected boundStore(member: M, raw: CredentialStore): BoundStore;
101
+ protected engine(member: M, raw: CredentialStore): Promise<R>;
66
102
  /** A member's engine, holding only their own sign-ins (`store(member)`). Override to use another engine with the same seam. */
67
103
  protected open(member: M): Promise<R>;
68
104
  runtime(member: M): Promise<R>;
@@ -115,12 +151,16 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
115
151
  paste(member: M, key: string, text: string): void;
116
152
  /** Stop a sign-in and forget it; nothing it started is kept. */
117
153
  cancel(member: M, key: string): void;
154
+ private refreshed;
118
155
  /** Refresh every signed-in account an hour ahead of expiry (call it now and then), so a sign-in never lapses while
119
156
  * nobody is looking. Only the provider refusing signs it out, and `onExpired` says so once; a network hiccup doesn't. */
120
157
  keepFresh(members: readonly M[]): Promise<void>;
121
158
  /** After the account turned a request away: true if its sign-in still refreshes; if not, it is signed out for good. */
122
159
  recheck(member: M, key: string): Promise<boolean>;
160
+ /** Signs out here, and at the provider too where it can end a sign-in (ChatGPT), best effort: the sign-in is deleted
161
+ * here whatever the provider answers. */
123
162
  logout(member: M, key: string): Promise<void>;
124
163
  view(member: M, key: string): SignIn | null;
125
164
  stop(): void;
126
165
  }
166
+ export {};
package/dist/accounts.js CHANGED
@@ -1,29 +1,38 @@
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 response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) });
24
+ if (!response.ok)
25
+ throw new Error(`ChatGPT sign-out failed (${response.status})`);
26
+ }
22
27
  export class Accounts {
23
28
  providers;
24
29
  opts;
25
30
  runtimes = new Map();
26
31
  stores = new Map();
32
+ generations = new Map();
33
+ signals = new WeakMap();
34
+ chains = new Map();
35
+ signingOut = new Map();
27
36
  flows = new Map();
28
37
  ready = new Map();
29
38
  lapsed = new Set();
@@ -36,18 +45,126 @@ export class Accounts {
36
45
  onSignedIn;
37
46
  /** Said once when a sign-in can no longer be refreshed. */
38
47
  onExpired;
39
- constructor(opts = {}) { this.opts = opts; this.providers = offered(opts.offer); }
48
+ onSignOutError;
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));
43
- if (!s)
44
- this.stores.set(String(member), s = (this.opts.store ?? memoryStore)(member));
59
+ if (!s) {
60
+ const base = (this.opts.store ?? memoryStore)(member);
61
+ let chain = Promise.resolve();
62
+ const serial = (fn) => { const result = chain.then(fn); chain = result.catch(() => { }); return result; };
63
+ s = {
64
+ read: (id) => base.read(id),
65
+ list: () => base.list(),
66
+ modify: (id, fn, options) => serial(() => base.modify(id, fn, options)),
67
+ delete: (id, options) => serial(() => base.delete(id, options)),
68
+ end: (id, fn) => serial(async () => {
69
+ if (typeof base.end === 'function')
70
+ return base.end(id, fn);
71
+ try {
72
+ await fn(await base.read(id));
73
+ }
74
+ finally {
75
+ await base.delete(id);
76
+ }
77
+ }),
78
+ };
79
+ this.stores.set(String(member), s);
80
+ }
45
81
  return s;
46
82
  }
47
- /** A member's engine, holding only their own sign-ins (`store(member)`). Override to use another engine with the same seam. */
48
- open(member) {
49
- return Promise.resolve(builtinModels({ credentials: this.store(member), authContext: emptyAuthContext }));
83
+ async serial(id, fn) {
84
+ const previous = this.chains.get(id) ?? Promise.resolve();
85
+ const work = previous.then(fn, fn);
86
+ const tail = work.then(() => { }, () => { });
87
+ this.chains.set(id, tail);
88
+ try {
89
+ return await work;
90
+ }
91
+ finally {
92
+ if (this.chains.get(id) === tail)
93
+ this.chains.delete(id);
94
+ }
50
95
  }
96
+ boundStore(member, raw) {
97
+ const key = (id) => `${member}:${this.providers.find((p) => p.pi === id)?.key ?? id}`;
98
+ return {
99
+ read: (id, options) => raw.read(id, options),
100
+ list: (options) => raw.list(options),
101
+ modify: (id, fn, options) => {
102
+ const account = key(id);
103
+ const stale = Symbol();
104
+ const started = options?.signal ? this.signals.get(options.signal) ?? this.generations.get(account) ?? 0 : this.generations.get(account) ?? 0;
105
+ const discard = async (next) => {
106
+ const p = this.providers.find((p) => p.pi === id);
107
+ if (next?.type === 'oauth' && p?.revoke) {
108
+ try {
109
+ await revoke(this.opts.authBase ? `${this.opts.authBase}/oauth/revoke` : p.revoke, p.clientId, next);
110
+ }
111
+ catch (e) {
112
+ const error = e instanceof Error ? e : new Error(String(e));
113
+ if (this.onSignOutError)
114
+ this.onSignOutError(member, p.key, error);
115
+ else
116
+ console.error(`sign-out ${p.key} for member ${member}:`, error);
117
+ throw error;
118
+ }
119
+ }
120
+ };
121
+ return this.serial(account, async () => {
122
+ if (started !== (this.generations.get(account) ?? 0)) {
123
+ await discard(await fn(undefined));
124
+ return undefined;
125
+ }
126
+ try {
127
+ return await raw.modify(id, async (current) => {
128
+ const next = await fn(current);
129
+ if (started !== (this.generations.get(account) ?? 0)) {
130
+ await discard(next);
131
+ throw stale;
132
+ }
133
+ return next;
134
+ }, options);
135
+ }
136
+ catch (e) {
137
+ if (e === stale)
138
+ return undefined;
139
+ throw e;
140
+ }
141
+ });
142
+ },
143
+ delete: (id, options) => this.serial(key(id), () => raw.delete(id, options)),
144
+ signOut: (id, p) => this.serial(key(id), async () => {
145
+ let error;
146
+ try {
147
+ const c = await raw.read(id);
148
+ if (c?.type === 'oauth')
149
+ await revoke(this.opts.authBase ? `${this.opts.authBase}/oauth/revoke` : p.revoke, p.clientId, c);
150
+ }
151
+ catch (e) {
152
+ error = e;
153
+ }
154
+ await raw.delete(id);
155
+ if (error)
156
+ throw error;
157
+ }),
158
+ };
159
+ }
160
+ engine(member, raw) {
161
+ const credentials = this.boundStore(member, raw);
162
+ return Promise.resolve(Object.assign(this.platform.engine(credentials, this.opts.authBase), {
163
+ credentialStore: credentials, readCredential: (id) => credentials.read(id),
164
+ }));
165
+ }
166
+ /** A member's engine, holding only their own sign-ins (`store(member)`). Override to use another engine with the same seam. */
167
+ open(member) { return this.engine(member, this.store(member)); }
51
168
  runtime(member) {
52
169
  let r = this.runtimes.get(String(member));
53
170
  if (!r)
@@ -70,7 +187,7 @@ export class Accounts {
70
187
  }
71
188
  /** Which ChatGPT the member signed in with: its plan, email, and whether it is a work account. Null when not signed in. */
72
189
  async plan(member) {
73
- const c = await this.store(member).read(this.offer('chatgpt').pi).catch(() => undefined);
190
+ const c = await (await this.runtime(member)).readCredential(this.offer('chatgpt').pi).catch(() => undefined);
74
191
  return c?.type === 'oauth' ? planOf(c.access) : null;
75
192
  }
76
193
  /** Whether the member's plan lacks this use; `on` records what the provider said, or that the person changed plans. */
@@ -144,6 +261,9 @@ export class Accounts {
144
261
  async login(member, key, body = {}) {
145
262
  this.offer(key);
146
263
  const id = `${member}:${key}`;
264
+ const pending = this.signingOut.get(id);
265
+ if (pending)
266
+ await pending.catch(() => { });
147
267
  const now = this.flows.get(id);
148
268
  if (now?.state === 'waiting' && body.via === 'code' && now.via === 'browser') {
149
269
  // "Having trouble?": the same sign-in carries on with a code instead.
@@ -152,7 +272,8 @@ export class Accounts {
152
272
  await Promise.race([visible, now.done]);
153
273
  }
154
274
  else if (now?.state !== 'waiting') {
155
- const flow = { state: 'waiting', abort: new AbortController() };
275
+ const flow = { state: 'waiting', abort: new AbortController(), generation: this.generations.get(id) ?? 0 };
276
+ this.signals.set(flow.abort.signal, flow.generation);
156
277
  this.flows.set(id, flow);
157
278
  const visible = new Promise((r) => (flow.shown = r));
158
279
  flow.done = this.signIn(member, key, body, flow);
@@ -207,7 +328,7 @@ export class Accounts {
207
328
  const stuck = setTimeout(() => this.toCode(flow), this.opts.redirectMs ?? 3 * 60_000);
208
329
  // Listen where the provider sends the browser back (the engine then finds the port taken and waits to be handed the address).
209
330
  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;
331
+ const catcher = port && body.via !== 'code' && this.platform.loopback ? await this.catchRedirect(this.platform.loopback, flow, p.name, port).catch(() => null) : undefined;
211
332
  try {
212
333
  if (catcher === null)
213
334
  throw Object.assign(new Error('port busy'), { why: 'busy' });
@@ -220,14 +341,16 @@ export class Accounts {
220
341
  throw e;
221
342
  Object.assign(flow, { url: undefined, code: undefined, via: 'code' });
222
343
  catcher?.close();
223
- catcher?.closeIdleConnections();
224
344
  await attempt('code');
225
345
  }
226
- // Never half signed in: only a sign-in the engine can use counts.
346
+ if (flow.generation !== (this.generations.get(id) ?? 0) || flow.state !== 'waiting')
347
+ return;
227
348
  if (!(await rt.checkAuth(p.pi).catch(() => undefined))) {
228
349
  await rt.logout(p.pi).catch(() => { });
229
350
  throw new Error('no usable credential');
230
351
  }
352
+ if (flow.generation !== (this.generations.get(id) ?? 0) || flow.state !== 'waiting')
353
+ return;
231
354
  flow.state = 'done';
232
355
  this.ready.set(id, true);
233
356
  for (const s of [this.lapsed, this.without])
@@ -248,34 +371,31 @@ export class Accounts {
248
371
  clearTimeout(timer);
249
372
  clearTimeout(stuck);
250
373
  catcher?.close();
251
- catcher?.closeIdleConnections();
252
374
  this.onChange?.(member, key);
253
375
  }
254
376
  }
255
377
  /** Listen where the provider sends the browser back; rejects if something else on this computer already listens there. */
256
- catchRedirect(flow, name, port) {
378
+ catchRedirect(loopback, flow, name, port) {
257
379
  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)); };
380
+ const page = (status, words, close = false) => ({ status, html: callbackPage(this.opts.app ?? name, words, close) });
381
+ return loopback(port, async (path) => {
382
+ const q = new URL(path, 'http://localhost').searchParams;
262
383
  if (!flow.oauthState || q.get('state') !== flow.oauthState || flow.state !== 'waiting')
263
384
  return page(400, say('callback.outOfDate', { app, name }));
264
385
  if (q.get('error'))
265
386
  flow.refuse?.(new Error(q.get('error')));
266
387
  // The engine only reads the query; the address it expects is the provider's registered one, on its fixed port.
267
388
  else
268
- flow.paste?.(`http://localhost:${port}${req.url}`);
389
+ flow.paste?.(`http://localhost:${port}${path}`);
269
390
  // 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())]);
391
+ await Promise.race([flow.done, new Promise((r) => setTimeout(r, 30_000).unref?.())]);
271
392
  const end = flow.state;
272
393
  if (end === 'done')
273
394
  return page(200, say('callback.done', { app }), true);
274
395
  if (flow.why === 'declined')
275
396
  return page(200, say('callback.declined', { app }), true);
276
- page(200, end === 'failed' ? say('callback.failed', { app, error: flow.error ?? '' }) : say('callback.nearly', { app }));
397
+ return page(200, end === 'failed' ? say('callback.failed', { app, error: flow.error ?? '' }) : say('callback.nearly', { app }));
277
398
  });
278
- return new Promise((resolve, reject) => { server.once('error', reject).listen(port, '127.0.0.1', () => resolve(server)); });
279
399
  }
280
400
  /** The redirect address (or a code) pasted back, for when the browser couldn't return to this computer by itself. */
281
401
  paste(member, key, text) {
@@ -295,6 +415,17 @@ export class Accounts {
295
415
  this.flows.delete(`${member}:${key}`);
296
416
  this.onChange?.(member, key);
297
417
  }
418
+ async refreshed(member, key, minOAuthValidityMs) {
419
+ const pi = this.offer(key).pi;
420
+ return (await this.runtime(member)).getAuth(pi, { minOAuthValidityMs }).then(Boolean, async (e) => {
421
+ if (offline(e))
422
+ return true;
423
+ if (e?.message !== `OAuth refresh returned a token that expires too soon for ${pi}`)
424
+ return false;
425
+ const c = await this.store(member).read(pi);
426
+ return c?.type === 'oauth' && c.expires > Date.now();
427
+ });
428
+ }
298
429
  /** Refresh every signed-in account an hour ahead of expiry (call it now and then), so a sign-in never lapses while
299
430
  * nobody is looking. Only the provider refusing signs it out, and `onExpired` says so once; a network hiccup doesn't. */
300
431
  async keepFresh(members) {
@@ -302,7 +433,7 @@ export class Accounts {
302
433
  for (const p of this.providers) {
303
434
  if (this.ready.get(`${m}:${p.key}`) !== true)
304
435
  continue;
305
- const ok = await (await this.runtime(m)).getAuth(p.pi, { minOAuthValidityMs: 60 * 60_000 }).then(Boolean, offline);
436
+ const ok = await this.refreshed(m, p.key, 60 * 60_000);
306
437
  if (!ok) {
307
438
  this.forget(m, p.key);
308
439
  this.onExpired?.(m, p.key);
@@ -311,17 +442,45 @@ export class Accounts {
311
442
  }
312
443
  /** After the account turned a request away: true if its sign-in still refreshes; if not, it is signed out for good. */
313
444
  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);
445
+ const ok = await this.refreshed(member, key, 365 * 86_400_000);
315
446
  if (!ok) {
316
447
  await this.logout(member, key).catch(() => { });
317
448
  this.forget(member, key);
318
449
  }
319
450
  return ok;
320
451
  }
452
+ /** Signs out here, and at the provider too where it can end a sign-in (ChatGPT), best effort: the sign-in is deleted
453
+ * here whatever the provider answers. */
321
454
  async logout(member, key) {
322
- await (await this.runtime(member)).logout(this.offer(key).pi);
323
- this.ready.set(`${member}:${key}`, false);
324
- this.onChange?.(member, key);
455
+ const p = this.offer(key);
456
+ const id = `${member}:${key}`;
457
+ this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
458
+ this.cancel(member, key);
459
+ const work = (async () => {
460
+ const rt = await this.runtime(member);
461
+ let error;
462
+ try {
463
+ if (p.revoke)
464
+ await rt.credentialStore.signOut(p.pi, p);
465
+ else
466
+ await rt.logout(p.pi);
467
+ }
468
+ catch (e) {
469
+ error = e;
470
+ }
471
+ this.ready.set(id, false);
472
+ this.onChange?.(member, key);
473
+ if (error)
474
+ throw error;
475
+ })();
476
+ this.signingOut.set(id, work);
477
+ try {
478
+ await work;
479
+ }
480
+ finally {
481
+ if (this.signingOut.get(id) === work)
482
+ this.signingOut.delete(id);
483
+ }
325
484
  }
326
485
  view(member, key) {
327
486
  const f = this.flows.get(`${member}:${key}`);
@@ -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,167 @@
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}`);
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');
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' };
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
+ const detail = code === 'deviceauth_expired' || code === 'access_denied' ? `: ${code}` : '';
43
+ return { status: 'failed', message: `OpenAI Codex device auth failed with status ${status}${detail}` };
44
+ }
45
+ /** A token response as the stored credential, the same shape Pi keeps. */
46
+ export function credentialOf(j, now = Date.now()) {
47
+ if (typeof j?.access_token !== 'string' || typeof j.refresh_token !== 'string' || typeof j.expires_in !== 'number')
48
+ throw new Error('OpenAI Codex token response missing fields');
49
+ let accountId;
50
+ try {
51
+ accountId = claims(j.access_token)['https://api.openai.com/auth']?.chatgpt_account_id;
52
+ }
53
+ catch { }
54
+ if (typeof accountId !== 'string' || !accountId)
55
+ throw new Error('Failed to extract accountId from token');
56
+ return { type: 'oauth', access: j.access_token, refresh: j.refresh_token, expires: now + j.expires_in * 1000, accountId };
57
+ }
58
+ /** Wait, unless the sign-in is cancelled first. */
59
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
60
+ if (signal?.aborted)
61
+ return reject(new Error('Login cancelled'));
62
+ const t = setTimeout(() => { signal?.removeEventListener('abort', stop); resolve(); }, ms);
63
+ const stop = () => { clearTimeout(t); reject(new Error('Login cancelled')); };
64
+ signal?.addEventListener('abort', stop, { once: true });
65
+ });
66
+ /** A member's engine on phones and in browsers: ChatGPT's device-code sign-in, refresh and sign-out, into `credentials`. */
67
+ export function portableEngine(credentials, { base = 'https://auth.openai.com' } = {}) {
68
+ const post = async (path, body, form = false, signal) => {
69
+ try {
70
+ const res = await fetch(base + path, {
71
+ method: 'POST', signal,
72
+ headers: { 'content-type': form ? 'application/x-www-form-urlencoded' : 'application/json' },
73
+ body: form ? new URLSearchParams(body).toString() : JSON.stringify(body),
74
+ });
75
+ return { status: res.status, body: await res.text() };
76
+ }
77
+ catch (e) {
78
+ if (signal?.aborted)
79
+ throw new Error('Login cancelled');
80
+ throw e;
81
+ }
82
+ };
83
+ const tokens = async (what, r) => {
84
+ if (r.status < 200 || r.status > 299)
85
+ throw new Error(`OpenAI Codex token ${what} failed (${r.status})`);
86
+ return credentialOf(json(r.body));
87
+ };
88
+ const refresh = async (c) => {
89
+ const stop = new AbortController();
90
+ const t = setTimeout(() => stop.abort(), 15_000);
91
+ try {
92
+ return await tokens('refresh', await post('/oauth/token', { grant_type: 'refresh_token', refresh_token: c.refresh, client_id: CLIENT_ID }, true, stop.signal));
93
+ }
94
+ catch (e) {
95
+ throw new Error(`OAuth refresh failed for openai-codex: ${e?.message ?? e}`);
96
+ }
97
+ finally {
98
+ clearTimeout(t);
99
+ }
100
+ };
101
+ const known = (id) => { if (!PORTABLE.includes(id))
102
+ throw new Error(`${id} can't be signed in to on this device`); };
103
+ const engine = {
104
+ async login(id, _type, { signal, notify }) {
105
+ known(id);
106
+ const asked = await post('/api/accounts/deviceauth/usercode', { client_id: CLIENT_ID }, false, signal);
107
+ const start = deviceStart(asked.status, asked.body);
108
+ notify({ type: 'device_code', userCode: start.userCode, verificationUri: `${base}/codex/device`, intervalSeconds: start.intervalSeconds, expiresInSeconds: CODE_LIVES_S });
109
+ let interval = Math.max(1000, start.intervalSeconds * 1000);
110
+ for (const deadline = Date.now() + CODE_LIVES_S * 1000;;) {
111
+ if (Date.now() >= deadline)
112
+ throw new Error('Device flow timed out');
113
+ // A poll that can't get through waits for the next: a phone cuts a backgrounded app's network while the person
114
+ // is typing the code in the browser (Android 15 and later), which isn't the sign-in failing.
115
+ const r = await post('/api/accounts/deviceauth/token', { device_auth_id: start.deviceAuthId, user_code: start.userCode }, false, signal)
116
+ .catch((e) => { if (signal?.aborted)
117
+ throw e; return { status: 0, body: '' }; });
118
+ const p = r.status ? devicePoll(r.status, r.body) : { status: 'pending' };
119
+ if (p.status === 'failed')
120
+ throw new Error(p.message);
121
+ if (p.status === 'complete') {
122
+ const c = await tokens('exchange', await post('/oauth/token', {
123
+ grant_type: 'authorization_code', client_id: CLIENT_ID, code: p.authorizationCode, code_verifier: p.codeVerifier, redirect_uri: `${base}/deviceauth/callback`,
124
+ }, true, signal));
125
+ if (signal?.aborted)
126
+ throw new Error('Login cancelled');
127
+ let wrote = false;
128
+ await credentials.modify(id, async () => {
129
+ if (signal?.aborted)
130
+ return undefined;
131
+ wrote = true;
132
+ return c;
133
+ }, { signal });
134
+ if (signal?.aborted) {
135
+ if (wrote)
136
+ await credentials.delete(id);
137
+ throw new Error('Login cancelled');
138
+ }
139
+ return c;
140
+ }
141
+ if (p.status === 'slow_down')
142
+ interval += 5000;
143
+ await sleep(interval, signal);
144
+ }
145
+ },
146
+ checkAuth: async (id) => (PORTABLE.includes(id) && (await credentials.read(id))?.type === 'oauth' ? { source: 'OAuth', type: 'oauth' } : undefined),
147
+ /** Pi's rule: refresh under the store's lock when under 5 minutes (or `minOAuthValidityMs`) remain, re-checked there,
148
+ * so a sign-out or another refresh in between wins; undefined once signed out. */
149
+ async getAuth(id, { minOAuthValidityMs } = {}) {
150
+ if (!PORTABLE.includes(id))
151
+ return undefined;
152
+ const min = Math.max(5 * 60_000, minOAuthValidityMs ?? 0);
153
+ const soon = (c) => Date.now() + min >= c.expires;
154
+ let c = await credentials.read(id);
155
+ if (c?.type !== 'oauth')
156
+ return undefined;
157
+ if (soon(c)) {
158
+ c = await credentials.modify(id, async (now) => (now?.type === 'oauth' && soon(now) ? refresh(now) : undefined));
159
+ if (c?.type !== 'oauth')
160
+ return undefined;
161
+ }
162
+ return { auth: { apiKey: c.access }, source: 'OAuth' };
163
+ },
164
+ logout: (id) => credentials.delete(id),
165
+ };
166
+ return engine;
167
+ }
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,24 @@
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
+ export type EndingStore = CredentialStore & {
6
+ end(id: string, fn: (c: Credential | undefined) => Promise<void>): Promise<void>;
7
+ };
8
+ /** A store over one whole record the platform loads and saves. Writes are serialized within this process; a write
9
+ * re-reads first, so a sign-in that took minutes never overwrites a provider that changed meanwhile. */
10
+ export declare function recordStore(load: () => Promise<Record>, save: (data: Record) => Promise<void>): EndingStore;
11
+ export declare function memoryStore(): CredentialStore;
12
+ /** The parts of `expo-secure-store` this uses (Keychain on iOS, Keystore-encrypted on Android); pass the module itself. */
13
+ export type SecureStoreLike = {
14
+ getItemAsync(key: string): Promise<string | null>;
15
+ setItemAsync(key: string, value: string): Promise<void>;
16
+ deleteItemAsync(key: string): Promise<void>;
17
+ };
18
+ /** One person's sign-ins in the phone's secure storage: `secureStore(SecureStore, 'byokit.1')`. Keys may hold letters,
19
+ * digits, `.`, `-` and `_`. The record is split into pieces under the 2048 bytes expo-secure-store warns about, written
20
+ * as a new generation, then `name` is pointed at it: a crash mid-write leaves the old sign-ins whole. */
21
+ export declare function secureStore(secure: SecureStoreLike, name: string): CredentialStore;
22
+ /** One person's sign-ins in the browser's IndexedDB (a PWA, or Electron's renderer), under `name`. A browser has no
23
+ * keychain: anything running on this page could read them, so keep the page free of scripts you don't control. */
24
+ export declare function browserStore(name: string, db?: string): EndingStore;
package/dist/stores.js CHANGED
@@ -1,42 +1,99 @@
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 })),
29
- modify: (id, fn) => serial(async () => {
30
- const current = load()[id];
7
+ read: async (id) => (await load())[id],
8
+ list: async () => Object.entries(await load()).map(([providerId, c]) => ({ providerId, type: c.type })),
9
+ modify: (id, fn, options) => serial(async () => {
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
+ if (options?.signal?.aborted)
15
+ throw new Error('Login cancelled');
16
+ await save({ ...(await load()), [id]: next });
35
17
  return next;
36
18
  }),
37
- delete: (id) => serial(async () => { const data = load(); if (id in data) {
19
+ delete: (id) => serial(async () => { const data = await load(); if (id in data) {
38
20
  delete data[id];
39
- save(data);
21
+ await save(data);
40
22
  } }),
23
+ end: (id, fn) => serial(async () => {
24
+ try {
25
+ await fn((await load())[id]);
26
+ }
27
+ finally {
28
+ const data = await load();
29
+ if (id in data) {
30
+ delete data[id];
31
+ await save(data);
32
+ }
33
+ }
34
+ }),
35
+ };
36
+ }
37
+ export function memoryStore() {
38
+ let data = {};
39
+ return recordStore(async () => ({ ...data }), async (d) => { data = d; });
40
+ }
41
+ /** One person's sign-ins in the phone's secure storage: `secureStore(SecureStore, 'byokit.1')`. Keys may hold letters,
42
+ * digits, `.`, `-` and `_`. The record is split into pieces under the 2048 bytes expo-secure-store warns about, written
43
+ * as a new generation, then `name` is pointed at it: a crash mid-write leaves the old sign-ins whole. */
44
+ export function secureStore(secure, name) {
45
+ const head = async () => { const [gen = '0', n = '0'] = (await secure.getItemAsync(name))?.split(':') ?? []; return { gen: Number(gen), n: Number(n) }; };
46
+ const load = async () => {
47
+ const { gen, n } = await head();
48
+ let text = '';
49
+ for (let i = 0; i < n; i++)
50
+ text += (await secure.getItemAsync(`${name}.${gen}.${i}`)) ?? '';
51
+ return text ? JSON.parse(text) : {};
52
+ };
53
+ const save = async (data) => {
54
+ const old = await head();
55
+ const gen = old.gen + 1;
56
+ const parts = (Object.keys(data).length ? JSON.stringify(data) : '').match(/[\s\S]{1,1800}/g) ?? [];
57
+ for (const [i, part] of parts.entries())
58
+ await secure.setItemAsync(`${name}.${gen}.${i}`, part);
59
+ await secure.setItemAsync(name, `${gen}:${parts.length}`);
60
+ for (let i = 0; i < old.n; i++)
61
+ await secure.deleteItemAsync(`${name}.${old.gen}.${i}`);
62
+ // Leftovers of a write that crashed at this generation before.
63
+ for (let i = parts.length; (await secure.getItemAsync(`${name}.${gen}.${i}`)) !== null; i++)
64
+ await secure.deleteItemAsync(`${name}.${gen}.${i}`);
65
+ };
66
+ return recordStore(load, save);
67
+ }
68
+ /** One person's sign-ins in the browser's IndexedDB (a PWA, or Electron's renderer), under `name`. A browser has no
69
+ * keychain: anything running on this page could read them, so keep the page free of scripts you don't control. */
70
+ export function browserStore(name, db = 'byokit') {
71
+ const open = () => new Promise((resolve, reject) => {
72
+ const r = indexedDB.open(db, 1);
73
+ r.onupgradeneeded = () => r.result.createObjectStore('signins');
74
+ r.onsuccess = () => resolve(r.result);
75
+ r.onerror = () => reject(r.error);
76
+ });
77
+ const run = async (mode, fn) => {
78
+ const d = await open();
79
+ try {
80
+ return await new Promise((resolve, reject) => {
81
+ const t = d.transaction('signins', mode);
82
+ const r = fn(t.objectStore('signins'));
83
+ t.oncomplete = () => resolve(r.result);
84
+ t.onerror = t.onabort = () => reject(t.error);
85
+ });
86
+ }
87
+ finally {
88
+ d.close();
89
+ }
90
+ };
91
+ const store = recordStore(async () => (await run('readonly', (s) => s.get(name))) ?? {}, (data) => run('readwrite', (s) => s.put(data, name)));
92
+ const locked = async (id, fn) => typeof navigator !== 'undefined' && navigator.locks ? await navigator.locks.request(`byokit:${db}:${name}:${id}`, fn) : fn();
93
+ return {
94
+ ...store,
95
+ modify: (id, fn, options) => locked(id, () => store.modify(id, fn, options)),
96
+ delete: (id, options) => locked(id, () => store.delete(id, options)),
97
+ end: (id, fn) => locked(id, () => store.end(id, fn)),
41
98
  };
42
99
  }
@@ -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] (21455 by default, never ChatGPT's own 1455)
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] ?? 21455), 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.1",
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,6 +14,14 @@
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
  },