@byokit/accounts 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +38 -7
- package/SECURITY.md +99 -0
- package/dist/accounts.js +2 -2
- package/dist/node-stores.d.ts +8 -5
- package/dist/node-stores.js +65 -11
- package/dist/portable.d.ts +1 -1
- package/dist/portable.js +1 -1
- package/dist/responses.d.ts +14 -3
- package/dist/responses.js +35 -4
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.8.0 (2026-09-30)
|
|
6
|
+
|
|
7
|
+
- SECURITY: Desktop fileStore now requires a sealing adapter, rejects insecure Electron storage backends, and refuses symlink or permissive credential files. Plaintext stores must revoke old credentials and sign in again; previously sealed stores remain readable with the same adapter.
|
|
8
|
+
- SECURITY: Credential writes use exclusive random temporary files with no-follow opens and file sync before atomic replacement when Node permissions allow it; default sign-in and discarded-credential revoke logs no longer include raw provider errors or member identifiers.
|
|
9
|
+
|
|
10
|
+
## 0.7.1 (2026-09-30)
|
|
11
|
+
|
|
12
|
+
- FIX: a cut-off answer is now reported as cut off: `respond` throws `IncompleteError` with its reason and partial output, and notifies `onEvent`, instead of returning it as finished.
|
|
13
|
+
|
|
5
14
|
## 0.7.0 (2026-09-30)
|
|
6
15
|
|
|
7
16
|
- Add the portable `chatgptPlan` adapter for a host-validated official token-sharing session, checking
|
package/README.md
CHANGED
|
@@ -77,9 +77,11 @@ flows, pinned exactly:
|
|
|
77
77
|
```ts
|
|
78
78
|
import { isolate } from '@byokit/accounts/isolate'; // first, before any Pi import
|
|
79
79
|
isolate('/path/to/app/engine'); // scrub inherited Pi settings and provider keys
|
|
80
|
-
|
|
80
|
+
const { Accounts, fileStore } = await import('@byokit/accounts');
|
|
81
|
+
const { app, safeStorage } = await import('electron');
|
|
82
|
+
await app.whenReady();
|
|
81
83
|
|
|
82
|
-
const accounts = new Accounts({ store: (member) => fileStore(`/path/to/app/people/${member}/auth.json
|
|
84
|
+
const accounts = new Accounts({ store: (member) => fileStore(`/path/to/app/people/${member}/auth.json`, safeStorage) });
|
|
83
85
|
const shown = await accounts.login(1, 'chatgpt', { via: 'code' }); // { state: 'waiting', code, url }
|
|
84
86
|
// show shown.code and shown.url; the sign-in finishes by itself
|
|
85
87
|
(await accounts.status(1, 'chatgpt')).words; // "ChatGPT is connected."
|
|
@@ -116,10 +118,10 @@ and [`examples/pwa`](../../examples/pwa) (browser sign-in).
|
|
|
116
118
|
|---|---|
|
|
117
119
|
| `Accounts` | Sign-in, status, sign-out, asking and limits for each member: `login`, `finished`, `status`, `plan`, `logout`, `respond`, `failed`, `ladder`, `keepFresh` |
|
|
118
120
|
| `portable`, `computer`, `loopback` | The platform `Accounts` runs on: device code with `fetch` alone, or (Node entry only) Pi's flows and the loopback listener |
|
|
119
|
-
| `memoryStore`, `fileStore`, `secureStore`, `browserStore`, `recordStore` | One store per person: in memory, a 0600 file (Node entry only), Keychain/Keystore, IndexedDB, or your own load and save |
|
|
121
|
+
| `memoryStore`, `fileStore`, `secureStore`, `browserStore`, `recordStore` | One store per person: in memory, a sealed 0600 file (Node entry only), Keychain/Keystore, IndexedDB, or your own load and save |
|
|
120
122
|
| `offered`, `provider`, `PROVIDERS` | The catalogue: each provider's billing, terms status, reason and source |
|
|
121
123
|
| `billingWords`, `say`, `WORDS`, `signInError`, `failure`, `clock`, `callbackPage` | The plain sentences every app shows the same way (`words.json`), a time in words, and the page a browser sees after a sign-in |
|
|
122
|
-
| `respond`, `ResponseError`, `sseReader`, `limitResponse`, `isFunctionCall` | Ask ChatGPT's answers endpoint with a sign-in, with tools, pictures, thinking effort and an answer shape; the error with the words to show and the kind acted on |
|
|
124
|
+
| `respond`, `ResponseError`, `IncompleteError`, `sseReader`, `limitResponse`, `isFunctionCall` | Ask ChatGPT's answers endpoint with a sign-in, with tools, pictures, thinking effort and an answer shape; the error with the words to show and the kind acted on |
|
|
123
125
|
| `classify`, `REST_MS` | An error's kind (limit, overload, plan without this use, lapsed sign-in, network) and default rest times |
|
|
124
126
|
| `planOf`, `claims` | The ChatGPT plan and email behind a sign-in, from its own token |
|
|
125
127
|
| `deviceStart`, `devicePoll`, `credentialOf`, `portableEngine`, `PORTABLE` | The device-code flow, the sign-in built from a token answer, and the engine under `portable` |
|
|
@@ -135,7 +137,7 @@ and [`examples/pwa`](../../examples/pwa) (browser sign-in).
|
|
|
135
137
|
| ChatGPT (subscription) | Its own page, straight back to this computer (port 1455); a code when asked or stuck | Device code | Device code |
|
|
136
138
|
| OpenRouter (API billing) | Its own page, back to this computer (Pi's flow), when an app offers it (never by default) | Not yet | Not yet |
|
|
137
139
|
| Grok, Copilot (hidden) | Pi's flows | No | No |
|
|
138
|
-
| Where sign-ins are kept | `fileStore(path)`,
|
|
140
|
+
| Where sign-ins are kept | `fileStore(path, safeStorage)`, sealing required | `browserStore(name)` (IndexedDB) | `secureStore(SecureStore, name)` (Keychain, Keystore) |
|
|
139
141
|
|
|
140
142
|
Device code works everywhere: OpenAI's sign-in endpoints answer any web page. The page-straight-back sign-in needs a
|
|
141
143
|
listener on the computer the browser runs on, so it is desktop only: ChatGPT sends the browser back to
|
|
@@ -182,12 +184,12 @@ the local sign-in even if the revoke fails. A failed revoke rejects after local
|
|
|
182
184
|
sign-in may remain active.
|
|
183
185
|
|
|
184
186
|
Within one store instance, a refresh already in progress finishes first, so sign-out uses its rotated token. If a
|
|
185
|
-
cancelled sign-in finishes late, `onSignOutError` reports a failed revoke of its discarded credential (or
|
|
187
|
+
cancelled sign-in finishes late, `onSignOutError` reports a failed revoke of its discarded credential (or a generic failure is logged
|
|
186
188
|
when no handler is set).
|
|
187
189
|
|
|
188
190
|
## One person, one store
|
|
189
191
|
|
|
190
|
-
`memoryStore()`, `fileStore(path)` (
|
|
192
|
+
`memoryStore()`, `fileStore(path, safeStorage)` (sealed, 0600), `secureStore(SecureStore, name,
|
|
191
193
|
options?)` or `browserStore(name)`; any other storage with `recordStore(load, save)`. Writes are serialized within a
|
|
192
194
|
store instance; `browserStore` also uses Web Locks across tabs for the same provider when available. Never a shared
|
|
193
195
|
fallback.
|
|
@@ -217,6 +219,14 @@ member's sign-in, refreshed first when due, and returns the whole text (`onText`
|
|
|
217
219
|
returned completion is authoritative). A limit or a lapsed sign-in is acted on as `failed()` does, then thrown as a
|
|
218
220
|
`ResponseError` with the words to show and the kind acted on. Rules: [conformance fixtures](../../fixtures/README.md).
|
|
219
221
|
|
|
222
|
+
A cut-off answer always throws `IncompleteError` (a `ResponseError` with `kind: null`), with or without tools.
|
|
223
|
+
Its `reason` preserves the provider's `incomplete_details.reason`, including `max_output_tokens` and
|
|
224
|
+
`content_filter` (`unknown` when absent). Its `result` holds the partial `{ text, output }` for apps that want to
|
|
225
|
+
show it as unfinished. `onEvent` also receives `{ type: 'incomplete', reason }` before rejection; `onText` may
|
|
226
|
+
already have shown partial words. This covers `response.incomplete` events and `status: 'incomplete'` envelopes,
|
|
227
|
+
whether fetch streams SSE, buffers it, or returns JSON. The account stays signed in and is not put to rest.
|
|
228
|
+
Successful return values are unchanged.
|
|
229
|
+
|
|
220
230
|
The whole question passes through: `input` takes the turns so far (messages, with `input_image` where the person
|
|
221
231
|
attached a picture), `tools` and `tool_choice` take the app's own function tools and built-ins (including
|
|
222
232
|
`image_generation`), `reasoning.effort` how hard the model thinks, and `text` how long the answer is with the shape it
|
|
@@ -291,3 +301,24 @@ including ID-token signature/issuer/audience/nonce verification and protected pe
|
|
|
291
301
|
available to eligible open-source/local apps; paid/remote apps require approval. This adapter does not start an
|
|
292
302
|
OAuth flow and does not convert the existing Codex `Accounts.login()` credential into a token-sharing session.
|
|
293
303
|
It reads no environment or files, and remains portable to browsers and React Native.
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
## Desktop credential storage security
|
|
307
|
+
|
|
308
|
+
`fileStore(path, safeStorage)` requires a sealing adapter; there is no plaintext fallback.
|
|
309
|
+
In Electron, pass `safeStorage` after `app.whenReady()`. The store refuses unavailable encryption
|
|
310
|
+
and the Linux `basic_text` backend. Other adapters must protect their keys outside the credential
|
|
311
|
+
file and provide authenticated encryption. For a Node service, use a host-owned keystore through
|
|
312
|
+
`recordStore(load, save)`, or provide an equivalent sealing adapter; the kit never discovers a key
|
|
313
|
+
or invokes an OS keyring itself. Use `memoryStore()` for temporary sign-ins.
|
|
314
|
+
|
|
315
|
+
Use an app-owned directory: the immediate folder must be a real 0700 directory and credential
|
|
316
|
+
files must be private regular files. Reuse one store instance for each path; a host lock is required
|
|
317
|
+
if several processes write the same file. See [SECURITY.md](SECURITY.md) for the threat model and limits.
|
|
318
|
+
|
|
319
|
+
**Migration from 0.7.x and earlier:** `fileStore(path)` is no longer accepted. Existing files already
|
|
320
|
+
sealed with the same adapter remain readable. Plain JSON is never silently imported or overwritten.
|
|
321
|
+
For a plaintext store, stop all writers, revoke the old credentials using the old app's sign-out flow,
|
|
322
|
+
remove the old app-owned credential file, and sign in again with a sealing adapter. Old plaintext
|
|
323
|
+
backups may retain tokens: delete them under the host's retention policy and revoke the affected
|
|
324
|
+
credentials. Do not point this migration at another tool's sign-in directory.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Accounts security
|
|
2
|
+
|
|
3
|
+
Review scope: accounts credential storage, sign-in failure logging, provider isolation and
|
|
4
|
+
portable platform boundaries, reviewed 2026-09-30. This is a source review with offline
|
|
5
|
+
regression tests, not an independent cryptographic audit or a live OS keychain certification.
|
|
6
|
+
|
|
7
|
+
## Assets and trust boundaries
|
|
8
|
+
|
|
9
|
+
OAuth access and refresh tokens, API keys, identity/plan claims, device sign-in codes and
|
|
10
|
+
callback state are sensitive. The host selects each member's store, provider offer and
|
|
11
|
+
platform adapter. The kit uses only those stores; it never imports another application's
|
|
12
|
+
credentials. The computer engine is exactly pinned in package.json and isolated from
|
|
13
|
+
ambient credential discovery. The Node entry has filesystem access; browser and React
|
|
14
|
+
Native entries exclude runtime Node and engine imports.
|
|
15
|
+
|
|
16
|
+
The provider and network are outside the storage boundary. OAuth sign-in sends codes and
|
|
17
|
+
tokens only to the configured provider endpoints over TLS in production. Host-supplied
|
|
18
|
+
endpoint overrides, adapters and fetch implementations are trusted code: production hosts
|
|
19
|
+
must not substitute untrusted endpoints. Callback state binds the loopback return to the
|
|
20
|
+
pending sign-in; a code or consent URL must be shown only to its intended member.
|
|
21
|
+
|
|
22
|
+
## At rest
|
|
23
|
+
|
|
24
|
+
`fileStore(path, adapter)` requires sealing. No encryption key is generated beside the
|
|
25
|
+
credential file and no plaintext fallback exists. Electron hosts pass safeStorage after
|
|
26
|
+
ready. When reported, unavailable encryption and the Linux basic_text backend are refused
|
|
27
|
+
at construction and on each operation. An equivalent adapter is trusted to use authenticated
|
|
28
|
+
encryption and keep its key outside this file, preferably in an OS keyring. An arbitrary
|
|
29
|
+
adapter can lie about sealing; this seam cannot certify the host's implementation.
|
|
30
|
+
|
|
31
|
+
New immediate directories are 0700; existing immediate directories must be real 0700
|
|
32
|
+
directories. Reads use O_NOFOLLOW, then inspect the opened descriptor for a private regular
|
|
33
|
+
file; nonblocking opens avoid waiting on a substituted FIFO. Writes seal before opening a
|
|
34
|
+
random temporary path with O_EXCL (wx) and O_NOFOLLOW, mode 0600, sync the file (except under Node's permission model, which disables fsync), then rename
|
|
35
|
+
it atomically. Cleanup removes only a temporary file this operation successfully created.
|
|
36
|
+
A legacy predictable .tmp path is never opened. Decryption and parse failures propagate;
|
|
37
|
+
corrupt or plaintext files are never replaced as an automatic recovery step.
|
|
38
|
+
|
|
39
|
+
The host must own the entire parent path and prevent concurrent directory replacement.
|
|
40
|
+
These POSIX checks do not resolve or pin every ancestor: malicious parent directories,
|
|
41
|
+
mounts, a process running as the same OS user, root, and compromised host code are outside
|
|
42
|
+
this boundary. Windows hosts must also configure private ACLs; POSIX mode bits and
|
|
43
|
+
O_NOFOLLOW are not a substitute for Windows access controls. Atomic rename avoids partial
|
|
44
|
+
records, but the directory is not synced: recovery after sudden power loss is not guaranteed.
|
|
45
|
+
Use one store instance per path and a host lock for multiple processes. Encryption does
|
|
46
|
+
not prevent deletion, rollback to an older sealed record, or leaking credentials in memory.
|
|
47
|
+
|
|
48
|
+
Browser IndexedDB is origin-scoped plaintext accessible to scripts in that origin. The host
|
|
49
|
+
must prevent XSS and untrusted scripts; a browser cannot promise OS-keychain protection.
|
|
50
|
+
React Native secureStore delegates protection and device accessibility policy to the
|
|
51
|
+
host's Keychain/Keystore adapter. memoryStore holds credentials only in process memory.
|
|
52
|
+
Host-defined recordStore persistence inherits the host backend's security properties.
|
|
53
|
+
|
|
54
|
+
## Logs, sign-out and isolation
|
|
55
|
+
|
|
56
|
+
Default sign-in and discarded-credential revoke diagnostics contain fixed messages only,
|
|
57
|
+
not raw provider errors, member identifiers, credentials, callback URLs or nested causes.
|
|
58
|
+
The host's onSignOutError callback receives the underlying error for handling: do not log
|
|
59
|
+
that error without sanitizing it. Other host-visible error/event payloads and model content
|
|
60
|
+
can also be sensitive; the kit does not redact arbitrary host logging or dump memory.
|
|
61
|
+
|
|
62
|
+
Sign-out attempts provider revocation and removes the local credential even on failure.
|
|
63
|
+
A generation check discards late sign-ins/refreshes so they cannot restore a signed-out
|
|
64
|
+
credential. Revocation failure means a copied token may remain usable at the provider.
|
|
65
|
+
Deleting local files alone does not revoke tokens, and deletion cannot erase old backups.
|
|
66
|
+
|
|
67
|
+
Tests use fake providers, temporary homes, filesystem canaries and Node permissions;
|
|
68
|
+
`npm test` blocks outbound networking and checks the owner's existing setup byte for byte.
|
|
69
|
+
The kit never invokes the owner's installed tools or borrows environment API keys.
|
|
70
|
+
|
|
71
|
+
## Review record
|
|
72
|
+
|
|
73
|
+
- [x] Required sealing; unavailable and basic_text Electron backends fail closed, including
|
|
74
|
+
availability changing after construction (`test/units.test.ts`).
|
|
75
|
+
- [x] Sealed credentials round-trip without plaintext access/refresh tokens on disk;
|
|
76
|
+
existing sealed adapter format is preserved (`test/units.test.ts`).
|
|
77
|
+
- [x] Target and immediate-directory symlinks and permissive modes are refused; a decoy
|
|
78
|
+
.tmp symlink is unchanged; encryption failure preserves the prior file and random temp
|
|
79
|
+
files are cleaned after successful replacement (`test/units.test.ts`).
|
|
80
|
+
- [x] Sign-in and failed late-revoke logs use fixed messages, exercised with credential
|
|
81
|
+
canaries in injected failures (`test/accounts.test.ts`, `test/revoke.test.ts`).
|
|
82
|
+
- [x] Revocation and late completion use offline fake-provider regression coverage
|
|
83
|
+
(`test/revoke.test.ts`); isolation and portable imports retain their existing tests
|
|
84
|
+
(`test/isolation.test.ts`, `test/portable.test.ts`).
|
|
85
|
+
|
|
86
|
+
Run `npm run build`, `npm run check` and `npm test` to reproduce the review checks.
|
|
87
|
+
Live Electron/OS keyring security and host adapter configuration remain the host's responsibility.
|
|
88
|
+
|
|
89
|
+
## Reporting and migration
|
|
90
|
+
|
|
91
|
+
Report a vulnerability privately through this repository's GitHub Security Advisories:
|
|
92
|
+
https://github.com/umeranjum17/byokit/security/advisories/new . Do not include live tokens
|
|
93
|
+
in an issue, logs, screenshots or a public reproduction. Include the package version,
|
|
94
|
+
platform, affected seam and an offline reproduction using synthetic credentials.
|
|
95
|
+
|
|
96
|
+
For legacy plaintext files, follow the README's migration: stop writers, revoke through
|
|
97
|
+
the old app, remove only the app-owned credential file and sign in again using sealing.
|
|
98
|
+
Previously sealed files need only the same adapter passed explicitly. There is no silent
|
|
99
|
+
plaintext migration and no recovery by replacing a file that fails to decrypt.
|
package/dist/accounts.js
CHANGED
|
@@ -114,7 +114,7 @@ export class Accounts {
|
|
|
114
114
|
if (this.onSignOutError)
|
|
115
115
|
this.onSignOutError(member, p.key, error);
|
|
116
116
|
else
|
|
117
|
-
console.error(
|
|
117
|
+
console.error('Sign-out of a discarded credential failed');
|
|
118
118
|
throw error;
|
|
119
119
|
}
|
|
120
120
|
}
|
|
@@ -391,8 +391,8 @@ export class Accounts {
|
|
|
391
391
|
if (flow.state !== 'waiting')
|
|
392
392
|
return; // cancelled: already settled
|
|
393
393
|
const error = String(e?.message ?? e);
|
|
394
|
-
console.error(`sign-in ${key} for member ${member}:`, error);
|
|
395
394
|
const why = e?.why ?? (flow.timedOut ? 'tooLong' : failure(error));
|
|
395
|
+
console.error('Sign-in failed');
|
|
396
396
|
Object.assign(flow, { state: 'failed', url: undefined, code: undefined, expiresAt: undefined, why,
|
|
397
397
|
error: why === 'busy' || why === 'tooLong' ? say(`signIn.${why}`, { name: p.name }) : signInError(p.name, error) });
|
|
398
398
|
}
|
package/dist/node-stores.d.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { CredentialStore } from '@earendil-works/pi-ai';
|
|
2
|
-
/**
|
|
2
|
+
/** Pass Electron's safeStorage from the main process after ready, or an equivalent trusted sealing adapter.
|
|
3
|
+
* Adapters without capability methods are responsible for ensuring their key is protected. */
|
|
3
4
|
export type SafeStorageLike = {
|
|
4
5
|
encryptString(text: string): Uint8Array;
|
|
5
6
|
decryptString(data: Buffer): string;
|
|
7
|
+
isEncryptionAvailable?(): boolean;
|
|
8
|
+
getSelectedStorageBackend?(): string;
|
|
6
9
|
};
|
|
7
|
-
/** One person's sign-ins in
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
export declare function fileStore(path: string, safeStorage
|
|
10
|
+
/** One person's sealed sign-ins in an app-owned 0600 file inside a private 0700 folder.
|
|
11
|
+
* No plaintext fallback. Writes are serialized per store instance; use one instance per path and a host lock
|
|
12
|
+
* if multiple processes share it. The host owns the adapter and its key, separately from this file. */
|
|
13
|
+
export declare function fileStore(path: string, safeStorage: SafeStorageLike): CredentialStore;
|
package/dist/node-stores.js
CHANGED
|
@@ -1,27 +1,81 @@
|
|
|
1
|
-
// Desktop stores:
|
|
2
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
1
|
+
// Desktop stores: sealed with a host-supplied adapter, such as Electron's safeStorage.
|
|
2
|
+
import { constants, closeSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
3
4
|
import { dirname } from 'node:path';
|
|
4
5
|
import { recordStore } from "./stores.js";
|
|
5
|
-
/** One person's sign-ins in
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
/** One person's sealed sign-ins in an app-owned 0600 file inside a private 0700 folder.
|
|
7
|
+
* No plaintext fallback. Writes are serialized per store instance; use one instance per path and a host lock
|
|
8
|
+
* if multiple processes share it. The host owns the adapter and its key, separately from this file. */
|
|
8
9
|
export function fileStore(path, safeStorage) {
|
|
10
|
+
const ready = () => {
|
|
11
|
+
if (!safeStorage || typeof safeStorage.encryptString !== 'function' || typeof safeStorage.decryptString !== 'function') {
|
|
12
|
+
throw new TypeError('fileStore requires a sealing adapter');
|
|
13
|
+
}
|
|
14
|
+
if (safeStorage.isEncryptionAvailable?.() === false || safeStorage.getSelectedStorageBackend?.() === 'basic_text') {
|
|
15
|
+
throw new Error('Secure credential storage is unavailable');
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
ready();
|
|
19
|
+
const folder = dirname(path);
|
|
20
|
+
const privateFolder = () => {
|
|
21
|
+
const st = lstatSync(folder);
|
|
22
|
+
if (!st.isDirectory() || (st.mode & 0o777) !== 0o700)
|
|
23
|
+
throw new Error('Credential storage requires a private 0700 folder');
|
|
24
|
+
};
|
|
9
25
|
const load = async () => {
|
|
26
|
+
ready();
|
|
27
|
+
let fd;
|
|
10
28
|
try {
|
|
11
|
-
|
|
12
|
-
|
|
29
|
+
privateFolder();
|
|
30
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
13
31
|
}
|
|
14
32
|
catch (e) {
|
|
15
33
|
if (e?.code === 'ENOENT')
|
|
16
34
|
return {};
|
|
17
35
|
throw e;
|
|
18
36
|
}
|
|
37
|
+
try {
|
|
38
|
+
const st = fstatSync(fd);
|
|
39
|
+
if (!st.isFile() || (st.mode & 0o077))
|
|
40
|
+
throw new Error('Credential storage requires a private regular file');
|
|
41
|
+
return JSON.parse(safeStorage.decryptString(readFileSync(fd)));
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
closeSync(fd);
|
|
45
|
+
}
|
|
19
46
|
};
|
|
20
47
|
const save = async (data) => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
48
|
+
ready();
|
|
49
|
+
const sealed = safeStorage.encryptString(JSON.stringify(data, null, 2));
|
|
50
|
+
mkdirSync(folder, { recursive: true, mode: 0o700 });
|
|
51
|
+
privateFolder();
|
|
52
|
+
const tmp = `${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`;
|
|
53
|
+
let created = false;
|
|
54
|
+
try {
|
|
55
|
+
// O_EXCL is the numeric equivalent of wx; O_NOFOLLOW also forbids a symlink.
|
|
56
|
+
const fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
57
|
+
created = true;
|
|
58
|
+
try {
|
|
59
|
+
writeFileSync(fd, sealed);
|
|
60
|
+
// Node's permission model disables fsync even for permitted descriptors.
|
|
61
|
+
if (!process.permission)
|
|
62
|
+
fsyncSync(fd);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
renameSync(tmp, path);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
if (created)
|
|
71
|
+
try {
|
|
72
|
+
unlinkSync(tmp);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
if (e?.code !== 'ENOENT')
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
25
79
|
};
|
|
26
80
|
return recordStore(load, save);
|
|
27
81
|
}
|
package/dist/portable.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export { Accounts, planOf, portable, type AccountsOptions, type AuthHost, type L
|
|
|
2
2
|
export { PROVIDERS, offered, provider, type Billing, type Provider, type Terms } from './catalogue.ts';
|
|
3
3
|
export { PORTABLE, claims, credentialOf, devicePoll, deviceStart, portableEngine, type EngineOptions, type Poll } from './engine.ts';
|
|
4
4
|
export { REST_MS, classify, type Kind } from './limits.ts';
|
|
5
|
-
export { ResponseError, isFunctionCall, limitResponse, respond, sseReader, type Ask, type ResponseFunctionCall, type ResponseInputItem, type ResponseOutputItem, type ResponseOutputMessage, type ResponseReasoning, type ResponseResult, type ResponseStreamEvent, type ResponseText, type ResponseTextFormat, type ResponseTool, type ResponseToolChoice } from './responses.ts';
|
|
5
|
+
export { IncompleteError, ResponseError, isFunctionCall, limitResponse, respond, sseReader, type Ask, type ResponseFunctionCall, type ResponseInputItem, type ResponseOutputItem, type ResponseOutputMessage, type ResponseReasoning, type ResponseResult, type ResponseStreamEvent, type ResponseText, type ResponseTextFormat, type ResponseTool, type ResponseToolChoice } from './responses.ts';
|
|
6
6
|
export { browserStore, memoryStore, recordStore, secureStore, type SecureStoreLike } from './stores.ts';
|
|
7
7
|
export { WORDS, billingWords, callbackPage, clock, failure, say, signInError, type WordKey, type Why } from './words.ts';
|
|
8
8
|
export { chatgptPlan, UnsupportedAccountError, type ChatGPTPlanAccount, type ChatGPTPlanSession } from './chatgpt-plan.ts';
|
package/dist/portable.js
CHANGED
|
@@ -4,7 +4,7 @@ export { Accounts, planOf, portable } from "./accounts.js";
|
|
|
4
4
|
export { PROVIDERS, offered, provider } from "./catalogue.js";
|
|
5
5
|
export { PORTABLE, claims, credentialOf, devicePoll, deviceStart, portableEngine } from "./engine.js";
|
|
6
6
|
export { REST_MS, classify } from "./limits.js";
|
|
7
|
-
export { ResponseError, isFunctionCall, limitResponse, respond, sseReader } from "./responses.js";
|
|
7
|
+
export { IncompleteError, ResponseError, isFunctionCall, limitResponse, respond, sseReader } from "./responses.js";
|
|
8
8
|
export { browserStore, memoryStore, recordStore, secureStore } from "./stores.js";
|
|
9
9
|
export { WORDS, billingWords, callbackPage, clock, failure, say, signInError } from "./words.js";
|
|
10
10
|
export { chatgptPlan, UnsupportedAccountError } from "./chatgpt-plan.js";
|
package/dist/responses.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ export declare class ResponseError extends Error {
|
|
|
6
6
|
until: number;
|
|
7
7
|
constructor(message: string, kind: Kind | null, until?: number);
|
|
8
8
|
}
|
|
9
|
+
/** An answer the provider cut off. Partial output is available, but never returned as a successful answer. */
|
|
10
|
+
export declare class IncompleteError extends ResponseError {
|
|
11
|
+
reason: string;
|
|
12
|
+
result: ResponseResult;
|
|
13
|
+
constructor(reason: string, result: ResponseResult);
|
|
14
|
+
}
|
|
9
15
|
/** A ChatGPT HTTP error as the kind, when to come back, and the message (fixtures/conformance/limit-responses.json). */
|
|
10
16
|
export declare function limitResponse(status: number, body: string, now?: number): {
|
|
11
17
|
kind: Kind | null;
|
|
@@ -129,6 +135,9 @@ export type ResponseResult = {
|
|
|
129
135
|
};
|
|
130
136
|
/** What streams besides the words: each text piece, each tool call as it builds and lands, and each output item. */
|
|
131
137
|
export type ResponseStreamEvent = {
|
|
138
|
+
type: 'incomplete';
|
|
139
|
+
reason: string;
|
|
140
|
+
} | {
|
|
132
141
|
type: 'text_delta';
|
|
133
142
|
delta: string;
|
|
134
143
|
} | {
|
|
@@ -149,7 +158,8 @@ export type ResponseStreamEvent = {
|
|
|
149
158
|
* `result` for the text with every output item. `onEvent` sees each tool call and output item as it lands.
|
|
150
159
|
* Events split on any blank line (LF, CRLF or bare CR). A data line that is not JSON throws a ResponseError;
|
|
151
160
|
* a stream ending with nothing to show throws too. The text is the streamed deltas; the completed envelope
|
|
152
|
-
* only fills in when no deltas arrived. An error event throws a ResponseError.
|
|
161
|
+
* only fills in when no deltas arrived. An error event throws a ResponseError. An incomplete answer
|
|
162
|
+
* emits an incomplete event and throws IncompleteError with its reason and partial result. */
|
|
153
163
|
export declare function sseReader(onText?: (delta: string) => void, onEvent?: (event: ResponseStreamEvent) => void): {
|
|
154
164
|
push(chunk: string): void;
|
|
155
165
|
end(): string;
|
|
@@ -173,7 +183,7 @@ export type Ask = {
|
|
|
173
183
|
text?: ResponseText;
|
|
174
184
|
/** Each piece of the answer as it streams. */
|
|
175
185
|
onText?: (delta: string) => void;
|
|
176
|
-
/** Each tool call
|
|
186
|
+
/** Each text piece, tool call, output item, and incomplete answer notification. */
|
|
177
187
|
onEvent?: (event: ResponseStreamEvent) => void;
|
|
178
188
|
signal?: AbortSignal;
|
|
179
189
|
/** The app's own originator header value. Default: 'byokit'. */
|
|
@@ -187,7 +197,8 @@ type Access = {
|
|
|
187
197
|
fetch?: typeof fetch;
|
|
188
198
|
};
|
|
189
199
|
/** Ask ChatGPT with a signed-in token. `fetch`: pass one that streams (Expo's `expo/fetch`); any fetch works.
|
|
190
|
-
* Without `tools` the answer is the plain text, as before; with `tools` it is the text with every output item.
|
|
200
|
+
* Without `tools` the answer is the plain text, as before; with `tools` it is the text with every output item.
|
|
201
|
+
* Incomplete answers always throw IncompleteError and notify onEvent, including with tools. */
|
|
191
202
|
export declare function respond(o: Ask & Access & {
|
|
192
203
|
tools?: undefined;
|
|
193
204
|
}): Promise<string>;
|
package/dist/responses.js
CHANGED
|
@@ -16,6 +16,17 @@ export class ResponseError extends Error {
|
|
|
16
16
|
until;
|
|
17
17
|
constructor(message, kind, until = 0) { super(message); this.kind = kind; this.until = until; }
|
|
18
18
|
}
|
|
19
|
+
/** An answer the provider cut off. Partial output is available, but never returned as a successful answer. */
|
|
20
|
+
export class IncompleteError extends ResponseError {
|
|
21
|
+
reason;
|
|
22
|
+
result;
|
|
23
|
+
constructor(reason, result) {
|
|
24
|
+
super('ChatGPT cut off its answer before it was complete.', null);
|
|
25
|
+
this.name = 'IncompleteError';
|
|
26
|
+
this.reason = reason;
|
|
27
|
+
this.result = result;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
19
30
|
/** A ChatGPT HTTP error as the kind, when to come back, and the message (fixtures/conformance/limit-responses.json). */
|
|
20
31
|
export function limitResponse(status, body, now = Date.now()) {
|
|
21
32
|
let err = {};
|
|
@@ -41,10 +52,12 @@ export const isFunctionCall = (item) => isRecord(item) && item.type === 'functio
|
|
|
41
52
|
* `result` for the text with every output item. `onEvent` sees each tool call and output item as it lands.
|
|
42
53
|
* Events split on any blank line (LF, CRLF or bare CR). A data line that is not JSON throws a ResponseError;
|
|
43
54
|
* a stream ending with nothing to show throws too. The text is the streamed deltas; the completed envelope
|
|
44
|
-
* only fills in when no deltas arrived. An error event throws a ResponseError.
|
|
55
|
+
* only fills in when no deltas arrived. An error event throws a ResponseError. An incomplete answer
|
|
56
|
+
* emits an incomplete event and throws IncompleteError with its reason and partial result. */
|
|
45
57
|
export function sseReader(onText, onEvent) {
|
|
46
58
|
let buffer = '', text = '', completed;
|
|
47
59
|
let done = false, finished;
|
|
60
|
+
let incomplete;
|
|
48
61
|
const output = [];
|
|
49
62
|
const emitted = new Set();
|
|
50
63
|
const calls = new Map();
|
|
@@ -100,7 +113,7 @@ export function sseReader(onText, onEvent) {
|
|
|
100
113
|
}
|
|
101
114
|
land(e.item, e.output_index ?? 0);
|
|
102
115
|
}
|
|
103
|
-
if (e.type === 'response.completed' || e.type === 'response.incomplete') {
|
|
116
|
+
if (e.type === 'response.completed' || e.type === 'response.incomplete' || e.response?.status === 'incomplete') {
|
|
104
117
|
done = true;
|
|
105
118
|
if (Array.isArray(e.response?.output)) {
|
|
106
119
|
for (const [i, item] of e.response.output.entries())
|
|
@@ -108,6 +121,11 @@ export function sseReader(onText, onEvent) {
|
|
|
108
121
|
completed = e.response.output.flatMap((o) => o?.content ?? []).filter((c) => c?.type === 'output_text').map((c) => c.text ?? '').join('');
|
|
109
122
|
}
|
|
110
123
|
}
|
|
124
|
+
if ((e.type === 'response.incomplete' || e.response?.status === 'incomplete') && incomplete === undefined) {
|
|
125
|
+
const reason = typeof e.response?.incomplete_details?.reason === 'string' ? e.response.incomplete_details.reason : 'unknown';
|
|
126
|
+
incomplete = reason;
|
|
127
|
+
onEvent?.({ type: 'incomplete', reason });
|
|
128
|
+
}
|
|
111
129
|
const failed = e.type === 'error' ? e : e.type === 'response.failed' ? e.response?.error : undefined;
|
|
112
130
|
if (failed) {
|
|
113
131
|
const message = String(failed.message ?? 'Request failed');
|
|
@@ -133,12 +151,14 @@ export function sseReader(onText, onEvent) {
|
|
|
133
151
|
if (!done)
|
|
134
152
|
throw new ResponseError('ChatGPT stopped before completing its answer.', 'network');
|
|
135
153
|
const whole = text !== '' ? text : (completed ?? '');
|
|
136
|
-
if (whole === '' && output.length === 0)
|
|
154
|
+
if (incomplete === undefined && whole === '' && output.length === 0)
|
|
137
155
|
throw new ResponseError('ChatGPT stopped before completing its answer.', 'network');
|
|
138
156
|
if (text === '' && whole !== '')
|
|
139
157
|
onText?.(whole);
|
|
140
158
|
finished = { text: whole, output };
|
|
141
159
|
}
|
|
160
|
+
if (incomplete !== undefined)
|
|
161
|
+
throw new IncompleteError(incomplete, finished);
|
|
142
162
|
return finished;
|
|
143
163
|
};
|
|
144
164
|
return {
|
|
@@ -173,7 +193,18 @@ export async function respond(o) {
|
|
|
173
193
|
}
|
|
174
194
|
const reader = sseReader(o.onText, o.onEvent);
|
|
175
195
|
const body = res.body;
|
|
176
|
-
if (
|
|
196
|
+
if (res.headers?.get('content-type')?.includes('application/json')) {
|
|
197
|
+
let response;
|
|
198
|
+
try {
|
|
199
|
+
response = JSON.parse(await res.text());
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
throw new ResponseError("ChatGPT's answer could not be read.", null);
|
|
203
|
+
}
|
|
204
|
+
const type = isRecord(response) ? `response.${String(response.status)}` : '';
|
|
205
|
+
reader.push(`data: ${JSON.stringify({ type, response })}\n\n`);
|
|
206
|
+
}
|
|
207
|
+
else if (body?.getReader && typeof TextDecoder !== 'undefined') {
|
|
177
208
|
const r = body.getReader();
|
|
178
209
|
const decoder = new TextDecoder();
|
|
179
210
|
for (let c = await r.read(); !c.done; c = await r.read())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byokit/accounts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Sign in with the AI plan you already pay for, into your app's own store.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
},
|
|
41
41
|
"files": [
|
|
42
42
|
"dist",
|
|
43
|
-
"CHANGELOG.md"
|
|
43
|
+
"CHANGELOG.md",
|
|
44
|
+
"SECURITY.md"
|
|
44
45
|
],
|
|
45
46
|
"scripts": {
|
|
46
47
|
"prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
|