@byokit/accounts 0.8.0 → 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 +6 -0
- package/README.md +34 -1
- package/dist/accounts.d.ts +4 -3
- package/dist/accounts.js +17 -4
- package/dist/engine.js +18 -7
- package/dist/node-stores.d.ts +2 -2
- package/dist/node-stores.js +10 -0
- package/dist/portable.d.ts +1 -1
- package/dist/portable.js +1 -1
- package/dist/stores.d.ts +18 -4
- package/dist/stores.js +69 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
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
|
+
|
|
5
11
|
## 0.8.0 (2026-09-30)
|
|
6
12
|
|
|
7
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.
|
package/README.md
CHANGED
|
@@ -191,9 +191,42 @@ when no handler is set).
|
|
|
191
191
|
|
|
192
192
|
`memoryStore()`, `fileStore(path, safeStorage)` (sealed, 0600), `secureStore(SecureStore, name,
|
|
193
193
|
options?)` or `browserStore(name)`; any other storage with `recordStore(load, save)`. Writes are serialized within a
|
|
194
|
-
store instance; `browserStore` also uses Web Locks across tabs for the
|
|
194
|
+
store instance; `browserStore` also uses Web Locks across tabs for the whole record when available. Never a shared
|
|
195
195
|
fallback.
|
|
196
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
|
+
|
|
197
230
|
- **Browser**: browser storage is readable by scripts on your page: avoid untrusted scripts.
|
|
198
231
|
- **Phone**: pass `{ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }` as `options` (to every get, set
|
|
199
232
|
and delete) so tokens never migrate to a new device through an iCloud/iTunes backup; without it Expo's default
|
package/dist/accounts.d.ts
CHANGED
|
@@ -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.
|
|
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();
|
|
@@ -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
|
-
//
|
|
457
|
-
// is unknown: try later
|
|
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.
|
|
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) =>
|
|
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
|
-
|
|
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),
|
package/dist/node-stores.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EndingStore } from './stores.ts';
|
|
2
2
|
/** Pass Electron's safeStorage from the main process after ready, or an equivalent trusted sealing adapter.
|
|
3
3
|
* Adapters without capability methods are responsible for ensuring their key is protected. */
|
|
4
4
|
export type SafeStorageLike = {
|
|
@@ -10,4 +10,4 @@ export type SafeStorageLike = {
|
|
|
10
10
|
/** One person's sealed sign-ins in an app-owned 0600 file inside a private 0700 folder.
|
|
11
11
|
* No plaintext fallback. Writes are serialized per store instance; use one instance per path and a host lock
|
|
12
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):
|
|
13
|
+
export declare function fileStore(path: string, safeStorage: SafeStorageLike): EndingStore;
|
package/dist/node-stores.js
CHANGED
|
@@ -65,6 +65,16 @@ export function fileStore(path, safeStorage) {
|
|
|
65
65
|
closeSync(fd);
|
|
66
66
|
}
|
|
67
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
|
+
}
|
|
68
78
|
}
|
|
69
79
|
finally {
|
|
70
80
|
if (created)
|
package/dist/portable.d.ts
CHANGED
|
@@ -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
|
-
|
|
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():
|
|
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):
|
|
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
|
-
|
|
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(
|
|
98
|
-
|
|
99
|
-
|
|
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
|
}
|