aisubs 0.3.6 → 0.3.8

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
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.8 - 2026-09-19
4
+
5
+ - Breaking: `createSubscriptionAuth` now requires an explicit credential store.
6
+ - Breaking: provider factories use the `aisubs/providers/*` entrypoints so the core `aisubs` import stays runtime-portable.
7
+ - Add `aisubs/node` SQLite credential and API-key stores with shared-connection support and cross-process refresh ownership.
8
+ - Persist standalone AISubs records in `~/.aisubs/aisubs.db` without retaining the legacy file-store implementation.
9
+
10
+ ## 0.3.7 - 2026-09-16
11
+
12
+ - Improve provider compatibility, proxy handling, and request normalization.
13
+ - Harden dashboard account flows, modal interactions, and stale-load handling.
14
+ - Add regression coverage for compatibility, proxy headers, authentication, and
15
+ dashboard behavior.
16
+
3
17
  ## 0.3.6 - 2026-09-15
4
18
 
5
19
  - Preserve provider cache controls and cache usage across OpenAI-compatible,
package/README.md CHANGED
@@ -438,9 +438,14 @@ bun add aisubs
438
438
  ```
439
439
 
440
440
  ```js
441
- import { chatGptProvider, createSubscriptionAuth } from "aisubs";
441
+ import { createSubscriptionAuth } from "aisubs";
442
+ import { SqliteCredentialStore } from "aisubs/node";
443
+ import { chatGptProvider } from "aisubs/providers/chatgpt";
442
444
 
443
- const subscriptions = createSubscriptionAuth({ providers: [chatGptProvider()] });
445
+ const subscriptions = createSubscriptionAuth({
446
+ store: new SqliteCredentialStore("./data/aisubs.db"),
447
+ providers: [chatGptProvider()],
448
+ });
444
449
  const account = subscriptions.account("chatgpt", "personal");
445
450
 
446
451
  if (!(await account.status()).authenticated) {
@@ -493,19 +498,16 @@ Useful account methods:
493
498
  <summary><strong>Configure every provider and custom credential storage</strong></summary>
494
499
 
495
500
  ```js
496
- import {
497
- FileCredentialStore,
498
- chatGptProvider,
499
- claudeProvider,
500
- copilotProvider,
501
- createSubscriptionAuth,
502
- grokProvider,
503
- openCodeGoProvider,
504
- openCodeZenProvider,
505
- } from "aisubs";
501
+ import { createSubscriptionAuth } from "aisubs";
502
+ import { SqliteCredentialStore } from "aisubs/node";
503
+ import { chatGptProvider } from "aisubs/providers/chatgpt";
504
+ import { claudeProvider } from "aisubs/providers/claude";
505
+ import { copilotProvider } from "aisubs/providers/copilot";
506
+ import { grokProvider } from "aisubs/providers/grok";
507
+ import { openCodeGoProvider, openCodeZenProvider } from "aisubs/providers/opencode";
506
508
 
507
509
  const subscriptions = createSubscriptionAuth({
508
- store: new FileCredentialStore("./data/aisubs-credentials.json"),
510
+ store: new SqliteCredentialStore("./data/aisubs.db"),
509
511
  providers: [
510
512
  chatGptProvider(),
511
513
  claudeProvider(),
@@ -517,9 +519,10 @@ const subscriptions = createSubscriptionAuth({
517
519
  });
518
520
  ```
519
521
 
520
- Without a custom store, credentials are saved to
521
- `~/.aisubs/credentials.json`. Select an account with its provider ID and a
522
- local account name:
522
+ Storage is always explicit. The core SDK never discovers a home directory,
523
+ creates files, starts a server, or opens a browser. The standalone CLI composes
524
+ the SQLite adapter at `~/.aisubs/aisubs.db`. Select an account with its provider
525
+ ID and a local account name:
523
526
 
524
527
  ```js
525
528
  const chatgpt = subscriptions.account("chatgpt", "personal");
@@ -596,25 +599,23 @@ const response = await selected.proxy("responses", requestOptions);
596
599
  <summary><strong>Run the local HTTP server from Node.js</strong></summary>
597
600
 
598
601
  This is the programmatic equivalent of `aisubs dashboard`. The API key is
599
- created once and reused across restarts; delete or regenerate the key file only
600
- when clients should receive a new key.
602
+ created once and reused across restarts; regenerate it when clients should
603
+ receive a new key.
601
604
 
602
605
  ```js
603
606
  import { homedir } from "node:os";
604
607
  import { join } from "node:path";
605
- import {
606
- FileApiKeyStore,
607
- FileCredentialStore,
608
- chatGptProvider,
609
- claudeProvider,
610
- createSubscriptionAuth,
611
- } from "aisubs";
608
+ import { createSubscriptionAuth } from "aisubs";
612
609
  import { createSubscriptionAuthServer } from "aisubs/http";
610
+ import { SqliteApiKeyStore, SqliteCredentialStore } from "aisubs/node";
611
+ import { chatGptProvider } from "aisubs/providers/chatgpt";
612
+ import { claudeProvider } from "aisubs/providers/claude";
613
613
 
614
614
  const directory = join(homedir(), ".aisubs");
615
- const apiKey = await new FileApiKeyStore(join(directory, "api-key")).readOrCreate();
615
+ const database = join(directory, "aisubs.db");
616
+ const apiKey = await new SqliteApiKeyStore(database).readOrCreate();
616
617
  const auth = createSubscriptionAuth({
617
- store: new FileCredentialStore(join(directory, "credentials.json")),
618
+ store: new SqliteCredentialStore(database),
618
619
  providers: [chatGptProvider(), claudeProvider()],
619
620
  });
620
621
 
@@ -674,8 +675,7 @@ the old key.
674
675
 
675
676
  ## Storage and security
676
677
 
677
- - Credentials: `~/.aisubs/credentials.json`.
678
- - Persistent local API key: `~/.aisubs/api-key`.
678
+ - Credentials and persistent local API key: `~/.aisubs/aisubs.db`.
679
679
  - Optional Codex catalog: `~/.codex/aisubs-catalog.json`.
680
680
  - Codex integration stores the local AISubs key in the user-private Codex config.
681
681
  - State directories and files use private permissions where the platform supports them.
package/dist/auth.d.ts CHANGED
@@ -29,6 +29,7 @@ export declare class SubscriptionAuth {
29
29
  private readonly attempts;
30
30
  private readonly generations;
31
31
  private readonly refreshes;
32
+ private readonly coordinatedRefreshes;
32
33
  private readonly usageCache;
33
34
  private readonly modelsCache;
34
35
  private readonly usageInflight;
@@ -53,6 +54,7 @@ export declare class SubscriptionAuth {
53
54
  error: string | null;
54
55
  } | null;
55
56
  cancelLoginAttempt(id: string): boolean;
57
+ private cancelAttempt;
56
58
  status(provider: ProviderId, options?: {
57
59
  validate?: boolean;
58
60
  account?: string;
@@ -61,6 +63,7 @@ export declare class SubscriptionAuth {
61
63
  listAccounts(provider: ProviderId): Promise<Session[]>;
62
64
  signOut(provider: ProviderId, account?: string): Promise<void>;
63
65
  private credential;
66
+ private coordinatedCredential;
64
67
  getAccessToken(provider: ProviderId, account?: string): Promise<string>;
65
68
  credentialSummary(provider: ProviderId, account?: string): Promise<CredentialSummary>;
66
69
  details(provider: ProviderId, account?: string, signal?: AbortSignal): Promise<SubscriptionAccountDetails>;
@@ -73,6 +76,6 @@ export declare class SubscriptionAuth {
73
76
  account(provider: ProviderId, account: string): SubscriptionAccount;
74
77
  }
75
78
  export declare function createSubscriptionAuth(options: {
76
- store?: CredentialStore;
79
+ store: CredentialStore;
77
80
  providers: readonly ProviderAdapter[];
78
81
  } & SubscriptionAuthOptions): SubscriptionAuth;
package/dist/auth.js CHANGED
@@ -1,9 +1,13 @@
1
- import { defaultAiSubsDataDir, FileCredentialStore } from "./store.js";
2
1
  import { errorMessage } from "./utils.js";
3
- import { join } from "node:path";
4
2
  export const DEFAULT_ACCOUNT = "default";
5
3
  const ACCOUNT_STORAGE_PREFIX = "$subscription-account$";
6
4
  const LOGIN_ATTEMPT_RETENTION_MS = 5 * 60_000;
5
+ function isCoordinatedStore(store) {
6
+ return ("readVersioned" in store &&
7
+ "replaceCredential" in store &&
8
+ "claimRefresh" in store &&
9
+ "commitRefresh" in store);
10
+ }
7
11
  function normalizeAccountKey(value) {
8
12
  if (value == null)
9
13
  return DEFAULT_ACCOUNT;
@@ -63,6 +67,7 @@ export class SubscriptionAuth {
63
67
  attempts = new Map();
64
68
  generations = new Map();
65
69
  refreshes = new Map();
70
+ coordinatedRefreshes = new Map();
66
71
  usageCache = new Map();
67
72
  modelsCache = new Map();
68
73
  usageInflight = new Map();
@@ -157,31 +162,53 @@ export class SubscriptionAuth {
157
162
  const accountKey = normalizeAccountKey(options?.account);
158
163
  const scope = credentialKey(provider, accountKey);
159
164
  const replace = options?.replace !== false;
160
- if (!replace && (await this.store.read(scope))) {
165
+ const durable = isCoordinatedStore(this.store)
166
+ ? await this.store.readVersioned(scope)
167
+ : undefined;
168
+ if (!replace && (durable?.credential ?? (await this.store.read(scope)))) {
161
169
  throw new Error(`Account name ${accountKey} is already connected for ${provider}`);
162
170
  }
163
171
  const epoch = this.advance(scope);
164
172
  for (const attempt of this.attempts.values()) {
165
- if (attempt.scope === scope && attempt.state === "pending")
166
- attempt.abort.abort();
173
+ if (attempt.scope === scope)
174
+ this.cancelAttempt(attempt);
167
175
  }
168
176
  const abort = new AbortController();
177
+ const id = crypto.randomUUID();
169
178
  const providerOptions = { ...options };
170
179
  delete providerOptions.account;
171
180
  delete providerOptions.replace;
172
181
  const login = await adapter.startLogin(abort.signal, providerOptions);
173
- const id = crypto.randomUUID();
182
+ if (this.generation(scope) !== epoch) {
183
+ abort.abort();
184
+ void login.complete.catch(() => { });
185
+ throw new Error("Login cancelled");
186
+ }
174
187
  let record;
175
188
  const promise = login.complete
176
189
  .then(async (credential) => {
177
- const saved = await this.store.modify(scope, (current) => {
178
- if (this.generation(scope) !== epoch)
179
- return current;
180
- if (current && !replace) {
181
- throw new Error(`Account name ${accountKey} is already connected for ${provider}`);
182
- }
183
- return credential;
184
- });
190
+ let saved;
191
+ if (isCoordinatedStore(this.store)) {
192
+ const replacement = await this.store.replaceCredential({
193
+ provider: scope,
194
+ credential,
195
+ expectedGeneration: durable?.generation ?? 0,
196
+ operationId: `login:${id}`,
197
+ });
198
+ if (!replacement.applied)
199
+ throw new Error("Login cancelled");
200
+ saved = replacement.record.credential;
201
+ }
202
+ else {
203
+ saved = await this.store.modify(scope, (current) => {
204
+ if (this.generation(scope) !== epoch)
205
+ return current;
206
+ if (current && !replace) {
207
+ throw new Error(`Account name ${accountKey} is already connected for ${provider}`);
208
+ }
209
+ return credential;
210
+ });
211
+ }
185
212
  if (this.generation(scope) !== epoch || !saved)
186
213
  throw new Error("Login cancelled");
187
214
  this.clearMetadata(scope);
@@ -199,6 +226,7 @@ export class SubscriptionAuth {
199
226
  provider,
200
227
  accountKey,
201
228
  scope,
229
+ generation: epoch,
202
230
  state: "pending",
203
231
  error: null,
204
232
  abort,
@@ -218,10 +246,7 @@ export class SubscriptionAuth {
218
246
  return record.error;
219
247
  },
220
248
  wait: () => record.promise,
221
- cancel: () => {
222
- this.advance(scope);
223
- abort.abort();
224
- },
249
+ cancel: () => this.cancelAttempt(record),
225
250
  };
226
251
  }
227
252
  expireLoginAttempt(id) {
@@ -243,10 +268,17 @@ export class SubscriptionAuth {
243
268
  const attempt = this.attempts.get(id);
244
269
  if (!attempt || attempt.state !== "pending")
245
270
  return false;
246
- this.advance(attempt.scope);
247
- attempt.abort.abort();
271
+ this.cancelAttempt(attempt);
248
272
  return true;
249
273
  }
274
+ cancelAttempt(attempt) {
275
+ if (attempt.state !== "pending")
276
+ return;
277
+ if (this.generation(attempt.scope) === attempt.generation)
278
+ this.advance(attempt.scope);
279
+ attempt.state = "cancelled";
280
+ attempt.abort.abort();
281
+ }
250
282
  async status(provider, options = {}) {
251
283
  const accountKey = normalizeAccountKey(options.account);
252
284
  if (options.validate)
@@ -277,15 +309,34 @@ export class SubscriptionAuth {
277
309
  for (const refresh of this.refreshes.get(scope) ?? [])
278
310
  refresh.abort();
279
311
  for (const attempt of this.attempts.values()) {
280
- if (attempt.scope === scope && attempt.state === "pending")
281
- attempt.abort.abort();
312
+ if (attempt.scope === scope)
313
+ this.cancelAttempt(attempt);
314
+ }
315
+ if (isCoordinatedStore(this.store)) {
316
+ await this.store.deleteCredential({
317
+ provider: scope,
318
+ operationId: `logout:${crypto.randomUUID()}`,
319
+ });
320
+ }
321
+ else {
322
+ await this.store.delete(scope);
282
323
  }
283
- await this.store.delete(scope);
284
324
  }
285
325
  async credential(provider, account, forceRefresh = false) {
286
326
  const adapter = this.adapter(provider);
287
327
  const accountKey = normalizeAccountKey(account);
288
328
  const scope = credentialKey(provider, accountKey);
329
+ if (isCoordinatedStore(this.store)) {
330
+ const pending = this.coordinatedRefreshes.get(scope);
331
+ if (pending)
332
+ return pending;
333
+ const operation = this.coordinatedCredential(this.store, adapter, provider, accountKey, scope, forceRefresh).finally(() => {
334
+ if (this.coordinatedRefreshes.get(scope) === operation)
335
+ this.coordinatedRefreshes.delete(scope);
336
+ });
337
+ this.coordinatedRefreshes.set(scope, operation);
338
+ return operation;
339
+ }
289
340
  const observed = await this.store.read(scope);
290
341
  if (!observed)
291
342
  throw new Error(`Not authenticated with ${provider} account ${accountKey}`);
@@ -340,6 +391,74 @@ export class SubscriptionAuth {
340
391
  this.clearMetadata(scope);
341
392
  return refreshed;
342
393
  }
394
+ async coordinatedCredential(store, adapter, provider, accountKey, scope, forceRefresh) {
395
+ const observed = await store.readVersioned(scope);
396
+ const credential = observed.credential;
397
+ if (!credential)
398
+ throw new Error(`Not authenticated with ${provider} account ${accountKey}`);
399
+ if (credential.metadata?.reauthRequired === true) {
400
+ throw new Error(`Session expired for ${provider} account ${accountKey}; sign in again`);
401
+ }
402
+ if (!forceRefresh && credential.expiresAt > Date.now())
403
+ return credential;
404
+ const claimId = crypto.randomUUID();
405
+ const claim = await store.claimRefresh({
406
+ provider: scope,
407
+ expectedVersion: observed.version,
408
+ expectedGeneration: observed.generation,
409
+ claimId,
410
+ operationId: `refresh-claim:${claimId}`,
411
+ });
412
+ if (claim === "missing") {
413
+ throw new Error(`Not authenticated with ${provider} account ${accountKey}`);
414
+ }
415
+ if (claim !== "claimed") {
416
+ const deadline = Date.now() + this.refreshTimeoutMs;
417
+ while (Date.now() < deadline) {
418
+ await new Promise((resolve) => setTimeout(resolve, 50));
419
+ const latest = await store.readVersioned(scope);
420
+ if (latest.version === observed.version && latest.generation === observed.generation)
421
+ continue;
422
+ if (!latest.credential || latest.credential.metadata?.reauthRequired === true)
423
+ break;
424
+ if (latest.credential.expiresAt > Date.now())
425
+ return latest.credential;
426
+ break;
427
+ }
428
+ throw new Error(`Refresh outcome is uncertain for ${provider} account ${accountKey}; sign in again`);
429
+ }
430
+ let next;
431
+ try {
432
+ next = await adapter.refresh(credential, AbortSignal.timeout(this.refreshTimeoutMs));
433
+ }
434
+ catch (error) {
435
+ if (!adapter.isPermanentRefreshError?.(error))
436
+ throw error;
437
+ next = {
438
+ accessToken: "",
439
+ expiresAt: 0,
440
+ account: credential.account,
441
+ metadata: { ...credential.metadata, reauthRequired: true },
442
+ };
443
+ }
444
+ const committed = await store.commitRefresh({
445
+ provider: scope,
446
+ claimId,
447
+ expectedGeneration: observed.generation,
448
+ credential: next,
449
+ operationId: `refresh-commit:${claimId}`,
450
+ });
451
+ if (!committed.applied) {
452
+ throw new Error(`Session changed while refreshing ${provider} account ${accountKey}`);
453
+ }
454
+ const saved = committed.record.credential;
455
+ if (!saved || saved.metadata?.reauthRequired === true) {
456
+ this.clearMetadata(scope);
457
+ throw new Error(`Session expired for ${provider} account ${accountKey}; sign in again`);
458
+ }
459
+ this.clearMetadata(scope);
460
+ return saved;
461
+ }
343
462
  async getAccessToken(provider, account = DEFAULT_ACCOUNT) {
344
463
  const credential = await this.credential(provider, account);
345
464
  if (credential.metadata?.delegatedCli === true) {
@@ -502,6 +621,5 @@ export class SubscriptionAuth {
502
621
  }
503
622
  }
504
623
  export function createSubscriptionAuth(options) {
505
- const store = options.store ?? new FileCredentialStore(join(defaultAiSubsDataDir(), "credentials.json"));
506
- return new SubscriptionAuth(store, options.providers, options);
624
+ return new SubscriptionAuth(options.store, options.providers, options);
507
625
  }
package/dist/cli.js CHANGED
@@ -8,7 +8,8 @@ import { claudeProvider } from "./providers/claude.js";
8
8
  import { copilotProvider } from "./providers/copilot.js";
9
9
  import { grokProvider } from "./providers/grok.js";
10
10
  import { openCodeGoProvider, openCodeZenProvider } from "./providers/opencode.js";
11
- import { defaultAiSubsDataDir, FileApiKeyStore, FileCredentialStore } from "./store.js";
11
+ import { defaultAiSubsDataDir } from "./store.js";
12
+ import { SqliteApiKeyStore, SqliteCredentialStore } from "./sqlite-store.js";
12
13
  const DEFAULT_DASHBOARD_PORT = 4319;
13
14
  function usage() {
14
15
  console.log(`AI Subs
@@ -61,8 +62,9 @@ async function main() {
61
62
  }
62
63
  if (!Number.isInteger(port) || port < 0 || port > 65535)
63
64
  throw new Error("Invalid port");
64
- const store = new FileCredentialStore(join(dataDirectory, "credentials.json"));
65
- const apiKeys = new FileApiKeyStore(join(dataDirectory, "api-key"));
65
+ const database = join(dataDirectory, "aisubs.db");
66
+ const store = new SqliteCredentialStore(database);
67
+ const apiKeys = new SqliteApiKeyStore(database);
66
68
  const auth = createSubscriptionAuth({
67
69
  store,
68
70
  providers: [