@byokit/accounts 0.7.1 → 0.9.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 CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.0 (2026-09-30)
6
+
7
+
8
+
9
+ - SECURITY: Prevent replay of single-use refresh grants in the portable engine by saving a generation attempt before sending and committing the replacement before returning access. An uncertain or terminal attempt requires sign-in again; custom stores must provide a refresh transaction, and restart safety depends on durable storage and a single refresh owner or host lock.
10
+
11
+ ## 0.8.0 (2026-09-30)
12
+
13
+ - 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.
14
+ - 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.
15
+
5
16
  ## 0.7.1 (2026-09-30)
6
17
 
7
18
  - 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.
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
- import { Accounts, fileStore } from '@byokit/accounts';
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,7 +118,7 @@ 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
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 |
@@ -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)`, sealed with Electron's `safeStorage` when given | `browserStore(name)` (IndexedDB) | `secureStore(SecureStore, name)` (Keychain, Keystore) |
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,16 +184,49 @@ 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 it is logged
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)` (0600, the same shape as Pi's `auth.json`), `secureStore(SecureStore, name,
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
- store instance; `browserStore` also uses Web Locks across tabs for the same provider when available. Never a shared
194
+ store instance; `browserStore` also uses Web Locks across tabs for the whole record when available. Never a shared
193
195
  fallback.
194
196
 
197
+ ### Refresh safety
198
+
199
+ `portableEngine` (the default on phones and in browsers) holds the store lock, re-reads the current sign-in, and saves
200
+ a non-secret `byokitRefresh` generation/attempt marker in the credential record **before** sending a refresh grant.
201
+ It commits the replacement pair before returning access. A lost response, terminal refusal, unchanged refresh grant,
202
+ or failure to save the replacement requires sign-in again; an attempted or quarantined generation is never retried,
203
+ including by `recheck`. If the attempt marker cannot be saved, nothing is sent. A fresh sign-in replaces quarantine.
204
+ This deliberately requires sign-in again after even a network failure once a refresh send has started: the server
205
+ may already have spent the grant. Storage failures before the send, such as a locked phone keychain, remain retryable.
206
+
207
+ - **iOS/Android `secureStore`**: crash-safe across process restart after the platform acknowledges the marker write,
208
+ with one store instance/refresh owner per storage name. Its chunk-generation pointer commits the marker and each
209
+ replacement atomically. Multiple app processes or independently created store instances need a host lock covering
210
+ the whole transaction.
211
+ - **Browser/PWA `browserStore`**: IndexedDB commits the marker before sending. With Web Locks it serializes the whole
212
+ transaction across tabs. Without Web Locks, use one store instance and tab; multiple writers are only best-effort.
213
+ Browser storage eviction, rollback and power-loss durability are outside this guarantee.
214
+ - **Node/Electron `fileStore` with `portableEngine`**: the sealed file is atomically replaced and synced when Node
215
+ permissions permit; on POSIX the directory is synced too. Process restart retains the attempt. Use one instance per
216
+ path and a host lock across processes. On Windows or with Node's permission model, power-loss durability is best-effort.
217
+ - **`recordStore(load, save)`**: crash safety depends on the host's atomic, durable save completing before its promise
218
+ resolves and a host lock across independent writers. A best-effort save makes refresh best-effort too.
219
+ - **`memoryStore`**: serialized only in memory; there is no restart recovery. A bare custom `CredentialStore` can serve
220
+ existing access but cannot refresh: wrap its durable load/save in `recordStore`, or implement the exported
221
+ `RefreshStore.refresh` transaction contract with these same guarantees.
222
+
223
+ The default computer engine is still Pi's engine; its refresh path does **not** use this transaction. Other engines
224
+ and runtime aggregators own their own refresh guarantees. The fix does not add a second refresher to them.
225
+
226
+ The persisted-attempt, failed-save and concurrency regression ideas were informed by
227
+ [clauth's refresh guard](https://github.com/uwuclxdy/clauth/blob/6410345c65b91cf07eabd4f9f79670ba602ace63/src/codex_auth.rs).
228
+ The TypeScript transaction and synthetic tests were written independently; no upstream code or tests were copied.
229
+
195
230
  - **Browser**: browser storage is readable by scripts on your page: avoid untrusted scripts.
196
231
  - **Phone**: pass `{ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }` as `options` (to every get, set
197
232
  and delete) so tokens never migrate to a new device through an iCloud/iTunes backup; without it Expo's default
@@ -299,3 +334,24 @@ including ID-token signature/issuer/audience/nonce verification and protected pe
299
334
  available to eligible open-source/local apps; paid/remote apps require approval. This adapter does not start an
300
335
  OAuth flow and does not convert the existing Codex `Accounts.login()` credential into a token-sharing session.
301
336
  It reads no environment or files, and remains portable to browsers and React Native.
337
+
338
+
339
+ ## Desktop credential storage security
340
+
341
+ `fileStore(path, safeStorage)` requires a sealing adapter; there is no plaintext fallback.
342
+ In Electron, pass `safeStorage` after `app.whenReady()`. The store refuses unavailable encryption
343
+ and the Linux `basic_text` backend. Other adapters must protect their keys outside the credential
344
+ file and provide authenticated encryption. For a Node service, use a host-owned keystore through
345
+ `recordStore(load, save)`, or provide an equivalent sealing adapter; the kit never discovers a key
346
+ or invokes an OS keyring itself. Use `memoryStore()` for temporary sign-ins.
347
+
348
+ Use an app-owned directory: the immediate folder must be a real 0700 directory and credential
349
+ files must be private regular files. Reuse one store instance for each path; a host lock is required
350
+ if several processes write the same file. See [SECURITY.md](SECURITY.md) for the threat model and limits.
351
+
352
+ **Migration from 0.7.x and earlier:** `fileStore(path)` is no longer accepted. Existing files already
353
+ sealed with the same adapter remain readable. Plain JSON is never silently imported or overwritten.
354
+ For a plaintext store, stop all writers, revoke the old credentials using the old app's sign-out flow,
355
+ remove the old app-owned credential file, and sign in again with a sealing adapter. Old plaintext
356
+ backups may retain tokens: delete them under the host's retention policy and revoke the affected
357
+ 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.
@@ -2,10 +2,10 @@ 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
4
  import { ResponseError, type Ask, type ResponseResult, type ResponseTool } from './responses.ts';
5
- import { type EndingStore } from './stores.ts';
5
+ import { type EndingStore, type RefreshStore } from './stores.ts';
6
6
  import { type Why } from './words.ts';
7
7
  /** What signing in needs from an engine: Pi's `Models`, or anything shaped like it (the coding agent's `ModelRuntime`). */
8
- type BoundStore = CredentialStore & {
8
+ type BoundStore = CredentialStore & Partial<Pick<RefreshStore, 'refresh'>> & {
9
9
  signOut: (id: string, p: Provider) => Promise<void>;
10
10
  };
11
11
  export type AuthHost = Pick<Models, 'login' | 'logout' | 'checkAuth' | 'getAuth'> & {
@@ -171,7 +171,8 @@ export declare class Accounts<R extends AuthHost = AuthHost, M extends Member =
171
171
  cancel(member: M, key: string): void;
172
172
  private refreshed;
173
173
  /** Refresh every signed-in account an hour ahead of expiry (call it now and then), so a sign-in never lapses while
174
- * nobody is looking. Only the provider refusing signs it out, and `onExpired` says so once; a network hiccup doesn't. */
174
+ * nobody is looking. A refusal or uncertain refresh requires sign-in again; `onExpired` says so once. A storage
175
+ * read failure before sending remains unknown and can be retried. */
175
176
  keepFresh(members: readonly M[]): Promise<void>;
176
177
  /** After the account turned a request away: true if its sign-in still refreshes; if not, it is signed out for good. */
177
178
  recheck(member: M, key: string): Promise<boolean>;
package/dist/accounts.js CHANGED
@@ -2,7 +2,7 @@ import { offered, provider } from "./catalogue.js";
2
2
  import { claims, PORTABLE, portableEngine } from "./engine.js";
3
3
  import { classify, REST_MS } from "./limits.js";
4
4
  import { respond, ResponseError } from "./responses.js";
5
- import { memoryStore } from "./stores.js";
5
+ import { memoryStore, refreshCredential } from "./stores.js";
6
6
  import { callbackPage, clock, failure, say, signInError } from "./words.js";
7
7
  /** Phones and browsers: ChatGPT by device code, no listener. */
8
8
  export const portable = { engine: (c, base) => portableEngine(c, { base }), signsIn: (pi) => PORTABLE.includes(pi) };
@@ -65,6 +65,7 @@ export class Accounts {
65
65
  read: (id) => base.read(id),
66
66
  list: () => base.list(),
67
67
  modify: (id, fn, options) => serial(() => base.modify(id, fn, options)),
68
+ refresh: (id, due, rotate) => serial(() => refreshCredential(base, id, due, rotate)),
68
69
  delete: (id, options) => serial(() => base.delete(id, options)),
69
70
  end: (id, fn) => serial(async () => {
70
71
  if (typeof base.end === 'function')
@@ -99,6 +100,17 @@ export class Accounts {
99
100
  return {
100
101
  read: (id, options) => raw.read(id, options),
101
102
  list: (options) => raw.list(options),
103
+ refresh: (id, due, rotate) => {
104
+ const account = key(id);
105
+ const started = this.generations.get(account) ?? 0;
106
+ return this.serial(account, async () => {
107
+ if (started !== (this.generations.get(account) ?? 0))
108
+ return undefined;
109
+ const next = await refreshCredential(raw, id, due, rotate);
110
+ // Sign-out waits on this lock and revokes the committed replacement pair.
111
+ return started === (this.generations.get(account) ?? 0) ? next : undefined;
112
+ });
113
+ },
102
114
  modify: (id, fn, options) => {
103
115
  const account = key(id);
104
116
  const stale = Symbol();
@@ -114,7 +126,7 @@ export class Accounts {
114
126
  if (this.onSignOutError)
115
127
  this.onSignOutError(member, p.key, error);
116
128
  else
117
- console.error(`sign-out ${p.key} for member ${member}:`, error);
129
+ console.error('Sign-out of a discarded credential failed');
118
130
  throw error;
119
131
  }
120
132
  }
@@ -391,8 +403,8 @@ export class Accounts {
391
403
  if (flow.state !== 'waiting')
392
404
  return; // cancelled: already settled
393
405
  const error = String(e?.message ?? e);
394
- console.error(`sign-in ${key} for member ${member}:`, error);
395
406
  const why = e?.why ?? (flow.timedOut ? 'tooLong' : failure(error));
407
+ console.error('Sign-in failed');
396
408
  Object.assign(flow, { state: 'failed', url: undefined, code: undefined, expiresAt: undefined, why,
397
409
  error: why === 'busy' || why === 'tooLong' ? say(`signIn.${why}`, { name: p.name }) : signInError(p.name, error) });
398
410
  }
@@ -453,14 +465,15 @@ export class Accounts {
453
465
  const c = await this.store(member).read(pi);
454
466
  return c?.type === 'oauth' && c.expires > Date.now();
455
467
  }
456
- // Only the provider refusing (400-403) counts as expiry. Anything else (a locked keychain read, storage failing)
457
- // is unknown: try later, never sign the person out.
468
+ // A provider refusal or quarantined refresh requires sign-in again. A read failure before sending (for example
469
+ // a locked keychain) is unknown: try later.
458
470
  const status = e?.status;
459
471
  return typeof status !== 'number' || status < 400 || status > 403;
460
472
  });
461
473
  }
462
474
  /** Refresh every signed-in account an hour ahead of expiry (call it now and then), so a sign-in never lapses while
463
- * nobody is looking. Only the provider refusing signs it out, and `onExpired` says so once; a network hiccup doesn't. */
475
+ * nobody is looking. A refusal or uncertain refresh requires sign-in again; `onExpired` says so once. A storage
476
+ * read failure before sending remains unknown and can be retried. */
464
477
  async keepFresh(members) {
465
478
  for (const m of members)
466
479
  for (const p of this.providers) {
package/dist/engine.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { RefreshRequiredError, refreshCredential } from "./stores.js";
1
2
  const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
2
3
  const CODE_LIVES_S = 15 * 60;
3
4
  /** The Pi provider ids this engine signs in to. */
@@ -143,7 +144,21 @@ export function portableEngine(credentials, { base = 'https://auth.openai.com' }
143
144
  await sleep(interval, signal);
144
145
  }
145
146
  },
146
- checkAuth: async (id) => (PORTABLE.includes(id) && (await credentials.read(id))?.type === 'oauth' ? { source: 'OAuth', type: 'oauth' } : undefined),
147
+ checkAuth: async (id) => {
148
+ if (!PORTABLE.includes(id))
149
+ return undefined;
150
+ try {
151
+ // Wait for a live transaction rather than mistake its before-send marker for a failed sign-in.
152
+ // A false due predicate only reads under the lock; it never sends or saves.
153
+ const c = await refreshCredential(credentials, id, () => false, refresh);
154
+ return c?.type === 'oauth' ? { source: 'OAuth', type: 'oauth' } : undefined;
155
+ }
156
+ catch (e) {
157
+ if (e instanceof RefreshRequiredError)
158
+ return undefined;
159
+ throw e;
160
+ }
161
+ },
147
162
  /** Pi's rule: refresh under the store's lock when under 5 minutes (or `minOAuthValidityMs`) remain, re-checked there,
148
163
  * so a sign-out or another refresh in between wins; undefined once signed out. */
149
164
  async getAuth(id, { minOAuthValidityMs } = {}) {
@@ -151,14 +166,10 @@ export function portableEngine(credentials, { base = 'https://auth.openai.com' }
151
166
  return undefined;
152
167
  const min = Math.max(5 * 60_000, minOAuthValidityMs ?? 0);
153
168
  const soon = (c) => Date.now() + min >= c.expires;
154
- let c = await credentials.read(id);
169
+ // Even a still-valid access token must not bypass quarantine, including a forced refresh after a refusal.
170
+ const c = await refreshCredential(credentials, id, soon, refresh);
155
171
  if (c?.type !== 'oauth')
156
172
  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
173
  return { auth: { apiKey: c.access }, source: 'OAuth' };
163
174
  },
164
175
  logout: (id) => credentials.delete(id),
@@ -1,10 +1,13 @@
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`). */
1
+ import { type EndingStore } from './stores.ts';
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 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;
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): EndingStore;
@@ -1,27 +1,91 @@
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';
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 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. */
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
- const raw = readFileSync(path);
12
- return JSON.parse(safeStorage ? safeStorage.decryptString(raw) : raw.toString('utf8'));
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
- 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);
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
+ // Windows does not support opening directories for fsync through this API.
69
+ if (!process.permission && process.platform !== 'win32') {
70
+ const dir = openSync(folder, constants.O_RDONLY);
71
+ try {
72
+ fsyncSync(dir);
73
+ }
74
+ finally {
75
+ closeSync(dir);
76
+ }
77
+ }
78
+ }
79
+ finally {
80
+ if (created)
81
+ try {
82
+ unlinkSync(tmp);
83
+ }
84
+ catch (e) {
85
+ if (e?.code !== 'ENOENT')
86
+ throw e;
87
+ }
88
+ }
25
89
  };
26
90
  return recordStore(load, save);
27
91
  }
@@ -3,6 +3,6 @@ export { PROVIDERS, offered, provider, type Billing, type Provider, type Terms }
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
5
  export { IncompleteError, ResponseError, isFunctionCall, limitResponse, respond, sseReader, type Ask, type ResponseFunctionCall, type ResponseInputItem, type ResponseOutputItem, type ResponseOutputMessage, type ResponseReasoning, type ResponseResult, type ResponseStreamEvent, type ResponseText, type ResponseTextFormat, type ResponseTool, type ResponseToolChoice } from './responses.ts';
6
- export { browserStore, memoryStore, recordStore, secureStore, type SecureStoreLike } from './stores.ts';
6
+ export { browserStore, memoryStore, recordStore, secureStore, RefreshRequiredError, type RefreshStore, type SecureStoreLike } from './stores.ts';
7
7
  export { WORDS, billingWords, callbackPage, clock, failure, say, signInError, type WordKey, type Why } from './words.ts';
8
8
  export { chatgptPlan, UnsupportedAccountError, type ChatGPTPlanAccount, type ChatGPTPlanSession } from './chatgpt-plan.ts';
package/dist/portable.js CHANGED
@@ -5,6 +5,6 @@ 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
7
  export { IncompleteError, ResponseError, isFunctionCall, limitResponse, respond, sseReader } from "./responses.js";
8
- export { browserStore, memoryStore, recordStore, secureStore } from "./stores.js";
8
+ export { browserStore, memoryStore, recordStore, secureStore, RefreshRequiredError } from "./stores.js";
9
9
  export { WORDS, billingWords, callbackPage, clock, failure, say, signInError } from "./words.js";
10
10
  export { chatgptPlan, UnsupportedAccountError } from "./chatgpt-plan.js";
package/dist/stores.d.ts CHANGED
@@ -1,14 +1,28 @@
1
- import type { Credential, CredentialStore } from '@earendil-works/pi-ai';
1
+ import type { Credential, CredentialStore, OAuthCredential } from '@earendil-works/pi-ai';
2
2
  export type Record = {
3
3
  [providerId: string]: Credential;
4
4
  };
5
- export type EndingStore = CredentialStore & {
5
+ /** Extends the credential seam so two durable writes can bracket a refresh while holding the same lock. */
6
+ export type RefreshStore = CredentialStore & {
7
+ refresh(id: string, due: (c: OAuthCredential) => boolean, rotate: (c: OAuthCredential) => Promise<OAuthCredential>): Promise<OAuthCredential | undefined>;
8
+ };
9
+ export type EndingStore = RefreshStore & {
6
10
  end(id: string, fn: (c: Credential | undefined) => Promise<void>): Promise<void>;
7
11
  };
12
+ /** No provider response or credential is included in this error. `status` lets Accounts ask for sign-in again. */
13
+ export declare class RefreshRequiredError extends Error {
14
+ readonly status = 401;
15
+ constructor();
16
+ }
17
+ /** Legacy credentials have no marker. Unknown or incomplete state fails closed. */
18
+ export declare function needsReauth(c: Credential | undefined): boolean;
19
+ /** Bare CredentialStore implementations cannot persist inside their modify callback. Require the transaction seam
20
+ * rather than send a grant without its attempt marker; custom stores can use recordStore(load, save). */
21
+ export declare function refreshCredential(store: CredentialStore, id: string, due: (c: OAuthCredential) => boolean, rotate: (c: OAuthCredential) => Promise<OAuthCredential>): Promise<OAuthCredential | undefined>;
8
22
  /** A store over one whole record the platform loads and saves. Writes are serialized within this process; a write
9
23
  * re-reads first, so a sign-in that took minutes never overwrites a provider that changed meanwhile. */
10
24
  export declare function recordStore(load: () => Promise<Record>, save: (data: Record) => Promise<void>): EndingStore;
11
- export declare function memoryStore(): CredentialStore;
25
+ export declare function memoryStore(): EndingStore;
12
26
  /** The parts of `expo-secure-store` this uses (Keychain on iOS, Keystore-encrypted on Android); pass the module itself.
13
27
  * Every method takes the same optional `options` (e.g. `{ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }`). */
14
28
  export type SecureStoreLike = {
@@ -21,7 +35,7 @@ export type SecureStoreLike = {
21
35
  * as a new generation, then `name` is pointed at it: a crash mid-write leaves the old sign-ins whole. `options` (e.g.
22
36
  * `{ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }`) is passed to every get, set and delete;
23
37
  * without it Expo's default (`WHEN_UNLOCKED`) applies. */
24
- export declare function secureStore(secure: SecureStoreLike, name: string, options?: object): CredentialStore;
38
+ export declare function secureStore(secure: SecureStoreLike, name: string, options?: object): EndingStore;
25
39
  /** One person's sign-ins in the browser's IndexedDB (a PWA, or Electron's renderer), under `name`. A browser has no
26
40
  * keychain: anything running on this page could read them, so keep the page free of scripts you don't control. */
27
41
  export declare function browserStore(name: string, db?: string): EndingStore;
package/dist/stores.js CHANGED
@@ -1,3 +1,30 @@
1
+ /** No provider response or credential is included in this error. `status` lets Accounts ask for sign-in again. */
2
+ export class RefreshRequiredError extends Error {
3
+ status = 401;
4
+ constructor() { super('This sign-in needs to be connected again.'); this.name = 'RefreshRequiredError'; }
5
+ }
6
+ /** Legacy credentials have no marker. Unknown or incomplete state fails closed. */
7
+ export function needsReauth(c) {
8
+ if (c?.type !== 'oauth' || c.byokitRefresh === undefined)
9
+ return false;
10
+ const marker = c.byokitRefresh;
11
+ return !marker || marker.state !== 'ready' || !Number.isSafeInteger(marker.generation) || marker.generation < 0;
12
+ }
13
+ /** Bare CredentialStore implementations cannot persist inside their modify callback. Require the transaction seam
14
+ * rather than send a grant without its attempt marker; custom stores can use recordStore(load, save). */
15
+ export async function refreshCredential(store, id, due, rotate) {
16
+ const refresh = store.refresh;
17
+ if (refresh)
18
+ return refresh.call(store, id, due, rotate);
19
+ const c = await store.read(id);
20
+ if (c?.type !== 'oauth')
21
+ return undefined;
22
+ if (needsReauth(c))
23
+ throw new RefreshRequiredError();
24
+ if (!due(c))
25
+ return c;
26
+ throw new Error('Refresh requires a transactional credential store; use recordStore(load, save).');
27
+ }
1
28
  /** A store over one whole record the platform loads and saves. Writes are serialized within this process; a write
2
29
  * re-reads first, so a sign-in that took minutes never overwrites a provider that changed meanwhile. */
3
30
  export function recordStore(load, save) {
@@ -16,6 +43,42 @@ export function recordStore(load, save) {
16
43
  await save({ ...(await load()), [id]: next });
17
44
  return next;
18
45
  }),
46
+ refresh: (id, due, rotate) => serial(async () => {
47
+ const current = (await load())[id];
48
+ if (current?.type !== 'oauth')
49
+ return undefined;
50
+ if (needsReauth(current))
51
+ throw new RefreshRequiredError();
52
+ if (!due(current))
53
+ return current;
54
+ const generation = current.byokitRefresh?.generation ?? 0;
55
+ const attempted = { ...current, byokitRefresh: { generation, state: 'attempted' } };
56
+ const write = async (c) => save({ ...(await load()), [id]: c });
57
+ // Never send if this write fails. The marker stays in the same sealed record as the old pair.
58
+ await write(attempted);
59
+ let next;
60
+ try {
61
+ next = await rotate(current);
62
+ if (next.refresh === current.refresh)
63
+ throw new Error('Refresh did not replace the grant');
64
+ if (current.accountId && next.accountId !== current.accountId)
65
+ throw new RefreshRequiredError();
66
+ }
67
+ catch (e) {
68
+ const state = [400, 401, 403].includes(e?.status) ? 'terminal' : 'uncertain';
69
+ // The before-send marker already prevents replay if the quarantine write also fails.
70
+ await write({ ...attempted, byokitRefresh: { generation, state } }).catch(() => { });
71
+ throw new RefreshRequiredError();
72
+ }
73
+ const committed = { ...current, ...next, byokitRefresh: { generation: generation + 1, state: 'ready' } };
74
+ try {
75
+ await write(committed);
76
+ }
77
+ catch {
78
+ throw new RefreshRequiredError();
79
+ }
80
+ return committed;
81
+ }),
19
82
  delete: (id) => serial(async () => { const data = await load(); if (id in data) {
20
83
  delete data[id];
21
84
  await save(data);
@@ -91,11 +154,13 @@ export function browserStore(name, db = 'byokit') {
91
154
  }
92
155
  };
93
156
  const store = recordStore(async () => (await run('readonly', (s) => s.get(name))) ?? {}, (data) => run('readwrite', (s) => s.put(data, name)));
94
- const locked = async (id, fn) => typeof navigator !== 'undefined' && navigator.locks ? await navigator.locks.request(`byokit:${db}:${name}:${id}`, fn) : fn();
157
+ // Every write replaces the whole record, so tabs must share a record lock, including the entire refresh.
158
+ const locked = async (fn) => typeof navigator !== 'undefined' && navigator.locks ? await navigator.locks.request(`byokit:${db}:${name}`, fn) : fn();
95
159
  return {
96
160
  ...store,
97
- modify: (id, fn, options) => locked(id, () => store.modify(id, fn, options)),
98
- delete: (id, options) => locked(id, () => store.delete(id, options)),
99
- end: (id, fn) => locked(id, () => store.end(id, fn)),
161
+ modify: (id, fn, options) => locked(() => store.modify(id, fn, options)),
162
+ refresh: (id, due, rotate) => locked(() => store.refresh(id, due, rotate)),
163
+ delete: (id, options) => locked(() => store.delete(id, options)),
164
+ end: (id, fn) => locked(() => store.end(id, fn)),
100
165
  };
101
166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/accounts",
3
- "version": "0.7.1",
3
+ "version": "0.9.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"