@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 +55 -9
- package/dist/accounts.d.ts +43 -3
- package/dist/accounts.js +195 -36
- package/dist/catalogue.d.ts +4 -1
- package/dist/catalogue.json +2 -0
- package/dist/engine.d.ts +30 -0
- package/dist/engine.js +167 -0
- package/dist/index.d.ts +10 -5
- package/dist/index.js +27 -5
- package/dist/node-stores.d.ts +10 -0
- package/dist/node-stores.js +27 -0
- package/dist/portable.d.ts +6 -0
- package/dist/portable.js +8 -0
- package/dist/stores.d.ts +24 -5
- package/dist/stores.js +87 -30
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/mock-openai.d.ts +31 -0
- package/dist/testing/mock-openai.js +112 -0
- package/dist/words.json +5 -0
- package/package.json +9 -1
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
|
|
4
|
-
own app, into your app's own store
|
|
5
|
-
|
|
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'
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
- **
|
|
27
|
-
|
|
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 })`.
|
package/dist/accounts.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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):
|
|
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 {
|
|
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
|
|
10
|
+
let c = {};
|
|
14
11
|
try {
|
|
15
|
-
|
|
12
|
+
c = claims(access);
|
|
16
13
|
}
|
|
17
14
|
catch { }
|
|
18
|
-
const plan = String(
|
|
19
|
-
return { plan, email: String(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
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}${
|
|
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
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
this.
|
|
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}`);
|
package/dist/catalogue.d.ts
CHANGED
|
@@ -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;
|
package/dist/catalogue.json
CHANGED
|
@@ -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.",
|
package/dist/engine.d.ts
ADDED
|
@@ -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
|
-
|
|
2
|
-
|
|
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 {
|
|
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
|
-
|
|
2
|
-
|
|
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 {
|
|
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';
|
package/dist/portable.js
ADDED
|
@@ -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 {
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/testing/index.d.ts
CHANGED
package/dist/testing/index.js
CHANGED
|
@@ -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
|
|
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
|
},
|