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.
@@ -0,0 +1,61 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import type { CoordinatedCredentialStore, OAuthCredential, ProviderId, VersionedCredential } from "./types.js";
3
+ /** Node SQLite persistence. Pass a DatabaseSync to share a host-owned connection. */
4
+ export declare class SqliteCredentialStore implements CoordinatedCredentialStore {
5
+ private readonly database;
6
+ private readonly ownsConnection;
7
+ private readonly queues;
8
+ constructor(database: string | DatabaseSync);
9
+ read(provider: ProviderId): Promise<OAuthCredential | null>;
10
+ listKeys(): Promise<string[]>;
11
+ readVersioned(provider: ProviderId): Promise<VersionedCredential>;
12
+ replaceCredential(input: {
13
+ provider: ProviderId;
14
+ credential: OAuthCredential;
15
+ expectedGeneration: number;
16
+ operationId: string;
17
+ }): Promise<{
18
+ applied: boolean;
19
+ record: VersionedCredential;
20
+ }>;
21
+ deleteCredential(input: {
22
+ provider: ProviderId;
23
+ operationId: string;
24
+ }): Promise<void>;
25
+ claimRefresh(input: {
26
+ provider: ProviderId;
27
+ expectedVersion: number;
28
+ expectedGeneration: number;
29
+ claimId: string;
30
+ operationId: string;
31
+ }): Promise<"claimed" | "busy" | "changed" | "missing">;
32
+ commitRefresh(input: {
33
+ provider: ProviderId;
34
+ claimId: string;
35
+ expectedGeneration: number;
36
+ credential: OAuthCredential;
37
+ operationId: string;
38
+ }): Promise<{
39
+ applied: boolean;
40
+ record: VersionedCredential;
41
+ }>;
42
+ modify(provider: ProviderId, update: (current: OAuthCredential | null) => OAuthCredential | null | Promise<OAuthCredential | null>): Promise<OAuthCredential | null>;
43
+ delete(provider: ProviderId): Promise<void>;
44
+ close(): void;
45
+ private row;
46
+ private generation;
47
+ private setGeneration;
48
+ private writeCredential;
49
+ private versioned;
50
+ private operation;
51
+ private rememberOperation;
52
+ }
53
+ export declare class SqliteApiKeyStore {
54
+ private readonly database;
55
+ private readonly ownsConnection;
56
+ constructor(database: string | DatabaseSync);
57
+ readOrCreate(): Promise<string>;
58
+ regenerate(): Promise<string>;
59
+ close(): void;
60
+ private read;
61
+ }
@@ -0,0 +1,323 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { dirname } from "node:path";
3
+ import { mkdirSync } from "node:fs";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ /** Node SQLite persistence. Pass a DatabaseSync to share a host-owned connection. */
6
+ export class SqliteCredentialStore {
7
+ database;
8
+ ownsConnection;
9
+ queues = new Map();
10
+ constructor(database) {
11
+ if (typeof database === "string") {
12
+ mkdirSync(dirname(database), { recursive: true, mode: 0o700 });
13
+ this.database = new DatabaseSync(database, { timeout: 5_000 });
14
+ this.ownsConnection = true;
15
+ }
16
+ else {
17
+ this.database = database;
18
+ this.ownsConnection = false;
19
+ }
20
+ this.database.exec(`
21
+ PRAGMA foreign_keys = ON;
22
+ CREATE TABLE IF NOT EXISTS aisubs_credentials (
23
+ account_key TEXT PRIMARY KEY,
24
+ credential_json TEXT NOT NULL,
25
+ version INTEGER NOT NULL,
26
+ updated_at INTEGER NOT NULL
27
+ ) STRICT;
28
+ CREATE TABLE IF NOT EXISTS aisubs_refresh_claims (
29
+ account_key TEXT PRIMARY KEY,
30
+ claim_id TEXT NOT NULL UNIQUE,
31
+ credential_version INTEGER NOT NULL,
32
+ account_generation INTEGER NOT NULL,
33
+ created_at INTEGER NOT NULL
34
+ ) STRICT;
35
+ CREATE TABLE IF NOT EXISTS aisubs_account_generations (
36
+ account_key TEXT PRIMARY KEY,
37
+ generation INTEGER NOT NULL,
38
+ updated_at INTEGER NOT NULL
39
+ ) STRICT;
40
+ CREATE TABLE IF NOT EXISTS aisubs_operations (
41
+ operation_id TEXT PRIMARY KEY,
42
+ result_json TEXT NOT NULL,
43
+ created_at INTEGER NOT NULL
44
+ ) STRICT;
45
+ CREATE TABLE IF NOT EXISTS aisubs_api_keys (
46
+ name TEXT PRIMARY KEY,
47
+ value TEXT NOT NULL,
48
+ updated_at INTEGER NOT NULL
49
+ ) STRICT;
50
+ `);
51
+ }
52
+ async read(provider) {
53
+ const row = this.row(provider);
54
+ return row ? JSON.parse(row.credential_json) : null;
55
+ }
56
+ async listKeys() {
57
+ return this.database.prepare("SELECT account_key FROM aisubs_credentials ORDER BY rowid").all().map(({ account_key }) => account_key);
58
+ }
59
+ async readVersioned(provider) {
60
+ const row = this.row(provider);
61
+ return {
62
+ credential: row ? JSON.parse(row.credential_json) : null,
63
+ version: row?.version ?? 0,
64
+ generation: this.generation(provider),
65
+ };
66
+ }
67
+ async replaceCredential(input) {
68
+ const replay = this.operation(input.operationId);
69
+ if (replay)
70
+ return replay;
71
+ this.database.exec("BEGIN IMMEDIATE");
72
+ try {
73
+ const generation = this.generation(input.provider);
74
+ if (generation !== input.expectedGeneration) {
75
+ const result = { applied: false, record: this.versioned(input.provider) };
76
+ this.rememberOperation(input.operationId, result);
77
+ this.database.exec("COMMIT");
78
+ return result;
79
+ }
80
+ this.writeCredential(input.provider, input.credential);
81
+ this.setGeneration(input.provider, generation + 1);
82
+ this.database
83
+ .prepare("DELETE FROM aisubs_refresh_claims WHERE account_key = ?")
84
+ .run(input.provider);
85
+ const result = { applied: true, record: this.versioned(input.provider) };
86
+ this.rememberOperation(input.operationId, result);
87
+ this.database.exec("COMMIT");
88
+ return result;
89
+ }
90
+ catch (error) {
91
+ this.database.exec("ROLLBACK");
92
+ throw error;
93
+ }
94
+ }
95
+ async deleteCredential(input) {
96
+ if (this.operation(input.operationId))
97
+ return;
98
+ this.database.exec("BEGIN IMMEDIATE");
99
+ try {
100
+ this.database
101
+ .prepare("DELETE FROM aisubs_credentials WHERE account_key = ?")
102
+ .run(input.provider);
103
+ this.database
104
+ .prepare("DELETE FROM aisubs_refresh_claims WHERE account_key = ?")
105
+ .run(input.provider);
106
+ this.setGeneration(input.provider, this.generation(input.provider) + 1);
107
+ this.rememberOperation(input.operationId, { applied: true });
108
+ this.database.exec("COMMIT");
109
+ }
110
+ catch (error) {
111
+ this.database.exec("ROLLBACK");
112
+ throw error;
113
+ }
114
+ }
115
+ async claimRefresh(input) {
116
+ const replay = this.operation(input.operationId);
117
+ if (replay?.value)
118
+ return replay.value;
119
+ this.database.exec("BEGIN IMMEDIATE");
120
+ try {
121
+ const row = this.row(input.provider);
122
+ const generation = this.generation(input.provider);
123
+ const value = !row
124
+ ? "missing"
125
+ : row.version !== input.expectedVersion || generation !== input.expectedGeneration
126
+ ? "changed"
127
+ : this.database
128
+ .prepare("SELECT 1 FROM aisubs_refresh_claims WHERE account_key = ?")
129
+ .get(input.provider)
130
+ ? "busy"
131
+ : "claimed";
132
+ if (value === "claimed") {
133
+ this.database
134
+ .prepare(`INSERT INTO aisubs_refresh_claims(account_key, claim_id, credential_version, account_generation, created_at)
135
+ VALUES(?, ?, ?, ?, ?)`)
136
+ .run(input.provider, input.claimId, input.expectedVersion, input.expectedGeneration, Date.now());
137
+ }
138
+ this.rememberOperation(input.operationId, { value });
139
+ this.database.exec("COMMIT");
140
+ return value;
141
+ }
142
+ catch (error) {
143
+ this.database.exec("ROLLBACK");
144
+ throw error;
145
+ }
146
+ }
147
+ async commitRefresh(input) {
148
+ const replay = this.operation(input.operationId);
149
+ if (replay)
150
+ return replay;
151
+ this.database.exec("BEGIN IMMEDIATE");
152
+ try {
153
+ const claim = this.database
154
+ .prepare(`SELECT account_generation FROM aisubs_refresh_claims
155
+ WHERE account_key = ? AND claim_id = ?`)
156
+ .get(input.provider, input.claimId);
157
+ const applied = Boolean(claim &&
158
+ claim.account_generation === input.expectedGeneration &&
159
+ this.generation(input.provider) === input.expectedGeneration);
160
+ if (applied) {
161
+ this.writeCredential(input.provider, input.credential);
162
+ this.database
163
+ .prepare("DELETE FROM aisubs_refresh_claims WHERE account_key = ? AND claim_id = ?")
164
+ .run(input.provider, input.claimId);
165
+ }
166
+ const result = { applied, record: this.versioned(input.provider) };
167
+ this.rememberOperation(input.operationId, result);
168
+ this.database.exec("COMMIT");
169
+ return result;
170
+ }
171
+ catch (error) {
172
+ this.database.exec("ROLLBACK");
173
+ throw error;
174
+ }
175
+ }
176
+ async modify(provider, update) {
177
+ const previous = this.queues.get(provider) ?? Promise.resolve();
178
+ let result = null;
179
+ const current = previous.then(async () => {
180
+ for (;;) {
181
+ const observed = this.row(provider);
182
+ result = await update(observed ? JSON.parse(observed.credential_json) : null);
183
+ this.database.exec("BEGIN IMMEDIATE");
184
+ try {
185
+ const latest = this.row(provider);
186
+ if ((latest?.version ?? 0) !== (observed?.version ?? 0)) {
187
+ this.database.exec("ROLLBACK");
188
+ continue;
189
+ }
190
+ if (result) {
191
+ this.database
192
+ .prepare(`INSERT INTO aisubs_credentials(account_key, credential_json, version, updated_at)
193
+ VALUES(?, ?, 1, ?)
194
+ ON CONFLICT(account_key) DO UPDATE SET credential_json = excluded.credential_json,
195
+ version = aisubs_credentials.version + 1, updated_at = excluded.updated_at`)
196
+ .run(provider, JSON.stringify(result), Date.now());
197
+ }
198
+ else {
199
+ this.database
200
+ .prepare("DELETE FROM aisubs_credentials WHERE account_key = ?")
201
+ .run(provider);
202
+ }
203
+ this.database.exec("COMMIT");
204
+ return;
205
+ }
206
+ catch (error) {
207
+ this.database.exec("ROLLBACK");
208
+ throw error;
209
+ }
210
+ }
211
+ });
212
+ const settled = current.catch(() => { });
213
+ this.queues.set(provider, settled);
214
+ try {
215
+ await current;
216
+ }
217
+ finally {
218
+ if (this.queues.get(provider) === settled)
219
+ this.queues.delete(provider);
220
+ }
221
+ return result;
222
+ }
223
+ async delete(provider) {
224
+ await this.modify(provider, () => null);
225
+ }
226
+ close() {
227
+ if (this.ownsConnection)
228
+ this.database.close();
229
+ }
230
+ row(provider) {
231
+ return this.database
232
+ .prepare("SELECT credential_json, version FROM aisubs_credentials WHERE account_key = ?")
233
+ .get(provider);
234
+ }
235
+ generation(provider) {
236
+ const row = this.database
237
+ .prepare("SELECT generation FROM aisubs_account_generations WHERE account_key = ?")
238
+ .get(provider);
239
+ return row?.generation ?? 0;
240
+ }
241
+ setGeneration(provider, generation) {
242
+ this.database
243
+ .prepare(`INSERT INTO aisubs_account_generations(account_key, generation, updated_at) VALUES(?, ?, ?)
244
+ ON CONFLICT(account_key) DO UPDATE SET generation = excluded.generation, updated_at = excluded.updated_at`)
245
+ .run(provider, generation, Date.now());
246
+ }
247
+ writeCredential(provider, credential) {
248
+ this.database
249
+ .prepare(`INSERT INTO aisubs_credentials(account_key, credential_json, version, updated_at)
250
+ VALUES(?, ?, 1, ?)
251
+ ON CONFLICT(account_key) DO UPDATE SET credential_json = excluded.credential_json,
252
+ version = aisubs_credentials.version + 1, updated_at = excluded.updated_at`)
253
+ .run(provider, JSON.stringify(credential), Date.now());
254
+ }
255
+ versioned(provider) {
256
+ const row = this.row(provider);
257
+ return {
258
+ credential: row ? JSON.parse(row.credential_json) : null,
259
+ version: row?.version ?? 0,
260
+ generation: this.generation(provider),
261
+ };
262
+ }
263
+ operation(operationId) {
264
+ const row = this.database
265
+ .prepare("SELECT result_json FROM aisubs_operations WHERE operation_id = ?")
266
+ .get(operationId);
267
+ return row ? JSON.parse(row.result_json) : undefined;
268
+ }
269
+ rememberOperation(operationId, result) {
270
+ this.database
271
+ .prepare("INSERT OR IGNORE INTO aisubs_operations(operation_id, result_json, created_at) VALUES(?, ?, ?)")
272
+ .run(operationId, JSON.stringify(result), Date.now());
273
+ }
274
+ }
275
+ export class SqliteApiKeyStore {
276
+ database;
277
+ ownsConnection;
278
+ constructor(database) {
279
+ if (typeof database === "string") {
280
+ mkdirSync(dirname(database), { recursive: true, mode: 0o700 });
281
+ this.database = new DatabaseSync(database, { timeout: 5_000 });
282
+ this.ownsConnection = true;
283
+ }
284
+ else {
285
+ this.database = database;
286
+ this.ownsConnection = false;
287
+ }
288
+ this.database.exec(`CREATE TABLE IF NOT EXISTS aisubs_api_keys (
289
+ name TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL
290
+ ) STRICT;`);
291
+ }
292
+ async readOrCreate() {
293
+ const existing = this.read();
294
+ if (existing)
295
+ return existing;
296
+ const value = apiKey();
297
+ this.database
298
+ .prepare("INSERT OR IGNORE INTO aisubs_api_keys(name, value, updated_at) VALUES('default', ?, ?)")
299
+ .run(value, Date.now());
300
+ return this.read() ?? value;
301
+ }
302
+ async regenerate() {
303
+ const value = apiKey();
304
+ this.database
305
+ .prepare(`INSERT INTO aisubs_api_keys(name, value, updated_at) VALUES('default', ?, ?)
306
+ ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
307
+ .run(value, Date.now());
308
+ return value;
309
+ }
310
+ close() {
311
+ if (this.ownsConnection)
312
+ this.database.close();
313
+ }
314
+ read() {
315
+ const row = this.database
316
+ .prepare("SELECT value FROM aisubs_api_keys WHERE name = 'default'")
317
+ .get();
318
+ return row?.value ?? null;
319
+ }
320
+ }
321
+ function apiKey() {
322
+ return `aisubs_${randomBytes(32).toString("base64url")}_${randomUUID().slice(0, 8)}`;
323
+ }
package/dist/store.d.ts CHANGED
@@ -1,24 +1 @@
1
- import type { CredentialStore, OAuthCredential, ProviderId } from "./types.js";
2
1
  export declare function defaultAiSubsDataDir(): string;
3
- export declare class FileApiKeyStore {
4
- readonly file: string;
5
- constructor(file: string);
6
- readOrCreate(): Promise<string>;
7
- regenerate(): Promise<string>;
8
- }
9
- export declare class FileCredentialStore implements CredentialStore {
10
- readonly file: string;
11
- constructor(file: string);
12
- read(provider: ProviderId): Promise<OAuthCredential | null>;
13
- listKeys(): Promise<string[]>;
14
- modify(provider: ProviderId, update: (current: OAuthCredential | null) => OAuthCredential | null | Promise<OAuthCredential | null>): Promise<OAuthCredential | null>;
15
- delete(provider: ProviderId): Promise<void>;
16
- }
17
- export declare class MemoryCredentialStore implements CredentialStore {
18
- private readonly values;
19
- private readonly queues;
20
- read(provider: ProviderId): Promise<OAuthCredential | null>;
21
- listKeys(): Promise<string[]>;
22
- modify(provider: ProviderId, update: (current: OAuthCredential | null) => OAuthCredential | null | Promise<OAuthCredential | null>): Promise<OAuthCredential | null>;
23
- delete(provider: ProviderId): Promise<void>;
24
- }
package/dist/store.js CHANGED
@@ -1,196 +1,6 @@
1
- import { createHash, randomBytes } from "node:crypto";
2
- import { chmod, link, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
1
  import { homedir } from "node:os";
4
- import { dirname, join, resolve } from "node:path";
5
- import { abortableDelay, isRecord } from "./utils.js";
6
- const LOCK_TIMEOUT_MS = 15_000;
7
- const LOCK_STALE_MS = 120_000;
8
- function isCredential(value) {
9
- if (!isRecord(value) || typeof value.accessToken !== "string")
10
- return false;
11
- if (typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt))
12
- return false;
13
- if (value.refreshToken != null && typeof value.refreshToken !== "string")
14
- return false;
15
- if (value.account != null &&
16
- (!isRecord(value.account) ||
17
- ![value.account.id, value.account.label, value.account.email, value.account.plan].every((item) => item == null || typeof item === "string")))
18
- return false;
19
- return (value.metadata == null ||
20
- (isRecord(value.metadata) &&
21
- Object.values(value.metadata).every((item) => item == null ||
22
- typeof item === "string" ||
23
- typeof item === "number" ||
24
- typeof item === "boolean")));
25
- }
2
+ import { join, resolve } from "node:path";
26
3
  export function defaultAiSubsDataDir() {
27
4
  const override = process.env.AISUBS_DATA_DIR?.trim();
28
5
  return override ? resolve(override) : join(homedir(), ".aisubs");
29
6
  }
30
- export class FileApiKeyStore {
31
- file;
32
- constructor(file) {
33
- this.file = file;
34
- }
35
- async readOrCreate() {
36
- try {
37
- const value = (await readFile(this.file, "utf8")).trim();
38
- if (value)
39
- return value;
40
- }
41
- catch (error) {
42
- if (error.code !== "ENOENT")
43
- throw error;
44
- }
45
- const value = `aisubs_${randomBytes(32).toString("base64url")}`;
46
- const temp = `${this.file}.${process.pid}.${crypto.randomUUID()}.tmp`;
47
- await mkdir(dirname(this.file), { recursive: true, mode: 0o700 });
48
- try {
49
- await writeFile(temp, `${value}\n`, { mode: 0o600 });
50
- try {
51
- await link(temp, this.file);
52
- return value;
53
- }
54
- catch (error) {
55
- if (error.code !== "EEXIST")
56
- throw error;
57
- const existing = (await readFile(this.file, "utf8")).trim();
58
- return existing || this.regenerate();
59
- }
60
- }
61
- finally {
62
- await rm(temp, { force: true }).catch(() => { });
63
- }
64
- }
65
- async regenerate() {
66
- const value = `aisubs_${randomBytes(32).toString("base64url")}`;
67
- const temp = `${this.file}.${process.pid}.${crypto.randomUUID()}.tmp`;
68
- await mkdir(dirname(this.file), { recursive: true, mode: 0o700 });
69
- try {
70
- await writeFile(temp, `${value}\n`, { mode: 0o600 });
71
- await rename(temp, this.file);
72
- return value;
73
- }
74
- finally {
75
- await rm(temp, { force: true }).catch(() => { });
76
- }
77
- }
78
- }
79
- async function readEnvelope(file) {
80
- try {
81
- const parsed = JSON.parse(await readFile(file, "utf8"));
82
- if (!isRecord(parsed))
83
- throw new Error("Credential store must contain a JSON object");
84
- const credentials = {};
85
- for (const [key, value] of Object.entries(parsed)) {
86
- if (!isCredential(value))
87
- throw new Error(`Credential store entry ${key} is invalid`);
88
- credentials[key] = value;
89
- }
90
- return credentials;
91
- }
92
- catch (error) {
93
- if (error.code === "ENOENT")
94
- return {};
95
- throw error;
96
- }
97
- }
98
- async function withFileLock(path, task) {
99
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
100
- const deadline = Date.now() + LOCK_TIMEOUT_MS;
101
- let handle;
102
- while (!handle) {
103
- try {
104
- handle = await open(path, "wx", 0o600);
105
- }
106
- catch (error) {
107
- if (error.code !== "EEXIST")
108
- throw error;
109
- try {
110
- if (Date.now() - (await stat(path)).mtimeMs > LOCK_STALE_MS)
111
- await rm(path, { force: true });
112
- }
113
- catch { }
114
- if (Date.now() >= deadline)
115
- throw new Error("Timed out waiting for credential-store lock");
116
- await abortableDelay(25);
117
- }
118
- }
119
- try {
120
- return await task();
121
- }
122
- finally {
123
- await handle.close().catch(() => { });
124
- await rm(path, { force: true }).catch(() => { });
125
- }
126
- }
127
- async function writeEnvelope(file, value) {
128
- const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
129
- await mkdir(dirname(file), { recursive: true, mode: 0o700 });
130
- try {
131
- await writeFile(temp, JSON.stringify(value, null, 2), { mode: 0o600 });
132
- await rename(temp, file);
133
- if (process.platform !== "win32")
134
- await chmod(file, 0o600);
135
- }
136
- finally {
137
- await rm(temp, { force: true }).catch(() => { });
138
- }
139
- }
140
- export class FileCredentialStore {
141
- file;
142
- constructor(file) {
143
- this.file = file;
144
- }
145
- async read(provider) {
146
- return (await readEnvelope(this.file))[provider] ?? null;
147
- }
148
- async listKeys() {
149
- return Object.keys(await readEnvelope(this.file));
150
- }
151
- async modify(provider, update) {
152
- const lockId = createHash("sha256").update(provider).digest("hex");
153
- return withFileLock(join(dirname(this.file), `${lockId}.credential.lock`), async () => {
154
- const next = await update(await this.read(provider));
155
- await withFileLock(`${this.file}.lock`, async () => {
156
- const all = await readEnvelope(this.file);
157
- if (next)
158
- all[provider] = next;
159
- else
160
- delete all[provider];
161
- await writeEnvelope(this.file, all);
162
- });
163
- return next;
164
- });
165
- }
166
- async delete(provider) {
167
- await this.modify(provider, () => null);
168
- }
169
- }
170
- export class MemoryCredentialStore {
171
- values = new Map();
172
- queues = new Map();
173
- async read(provider) {
174
- return this.values.get(provider) ?? null;
175
- }
176
- async listKeys() {
177
- return [...this.values.keys()];
178
- }
179
- async modify(provider, update) {
180
- const previous = this.queues.get(provider) ?? Promise.resolve();
181
- let result = null;
182
- const current = previous.then(async () => {
183
- result = await update(this.values.get(provider) ?? null);
184
- if (result)
185
- this.values.set(provider, result);
186
- else
187
- this.values.delete(provider);
188
- });
189
- this.queues.set(provider, current.catch(() => { }));
190
- await current;
191
- return result;
192
- }
193
- async delete(provider) {
194
- await this.modify(provider, () => null);
195
- }
196
- }
package/dist/types.d.ts CHANGED
@@ -18,6 +18,45 @@ export interface CredentialStore {
18
18
  modify(provider: ProviderId, update: (current: OAuthCredential | null) => OAuthCredential | null | Promise<OAuthCredential | null>): Promise<OAuthCredential | null>;
19
19
  delete(provider: ProviderId): Promise<void>;
20
20
  }
21
+ export interface VersionedCredential {
22
+ credential: OAuthCredential | null;
23
+ version: number;
24
+ generation: number;
25
+ }
26
+ /** Structured, remote-safe credential mutations used by hosted/shared stores. */
27
+ export interface CoordinatedCredentialStore extends CredentialStore {
28
+ readVersioned(provider: ProviderId): Promise<VersionedCredential>;
29
+ replaceCredential(input: {
30
+ provider: ProviderId;
31
+ credential: OAuthCredential;
32
+ expectedGeneration: number;
33
+ operationId: string;
34
+ }): Promise<{
35
+ applied: boolean;
36
+ record: VersionedCredential;
37
+ }>;
38
+ deleteCredential(input: {
39
+ provider: ProviderId;
40
+ operationId: string;
41
+ }): Promise<void>;
42
+ claimRefresh(input: {
43
+ provider: ProviderId;
44
+ expectedVersion: number;
45
+ expectedGeneration: number;
46
+ claimId: string;
47
+ operationId: string;
48
+ }): Promise<"claimed" | "busy" | "changed" | "missing">;
49
+ commitRefresh(input: {
50
+ provider: ProviderId;
51
+ claimId: string;
52
+ expectedGeneration: number;
53
+ credential: OAuthCredential;
54
+ operationId: string;
55
+ }): Promise<{
56
+ applied: boolean;
57
+ record: VersionedCredential;
58
+ }>;
59
+ }
21
60
  export interface UsageMeter {
22
61
  id: string;
23
62
  label: string;
@@ -72,6 +111,7 @@ export interface ProviderModel {
72
111
  maxOutputTokens?: number;
73
112
  reasoningEfforts?: string[];
74
113
  inputModalities?: string[];
114
+ outputModalities?: string[];
75
115
  endpoints?: string[];
76
116
  supportsToolCall?: boolean;
77
117
  available?: boolean;