@framers/agentos-ext-account-sources 0.6.1 → 0.7.1

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,794 @@
1
+ /**
2
+ * @fileoverview Fenced purge and revocation recovery for account sources. One short transaction blocks the owner,
3
+ * moves the connection, records a purge marker and moves the grant's refresh token into a sealed revoke-only queue;
4
+ * the purge completes in a recoverable transaction of its own; an account-independent worker revokes queued tokens
5
+ * before a fixed deadline. A durable grant lease serializes refreshes across workers, and the grant an authorization
6
+ * exchange returns is either installed or queued for revocation, never lost.
7
+ * @module account-sources/sync/SourceRevocations
8
+ */
9
+ import { randomUUID } from 'node:crypto';
10
+ import { SealedTokenStore, OAuthGrantRefused, sealSecret, openSecret, } from '@framers/agentos/auth';
11
+ import { SourceSyncFault } from './state.js';
12
+ /** The key a connection's grant is kept under in the host's sealed-value rows, inside its `sourceGrantNamespace`. */
13
+ export const SOURCE_GRANT_KEY = 'grant';
14
+ /**
15
+ * The sealed-store namespace of one connection's grant, naming its tenant, owner and connection: a sealed grant
16
+ * copied to another connection's row does not open. Initial installation, refresh and the transfer to the revoke-only
17
+ * queue all use it with `SOURCE_GRANT_KEY`.
18
+ */
19
+ export function sourceGrantNamespace(scope) {
20
+ return JSON.stringify(['AccountSourceGrant', scope.tenantId, scope.ownerId, scope.connectionId]);
21
+ }
22
+ const fields = (s) => [s.tenantId, s.ownerId, s.connectionId];
23
+ const ownerFields = (s) => [s.tenantId, s.ownerId];
24
+ const integer = (value) => Number.isSafeInteger(value) && value >= 0;
25
+ const key = (value) => typeof value === 'string' && value.length > 0 && value.length <= 512;
26
+ /** How long after it begins an exchange may install its grant; its queued token is held until then. */
27
+ const EXCHANGE_MS = 60_000;
28
+ /** Rolls back an installation the host refused or finished too late. */
29
+ class ExchangeRefused extends Error {
30
+ constructor() { super('source_exchange_refused'); }
31
+ }
32
+ /** How pressing a notice is, when a stopped grant and a returned grant both have one. */
33
+ const urgency = {
34
+ not_needed: 0, confirmed: 1, pending: 2, permission_removal_required: 3,
35
+ };
36
+ /** The notice of a connection's newest exchange: none while it is in flight or once it ended or installed. */
37
+ function exchangeNotice(row) {
38
+ if (!row || row.outcome === 'installed' || row.outcome === 'ended')
39
+ return 'not_needed';
40
+ if (row.outcome === 'unconfirmed')
41
+ return 'permission_removal_required';
42
+ if (!row.queue_id)
43
+ return 'not_needed';
44
+ if (row.state === 'pending')
45
+ return row.outcome === 'open' && row.lease_token === row.id ? 'not_needed' : 'pending';
46
+ return row.state === 'confirmed' ? 'confirmed' : 'permission_removal_required';
47
+ }
48
+ function tables(prefix = 'agentos_source_revoke') {
49
+ if (!/^[a-z][a-z0-9_]{0,27}$/.test(prefix))
50
+ throw new Error('invalid_source_revocation_prefix');
51
+ return { prefix, blocks: `"${prefix}_blocks"`, intents: `"${prefix}_intents"`,
52
+ queue: `"${prefix}_queue"`, grants: `"${prefix}_grant_leases"`, exchanges: `"${prefix}_exchanges"` };
53
+ }
54
+ /**
55
+ * PostgreSQL DDL for the migration-owned lifecycle tables: the owner's block marker, the per-connection stop
56
+ * intent, the revoke-only queue and its due index, the grant lease, and the authorization exchanges with their two
57
+ * indexes. The service never runs them: the host's migration does. No table references an account table, so
58
+ * removing an account cannot erase an outstanding revocation, and the rows hold opaque ids, times, fixed states,
59
+ * attempt counts and sealed refresh tokens only.
60
+ *
61
+ * @param prefix - Table-name prefix: a lower-case letter, then at most 27 lower-case letters, digits or underscores.
62
+ * @returns The eight statements, in the order they must run.
63
+ * @throws {Error} `invalid_source_revocation_prefix` when the prefix does not have that form.
64
+ */
65
+ export function sourceRevocationSchema(prefix = 'agentos_source_revoke') {
66
+ const t = tables(prefix);
67
+ return [
68
+ `CREATE TABLE IF NOT EXISTS ${t.blocks} (tenant_id TEXT NOT NULL,owner_id TEXT NOT NULL,
69
+ pending BOOLEAN NOT NULL DEFAULT FALSE,deleted BOOLEAN NOT NULL DEFAULT FALSE,
70
+ PRIMARY KEY(tenant_id,owner_id))`,
71
+ `CREATE TABLE IF NOT EXISTS ${t.intents} (tenant_id TEXT NOT NULL,owner_id TEXT NOT NULL,
72
+ connection_id TEXT NOT NULL,generation BIGINT NOT NULL,token TEXT NOT NULL,
73
+ reason TEXT NOT NULL CHECK(reason IN ('disconnect','account_deleted','grant_rejected','insufficient_scope')),
74
+ purged BOOLEAN NOT NULL,queue_id TEXT,acknowledged BOOLEAN NOT NULL DEFAULT FALSE,
75
+ PRIMARY KEY(tenant_id,owner_id,connection_id))`,
76
+ `CREATE TABLE IF NOT EXISTS ${t.queue} (id TEXT PRIMARY KEY,sealed TEXT,
77
+ state TEXT NOT NULL CHECK(state IN ('pending','confirmed','expired','unreadable')),
78
+ expires_at BIGINT NOT NULL,next_at BIGINT NOT NULL,attempts INTEGER NOT NULL DEFAULT 0,
79
+ lease_token TEXT,lease_until BIGINT,
80
+ CHECK((lease_token IS NULL)=(lease_until IS NULL)),
81
+ CHECK((state='pending')=(sealed IS NOT NULL)))`,
82
+ `CREATE INDEX IF NOT EXISTS "${prefix}_due" ON ${t.queue}(next_at,id) WHERE state='pending'`,
83
+ `CREATE TABLE IF NOT EXISTS ${t.grants} (tenant_id TEXT NOT NULL,owner_id TEXT NOT NULL,
84
+ connection_id TEXT NOT NULL,token TEXT NOT NULL,until BIGINT NOT NULL,
85
+ PRIMARY KEY(tenant_id,owner_id,connection_id))`,
86
+ `CREATE TABLE IF NOT EXISTS ${t.exchanges} (id TEXT PRIMARY KEY,tenant_id TEXT NOT NULL,owner_id TEXT NOT NULL,
87
+ connection_id TEXT NOT NULL,generation BIGINT NOT NULL,started_at BIGINT NOT NULL,ends_at BIGINT NOT NULL,
88
+ outcome TEXT NOT NULL CHECK(outcome IN ('open','installed','cleanup','unconfirmed','ended')),
89
+ queue_id TEXT,acknowledged BOOLEAN NOT NULL DEFAULT FALSE)`,
90
+ `CREATE INDEX IF NOT EXISTS "${prefix}_exchange_owner" ON ${t.exchanges}(tenant_id,owner_id,connection_id)`,
91
+ `CREATE INDEX IF NOT EXISTS "${prefix}_exchange_due" ON ${t.exchanges}(ends_at,id)
92
+ WHERE outcome='open' AND queue_id IS NULL`,
93
+ ];
94
+ }
95
+ /**
96
+ * Adapter-owned lifecycle recovery. No method logs or returns a queued credential.
97
+ *
98
+ * `disconnect`, `loseGrant` and `deleteOwner` commit the owner's fence, the connection's stop, a purge marker and the
99
+ * transfer of the grant's refresh token to the revoke-only queue in one transaction, before any provider call; the
100
+ * purge of sources and every result derived from them completes in a transaction of its own, which `recover`
101
+ * resumes after a failure. `revokeBatch` revokes queued tokens on its own schedule, independent of accounts, until
102
+ * each token's deadline, 23 hours and 59 minutes after it was queued. `withGrant` serializes refreshes across
103
+ * workers under a durable lease. The exchange methods record an authorization exchange before its request is sent
104
+ * and either install the grant it returns or queue it for revocation. Every check of `canConnect`, `permits` and
105
+ * `mayInstallExchange` belongs inside the owner fence of the host's transaction.
106
+ *
107
+ * @typeParam Prepared - The store's prepared value for one document.
108
+ */
109
+ export class SourceRevocations {
110
+ db;
111
+ options;
112
+ t;
113
+ store;
114
+ clock;
115
+ /**
116
+ * @param db - A PostgreSQL adapter with transactions, on the database of the store's tables.
117
+ * @param options - The store, keys, sealed-value binding and revoker, with an optional prefix, clock and sink.
118
+ * @throws {Error} `source_revocation_requires_postgres` for another kind of adapter or one without transactions,
119
+ * and `invalid_source_revocation_prefix` for a malformed prefix.
120
+ */
121
+ constructor(db, options) {
122
+ this.db = db;
123
+ this.options = options;
124
+ if (db.kind !== 'postgres' || !db.capabilities.has('transactions'))
125
+ throw new Error('source_revocation_requires_postgres');
126
+ this.t = tables(options.tablePrefix);
127
+ this.store = options.store;
128
+ this.clock = options.clock ?? Date.now;
129
+ }
130
+ now() {
131
+ const now = this.clock();
132
+ if (!integer(now) || now > 8_639_999_913_600_000)
133
+ throw new SourceSyncFault('invalid_response');
134
+ return now;
135
+ }
136
+ emit(state, n = 1) {
137
+ try {
138
+ this.options.events?.({ kind: 'source_revocation', state, n });
139
+ }
140
+ catch { /* No diagnostic can alter purge. */ }
141
+ }
142
+ async stored(work) {
143
+ try {
144
+ return await work();
145
+ }
146
+ catch (error) {
147
+ if (error instanceof SourceSyncFault)
148
+ throw error;
149
+ throw new SourceSyncFault('storage_unavailable');
150
+ }
151
+ }
152
+ grant(tx, scope) {
153
+ return new SealedTokenStore(this.options.values(tx, scope), this.options.keys, { namespace: sourceGrantNamespace(scope) });
154
+ }
155
+ context(id) { return JSON.stringify(['SourceRevocations', this.t.prefix, id]); }
156
+ /**
157
+ * Combine with fresh host account liveness in the sync store's active binding. False while a stop's purge is
158
+ * pending for the owner and, for good, once the owner was deleted; it does not authenticate an account.
159
+ */
160
+ async permits(tx, scope) {
161
+ const row = await tx.get(`SELECT pending,deleted FROM ${this.t.blocks}
162
+ WHERE tenant_id=$1 AND owner_id=$2`, ownerFields(scope));
163
+ return !row || (!row.pending && !row.deleted);
164
+ }
165
+ /**
166
+ * Check under the owner fence both at authorization start and grant installation. False when `permits` is, while a
167
+ * revocation of the owner's is pending or ended without confirmation and is not acknowledged, and while an exchange
168
+ * of the owner's is in flight or its returned grant awaits the same conditions.
169
+ */
170
+ async canConnect(tx, scope) {
171
+ if (!await this.permits(tx, scope))
172
+ return false;
173
+ return !await this.barrier(tx, scope, null);
174
+ }
175
+ /**
176
+ * Whether a stop's revocation or an exchange keeps the owner from a new grant, leaving out the exchange `except`.
177
+ * A revocation pending in the queue, or one that expired or could not be opened before the owner acknowledged the
178
+ * permission's removal, keeps it; so does an exchange in flight, one whose returned grant is in the queue under the
179
+ * same conditions, and one whose outcome is unconfirmed and not acknowledged.
180
+ */
181
+ async barrier(tx, scope, except) {
182
+ if (await tx.get(`SELECT 1 FROM ${this.t.intents} i LEFT JOIN ${this.t.queue} q ON q.id=i.queue_id
183
+ WHERE i.tenant_id=$1 AND i.owner_id=$2 AND i.queue_id IS NOT NULL
184
+ AND (q.state='pending' OR (COALESCE(q.state,'expired') IN ('expired','unreadable') AND i.acknowledged=FALSE)) LIMIT 1`, ownerFields(scope)))
185
+ return true;
186
+ return !!await tx.get(`SELECT 1 FROM ${this.t.exchanges} e LEFT JOIN ${this.t.queue} q ON q.id=e.queue_id
187
+ WHERE e.tenant_id=$1 AND e.owner_id=$2 AND ($3::text IS NULL OR e.id<>$3) AND (
188
+ (e.outcome='open' AND e.queue_id IS NULL)
189
+ OR (e.outcome IN ('open','cleanup') AND e.queue_id IS NOT NULL AND (q.state='pending'
190
+ OR (COALESCE(q.state,'expired') IN ('expired','unreadable') AND e.acknowledged=FALSE)))
191
+ OR (e.outcome='unconfirmed' AND e.acknowledged=FALSE)) LIMIT 1`, [...ownerFields(scope), except]);
192
+ }
193
+ /**
194
+ * The connection's notice for authenticated controls: whether its purge is pending, and the more pressing of the
195
+ * revocation of its last stopped grant and the cleanup of the grant its newest exchange returned.
196
+ */
197
+ async status(scope) {
198
+ return this.stored(async () => {
199
+ const row = await this.db.get(`SELECT i.purged,i.queue_id,q.state FROM ${this.t.intents} i LEFT JOIN ${this.t.queue} q ON q.id=i.queue_id
200
+ WHERE i.tenant_id=$1 AND i.owner_id=$2 AND i.connection_id=$3`, fields(scope));
201
+ const stopped = !row?.queue_id ? 'not_needed'
202
+ : row.state === 'pending' ? 'pending' : row.state === 'confirmed' ? 'confirmed' : 'permission_removal_required';
203
+ const returned = exchangeNotice(await this.db.get(`SELECT e.id,e.outcome,e.queue_id,q.state,
204
+ q.lease_token FROM ${this.t.exchanges} e LEFT JOIN ${this.t.queue} q ON q.id=e.queue_id
205
+ WHERE e.tenant_id=$1 AND e.owner_id=$2 AND e.connection_id=$3 ORDER BY e.started_at DESC,e.id DESC LIMIT 1`, fields(scope)));
206
+ return { localPending: row?.purged === false,
207
+ revocation: urgency[returned] > urgency[stopped] ? returned : stopped };
208
+ });
209
+ }
210
+ async block(tx, scope, deleted) {
211
+ await tx.run(`INSERT INTO ${this.t.blocks}(tenant_id,owner_id,pending,deleted) VALUES($1,$2,TRUE,$3)
212
+ ON CONFLICT(tenant_id,owner_id) DO UPDATE SET pending=TRUE,deleted=${this.t.blocks}.deleted OR excluded.deleted`, [...ownerFields(scope), deleted]);
213
+ }
214
+ async stage(tx, state, reason) {
215
+ const scope = state.scope;
216
+ await this.block(tx, scope, reason === 'account_deleted');
217
+ const previous = await tx.get(`SELECT * FROM ${this.t.intents}
218
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 FOR UPDATE`, fields(scope));
219
+ let queueId = previous?.queue_id ?? null;
220
+ let acknowledged = previous?.acknowledged ?? false;
221
+ const bytes = this.options.values(tx, scope);
222
+ const raw = await bytes.get(SOURCE_GRANT_KEY);
223
+ if (raw !== null) {
224
+ if (previous?.queue_id && !await this.canReplaceQueue(tx, previous))
225
+ throw new SourceSyncFault('grant_rejected');
226
+ const id = randomUUID();
227
+ const now = this.now();
228
+ let sealed = null;
229
+ // The bytes read succeeded. Decode failure cannot delay deletion of an unusable grant.
230
+ try {
231
+ const grant = await new SealedTokenStore({ get: async () => raw, set: async () => { }, delete: async () => { } }, this.options.keys, { namespace: sourceGrantNamespace(scope) }).load(SOURCE_GRANT_KEY);
232
+ const token = grant?.refreshToken;
233
+ if (typeof token === 'string' && token.length > 0 && token.length <= 16_384) {
234
+ sealed = sealSecret(token, this.options.keys.current.key, this.options.keys.current.id, this.context(id));
235
+ }
236
+ }
237
+ catch {
238
+ sealed = null;
239
+ }
240
+ await tx.run(`INSERT INTO ${this.t.queue}(id,sealed,state,expires_at,next_at)
241
+ VALUES($1,$2,$3,$4,$5)`, [id, sealed, sealed === null ? 'unreadable' : 'pending', now + 86_340_000, now]);
242
+ queueId = id;
243
+ acknowledged = false;
244
+ }
245
+ // Never invoke a flow's forget on the reusable live key after this commit.
246
+ await this.grant(tx, scope).clear(SOURCE_GRANT_KEY);
247
+ await tx.run(`DELETE FROM ${this.t.grants} WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, fields(scope));
248
+ await tx.run(`INSERT INTO ${this.t.intents}(tenant_id,owner_id,connection_id,generation,token,reason,purged,queue_id,acknowledged)
249
+ VALUES($1,$2,$3,$4,$5,$6,FALSE,$7,$8) ON CONFLICT(tenant_id,owner_id,connection_id) DO UPDATE SET
250
+ generation=excluded.generation,token=excluded.token,reason=excluded.reason,purged=FALSE,
251
+ queue_id=excluded.queue_id,acknowledged=excluded.acknowledged`, [...fields(scope), state.generation, randomUUID(), reason, queueId, acknowledged]);
252
+ }
253
+ async canReplaceQueue(tx, intent) {
254
+ const queue = await tx.get(`SELECT state FROM ${this.t.queue} WHERE id=$1`, [intent.queue_id]);
255
+ return queue?.state === 'confirmed' || (queue?.state !== 'pending' && intent.acknowledged);
256
+ }
257
+ /**
258
+ * Disconnects the connection at the generation the authenticated request saw: fences it, queues its grant for
259
+ * revocation and clears the active grant in one transaction, then purges its sources and every result derived
260
+ * from them. No provider call happens here.
261
+ *
262
+ * @returns The connection's notice, or null when the generation has moved on, which changes nothing.
263
+ * @throws {SourceSyncFault} `storage_unavailable` when a step fails: the committed stop stays, and `recover`
264
+ * completes its purge.
265
+ */
266
+ async disconnect(scope, generation) {
267
+ return this.stored(async () => {
268
+ if (!await this.store.stageStop(scope, { type: 'disconnect' }, (tx, state) => this.stage(tx, state, 'disconnect'), generation))
269
+ return null;
270
+ this.emit('fenced');
271
+ await this.recoverOwner(scope);
272
+ return this.status(scope);
273
+ });
274
+ }
275
+ /**
276
+ * The coordinator's handoff of a refused grant: with the run's exact current handle, the connection moves to
277
+ * `needs_reconnecting` and its grant is queued for revocation in one transaction, then purged as `disconnect` does.
278
+ *
279
+ * @returns Whether the handle was current and the grant was fenced; a stale handle changes nothing.
280
+ */
281
+ async loseGrant(handle, reason) {
282
+ return this.stored(async () => {
283
+ const scope = handle.lease.scope;
284
+ if (!await this.store.stageStop(scope, { type: 'grant_lost', reason }, (tx, state) => this.stage(tx, state, reason), handle))
285
+ return false;
286
+ this.emit('fenced');
287
+ await this.recoverOwner(scope);
288
+ return true;
289
+ });
290
+ }
291
+ /**
292
+ * Account deletion: blocks the owner for good and stops every connection of the owner, queueing each grant for
293
+ * revocation, in one transaction, also when the owner has no connection; then purges them. Call it before the host
294
+ * removes any source, credential or account row. Queued revocations outlive the account.
295
+ */
296
+ async deleteOwner(scope) {
297
+ await this.stored(async () => {
298
+ await this.store.stageOwnerStop(scope, async (tx, states) => {
299
+ await this.block(tx, scope, true);
300
+ for (const state of states)
301
+ await this.stage(tx, state, 'account_deleted');
302
+ if (!states.length)
303
+ await tx.run(`UPDATE ${this.t.blocks} SET pending=FALSE
304
+ WHERE tenant_id=$1 AND owner_id=$2`, ownerFields(scope));
305
+ });
306
+ this.emit('fenced');
307
+ await this.recoverOwner(scope);
308
+ });
309
+ }
310
+ /** Recover every pending intent for the owner. No grant or account row is required. */
311
+ async recover(scope) { await this.recoverOwner(scope); }
312
+ async recoverOwner(scope) {
313
+ await this.stored(async () => {
314
+ const intents = await this.db.all(`SELECT * FROM ${this.t.intents}
315
+ WHERE tenant_id=$1 AND owner_id=$2 AND purged=FALSE ORDER BY connection_id`, ownerFields(scope));
316
+ for (const intent of intents) {
317
+ const source = { tenantId: intent.tenant_id, ownerId: intent.owner_id, connectionId: intent.connection_id };
318
+ await this.store.recoverControl(source);
319
+ const completed = await this.store.completeStop(source, Number(intent.generation), async (tx) => {
320
+ const changed = await tx.run(`UPDATE ${this.t.intents} SET purged=TRUE
321
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND token=$4 AND purged=FALSE`, [...fields(source), intent.token]);
322
+ if (changed.changes !== 1)
323
+ return;
324
+ await tx.run(`UPDATE ${this.t.blocks} SET pending=EXISTS(SELECT 1 FROM ${this.t.intents}
325
+ WHERE tenant_id=$1 AND owner_id=$2 AND purged=FALSE) WHERE tenant_id=$1 AND owner_id=$2`, ownerFields(source));
326
+ });
327
+ if (!completed)
328
+ throw new SourceSyncFault('storage_unavailable');
329
+ this.emit('purged');
330
+ await this.store.recoverControl(source);
331
+ }
332
+ });
333
+ }
334
+ /**
335
+ * Owner-authenticated attestation; never describe it as provider confirmation. The account owner confirms having
336
+ * removed the permission at the provider for the connection's revocation that expired or could not be opened, and
337
+ * for its exchanges whose outcome is unconfirmed or whose returned grant's revocation ended the same way; a new
338
+ * grant may then begin. A pending or confirmed revocation is not acknowledged.
339
+ *
340
+ * @returns Whether anything was acknowledged.
341
+ */
342
+ async acknowledgeRemoval(scope) {
343
+ return this.stored(async () => {
344
+ let acknowledged = false;
345
+ await this.store.fence.blockOwner(scope, async (tx) => {
346
+ const intent = await tx.get(`SELECT * FROM ${this.t.intents}
347
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 FOR UPDATE`, fields(scope));
348
+ if (intent?.purged && intent.queue_id) {
349
+ const row = await tx.get(`SELECT * FROM ${this.t.queue} WHERE id=$1`, [intent.queue_id]);
350
+ if (row?.state !== 'pending' && row?.state !== 'confirmed') {
351
+ await tx.run(`UPDATE ${this.t.intents} SET acknowledged=TRUE
352
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3`, fields(scope));
353
+ acknowledged = true;
354
+ }
355
+ }
356
+ await this.lockOwner(tx, scope);
357
+ const exchanges = await tx.run(`UPDATE ${this.t.exchanges} e SET acknowledged=TRUE
358
+ WHERE e.tenant_id=$1 AND e.owner_id=$2 AND e.connection_id=$3 AND e.acknowledged=FALSE
359
+ AND (e.outcome='unconfirmed' OR (e.outcome IN ('open','cleanup') AND e.queue_id IS NOT NULL
360
+ AND NOT EXISTS(SELECT 1 FROM ${this.t.queue} q WHERE q.id=e.queue_id AND q.state IN ('pending','confirmed'))))`, fields(scope));
361
+ if ((exchanges.changes ?? 0) > 0)
362
+ acknowledged = true;
363
+ });
364
+ await this.store.recoverControl(scope);
365
+ return acknowledged;
366
+ });
367
+ }
368
+ /**
369
+ * Runs one server-side grant operation, such as a refresh, under a durable 30-second grant lease, without holding a
370
+ * transaction over the network. `work` builds the provider flow on the sealed store it is given and must honor the
371
+ * signal, which aborts after 15 seconds or with `parent`. Every load and save through that store, and the final
372
+ * return, recheck the sync handle and the grant lease in a short transaction, so a disconnect or an expired lease
373
+ * makes a held token's save or return fail. The lease is never renewed; after it expires another worker may take
374
+ * a new one. Access tokens stay in memory.
375
+ *
376
+ * @returns What `work` answered.
377
+ * @throws {SourceSyncFault} `rate_limited` while another worker holds the lease, with its expiry as `retryAt`;
378
+ * `grant_rejected` for a stale handle, a lost lease or a disconnect; `cancelled` when `parent` aborted first;
379
+ * `storage_unavailable` for any other failure. A provider refusal (`OAuthGrantRefused`) passes through.
380
+ */
381
+ async withGrant(handle, work, parent) {
382
+ if (parent?.aborted)
383
+ throw new SourceSyncFault('cancelled');
384
+ const scope = handle.lease.scope;
385
+ const token = randomUUID();
386
+ const acquired = await this.stored(() => this.store.withCurrentHandle(handle, async (tx) => {
387
+ const row = await tx.get(`SELECT token,until FROM ${this.t.grants}
388
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 FOR UPDATE`, fields(scope));
389
+ const now = this.now();
390
+ if (row && Number(row.until) > now)
391
+ throw new SourceSyncFault('rate_limited', Number(row.until));
392
+ await tx.run(`INSERT INTO ${this.t.grants}(tenant_id,owner_id,connection_id,token,until)
393
+ VALUES($1,$2,$3,$4,$5) ON CONFLICT(tenant_id,owner_id,connection_id)
394
+ DO UPDATE SET token=excluded.token,until=excluded.until`, [...fields(scope), token, now + 30_000]);
395
+ return true;
396
+ }));
397
+ if (acquired !== true)
398
+ throw new SourceSyncFault('grant_rejected');
399
+ const controller = new AbortController();
400
+ const abort = () => controller.abort();
401
+ parent?.addEventListener('abort', abort, { once: true });
402
+ if (parent?.aborted)
403
+ abort();
404
+ const timer = setTimeout(abort, 15_000);
405
+ const guarded = async (action) => {
406
+ if (controller.signal.aborted)
407
+ throw new SourceSyncFault(parent?.aborted ? 'cancelled' : 'network');
408
+ const result = await this.stored(() => this.store.withCurrentHandle(handle, async (tx) => {
409
+ const lease = await tx.get(`SELECT token,until FROM ${this.t.grants}
410
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 FOR UPDATE`, fields(scope));
411
+ if (!lease || lease.token !== token || Number(lease.until) <= this.now())
412
+ throw new SourceSyncFault('grant_rejected');
413
+ const value = await action(this.options.values(tx, scope));
414
+ if (controller.signal.aborted || Number(lease.until) <= this.now())
415
+ throw new SourceSyncFault('grant_rejected');
416
+ return { value };
417
+ }));
418
+ if (!result)
419
+ throw new SourceSyncFault('grant_rejected');
420
+ return result.value;
421
+ };
422
+ const values = {
423
+ get: (key) => guarded((store) => store.get(key)),
424
+ set: (key, sealed) => guarded((store) => store.set(key, sealed)),
425
+ delete: (key) => guarded((store) => store.delete(key)),
426
+ };
427
+ try {
428
+ const result = await work(new SealedTokenStore(values, this.options.keys, { namespace: sourceGrantNamespace(scope) }), controller.signal);
429
+ await guarded(async () => true);
430
+ return result;
431
+ }
432
+ catch (error) {
433
+ if (error instanceof SourceSyncFault || error instanceof OAuthGrantRefused)
434
+ throw error;
435
+ throw new SourceSyncFault('storage_unavailable');
436
+ }
437
+ finally {
438
+ clearTimeout(timer);
439
+ parent?.removeEventListener('abort', abort);
440
+ await this.stored(async () => {
441
+ await this.db.run(`DELETE FROM ${this.t.grants}
442
+ WHERE tenant_id=$1 AND owner_id=$2 AND connection_id=$3 AND token=$4`, [...fields(scope), token]);
443
+ });
444
+ }
445
+ }
446
+ async expireQueue() {
447
+ const now = this.now();
448
+ const changed = await this.db.run(`UPDATE ${this.t.queue} SET sealed=NULL,state='expired',lease_token=NULL,lease_until=NULL
449
+ WHERE id IN (SELECT id FROM ${this.t.queue} WHERE state='pending' AND expires_at<=$1 ORDER BY expires_at,id LIMIT 1000)
450
+ AND state='pending' AND expires_at<=$1`, [now]);
451
+ return changed.changes ?? 0;
452
+ }
453
+ async claimRevoke() {
454
+ return this.db.transaction(async (tx) => {
455
+ const now = this.now();
456
+ const row = await tx.get(`SELECT * FROM ${this.t.queue} WHERE state='pending'
457
+ AND next_at<=$1 AND expires_at>$1 AND (lease_until IS NULL OR lease_until<=$1)
458
+ ORDER BY next_at,id LIMIT 1 FOR UPDATE SKIP LOCKED`, [now]);
459
+ if (!row)
460
+ return null;
461
+ const token = randomUUID();
462
+ const until = Math.min(now + 30_000, Number(row.expires_at));
463
+ await tx.run(`UPDATE ${this.t.queue} SET lease_token=$2,lease_until=$3,attempts=attempts+1 WHERE id=$1`, [row.id, token, until]);
464
+ return { ...row, lease_token: token, lease_until: until, attempts: row.attempts + 1 };
465
+ });
466
+ }
467
+ async settleRevoke(row, state) {
468
+ const now = this.now();
469
+ const delay = Math.min(900_000, 60_000 * 2 ** Math.min(4, row.attempts - 1));
470
+ const next = Math.min(Number(row.expires_at), now + delay);
471
+ const changed = state === 'pending'
472
+ ? await this.db.run(`UPDATE ${this.t.queue} SET next_at=$3,lease_token=NULL,lease_until=NULL
473
+ WHERE id=$1 AND lease_token=$2 AND state='pending' AND lease_until>$4 AND expires_at>$4`, [row.id, row.lease_token, next, now])
474
+ : await this.db.run(`UPDATE ${this.t.queue} SET sealed=NULL,state=$3,lease_token=NULL,lease_until=NULL
475
+ WHERE id=$1 AND lease_token=$2 AND state='pending' AND lease_until>$4 AND expires_at>$4`, [row.id, row.lease_token, state, now]);
476
+ return changed.changes === 1;
477
+ }
478
+ /**
479
+ * Schedule independently of accounts, source scopes and provider polling, at least every minute. Erases queued
480
+ * tokens whose deadline has passed and records exchanges whose deadline passed with no outcome as unconfirmed, then
481
+ * claims up to `limit` due tokens, one worker per token, and revokes each outside any transaction with the
482
+ * provider flow's `revoke`, 15 seconds at most. A success confirms it; a failure retries after 1, 2, 4, 8, then 15
483
+ * minutes, never past the deadline; a token that does not open is erased. A worker whose claim expired cannot
484
+ * settle a token another worker claimed since.
485
+ *
486
+ * @param limit - At most this many tokens, an integer from 1 to 100; 20 unless given.
487
+ * @param parent - Stops the batch: no request starts after it aborts.
488
+ * @throws {SourceSyncFault} `invalid_response` for another limit and `storage_unavailable` when the store fails.
489
+ */
490
+ async revokeBatch(limit = 20, parent) {
491
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
492
+ throw new SourceSyncFault('invalid_response');
493
+ const out = { attempted: 0, confirmed: 0, deferred: 0, expired: 0, unreadable: 0 };
494
+ if (parent?.aborted)
495
+ return out;
496
+ return this.stored(async () => {
497
+ out.expired = await this.expireQueue() + await this.lapseExchanges();
498
+ if (out.expired)
499
+ this.emit('expired', out.expired);
500
+ for (let n = 0; n < limit && !parent?.aborted; n++) {
501
+ const row = await this.claimRevoke();
502
+ if (!row)
503
+ break;
504
+ let bytes = null;
505
+ let token = '';
506
+ try {
507
+ if (!row.sealed || Number(row.expires_at) <= this.now())
508
+ throw new Error('expired_revocation');
509
+ bytes = openSecret(row.sealed, [this.options.keys.current, ...(this.options.keys.previous ?? [])], this.context(row.id));
510
+ token = Buffer.from(bytes).toString('utf8');
511
+ if (!token.length || token.length > 16_384)
512
+ throw new Error('invalid_revocation_material');
513
+ }
514
+ catch {
515
+ if (await this.settleRevoke(row, 'unreadable')) {
516
+ out.unreadable++;
517
+ this.emit('unreadable');
518
+ }
519
+ bytes?.fill(0);
520
+ token = '';
521
+ continue;
522
+ }
523
+ const controller = new AbortController();
524
+ const abort = () => controller.abort();
525
+ parent?.addEventListener('abort', abort, { once: true });
526
+ if (parent?.aborted)
527
+ abort();
528
+ const remaining = Math.min(15_000, Number(row.expires_at) - this.now(), Number(row.lease_until) - this.now());
529
+ const timer = setTimeout(abort, Math.max(1, remaining));
530
+ let completed = false;
531
+ try {
532
+ if (remaining > 0 && !controller.signal.aborted) {
533
+ out.attempted++;
534
+ await this.options.revoker(controller.signal).revoke(token);
535
+ completed = !controller.signal.aborted && Number(row.expires_at) > this.now();
536
+ }
537
+ }
538
+ catch {
539
+ completed = false;
540
+ }
541
+ finally {
542
+ clearTimeout(timer);
543
+ parent?.removeEventListener('abort', abort);
544
+ bytes?.fill(0);
545
+ token = '';
546
+ }
547
+ if (await this.settleRevoke(row, completed ? 'confirmed' : 'pending')) {
548
+ if (completed) {
549
+ out.confirmed++;
550
+ this.emit('confirmed');
551
+ }
552
+ else {
553
+ out.deferred++;
554
+ this.emit('deferred');
555
+ }
556
+ }
557
+ }
558
+ const expired = await this.expireQueue();
559
+ out.expired += expired;
560
+ if (expired)
561
+ this.emit('expired', expired);
562
+ return out;
563
+ });
564
+ }
565
+ /**
566
+ * Remove unreferenced terminal diagnostics, never usable credentials or restore markers: a day after their end,
567
+ * exchanges that installed, ended or were acknowledged, or whose returned grant's revocation was confirmed; then, a
568
+ * day after their deadline, terminal queue rows no stop or exchange refers to; at most 1,000 of each a call. A
569
+ * deleted owner's exchanges outlive the account only to settle their attempt, and the owner can never acknowledge:
570
+ * once the deletion is recorded, an exchange of the owner's that is not in flight and holds no pending revocation
571
+ * also goes a day after its end, including one recorded unconfirmed or whose returned grant's revocation expired or
572
+ * could not be opened. The owner's block marker stays.
573
+ *
574
+ * @returns How many rows were removed.
575
+ */
576
+ async compact() {
577
+ return this.stored(async () => {
578
+ const cutoff = this.now() - 86_400_000;
579
+ // A deleted owner's exchange goes once it is not in flight and holds no pending revocation.
580
+ const exchanges = await this.db.run(`DELETE FROM ${this.t.exchanges} WHERE id IN (
581
+ SELECT e.id FROM ${this.t.exchanges} e LEFT JOIN ${this.t.queue} q ON q.id=e.queue_id
582
+ WHERE e.ends_at<$1 AND (e.outcome IN ('installed','ended') OR e.acknowledged OR q.state='confirmed'
583
+ OR ((e.outcome<>'open' OR e.queue_id IS NOT NULL) AND q.state IS DISTINCT FROM 'pending'
584
+ AND EXISTS(SELECT 1 FROM ${this.t.blocks} b
585
+ WHERE b.tenant_id=e.tenant_id AND b.owner_id=e.owner_id AND b.deleted)))
586
+ ORDER BY e.ends_at,e.id LIMIT 1000)`, [cutoff]);
587
+ const changed = await this.db.run(`DELETE FROM ${this.t.queue} WHERE id IN (
588
+ SELECT q.id FROM ${this.t.queue} q WHERE q.state<>'pending' AND q.expires_at<$1
589
+ AND NOT EXISTS(SELECT 1 FROM ${this.t.intents} i WHERE i.queue_id=q.id)
590
+ AND NOT EXISTS(SELECT 1 FROM ${this.t.exchanges} e WHERE e.queue_id=q.id)
591
+ ORDER BY q.expires_at,q.id LIMIT 1000) AND state<>'pending'`, [cutoff]);
592
+ return (exchanges.changes ?? 0) + (changed.changes ?? 0);
593
+ });
594
+ }
595
+ /** Records exchanges whose deadline passed with no grant returned as unconfirmed: their outcome is unknown. */
596
+ async lapseExchanges() {
597
+ const now = this.now();
598
+ const changed = await this.db.run(`UPDATE ${this.t.exchanges} SET outcome='unconfirmed' WHERE id IN (
599
+ SELECT id FROM ${this.t.exchanges} WHERE outcome='open' AND queue_id IS NULL AND ends_at<=$1
600
+ ORDER BY ends_at,id LIMIT 1000) AND outcome='open' AND queue_id IS NULL AND ends_at<=$1`, [now]);
601
+ return changed.changes ?? 0;
602
+ }
603
+ /** Takes the owner's lifecycle row lock, creating the row unblocked when missing; exchange changes take it first. */
604
+ async lockOwner(tx, scope) {
605
+ await tx.run(`INSERT INTO ${this.t.blocks}(tenant_id,owner_id) VALUES($1,$2)
606
+ ON CONFLICT(tenant_id,owner_id) DO NOTHING`, ownerFields(scope));
607
+ await tx.get(`SELECT pending FROM ${this.t.blocks} WHERE tenant_id=$1 AND owner_id=$2 FOR UPDATE`, ownerFields(scope));
608
+ }
609
+ checkExchange(exchange) {
610
+ const scope = exchange?.scope;
611
+ if (!scope || !key(exchange.id) || !key(scope.tenantId) || !key(scope.ownerId) || !key(scope.connectionId)
612
+ || !integer(exchange.generation) || !integer(exchange.startedAt) || !integer(exchange.endsAt)) {
613
+ throw new SourceSyncFault('invalid_response');
614
+ }
615
+ }
616
+ /** The exchange's row under its lock, or null when no row matches every field of the exchange. */
617
+ async exchangeRow(tx, exchange) {
618
+ const row = await tx.get(`SELECT * FROM ${this.t.exchanges} WHERE id=$1 FOR UPDATE`, [exchange.id]);
619
+ return row && row.tenant_id === exchange.scope.tenantId && row.owner_id === exchange.scope.ownerId
620
+ && row.connection_id === exchange.scope.connectionId && Number(row.generation) === exchange.generation
621
+ && Number(row.started_at) === exchange.startedAt && Number(row.ends_at) === exchange.endsAt ? row : null;
622
+ }
623
+ /** What a settled exchange did with its grant, from its row and its queued token's state. */
624
+ async exchangeOutcome(tx, row) {
625
+ if (row.outcome === 'installed')
626
+ return { kind: 'installed' };
627
+ if (!row.queue_id)
628
+ return { kind: 'permission_removal_required' };
629
+ const queue = await tx.get(`SELECT state FROM ${this.t.queue} WHERE id=$1`, [row.queue_id]);
630
+ return queue?.state === 'pending' || queue?.state === 'confirmed' ? { kind: 'cleanup_pending' }
631
+ : { kind: 'permission_removal_required' };
632
+ }
633
+ /**
634
+ * Records an authorization exchange before its request is sent. Call it inside the host transaction that consumes
635
+ * the authenticated start, under the owner fence: it answers null, and the caller rolls that whole transaction
636
+ * back, unless the connection is `connecting` at `generation` and `canConnect` holds, so one exchange at a time is
637
+ * in flight for the owner and none while a revocation of the owner's is pending or unacknowledged. The exchange's
638
+ * marker holds opaque ids and times only, and the exchange ends 60 seconds after it begins: a grant returned later
639
+ * is not installed. Send the request only after the transaction commits.
640
+ *
641
+ * @param exchangeId - The host's opaque id for this attempt, 1 to 512 characters, never reused.
642
+ * @returns The exchange, or null.
643
+ * @throws {SourceSyncFault} `invalid_response` for a malformed scope, generation or id.
644
+ */
645
+ async beginExchange(tx, scope, generation, exchangeId) {
646
+ if (!key(exchangeId) || !integer(generation) || !key(scope?.tenantId) || !key(scope?.ownerId)
647
+ || !key(scope?.connectionId))
648
+ throw new SourceSyncFault('invalid_response');
649
+ return this.stored(async () => {
650
+ const state = await this.store.lockState(tx, scope);
651
+ if (!state || state.status !== 'connecting' || state.generation !== generation)
652
+ return null;
653
+ await this.lockOwner(tx, scope);
654
+ if (!await this.canConnect(tx, scope))
655
+ return null;
656
+ const startedAt = this.now();
657
+ const endsAt = startedAt + EXCHANGE_MS;
658
+ const inserted = await tx.run(`INSERT INTO ${this.t.exchanges}(id,tenant_id,owner_id,connection_id,generation,
659
+ started_at,ends_at,outcome) VALUES($1,$2,$3,$4,$5,$6,$7,'open') ON CONFLICT(id) DO NOTHING`, [exchangeId, ...fields(scope), generation, startedAt, endsAt]);
660
+ if (inserted.changes !== 1)
661
+ return null;
662
+ const exchange = { id: exchangeId, generation, startedAt, endsAt,
663
+ scope: { tenantId: scope.tenantId, ownerId: scope.ownerId, connectionId: scope.connectionId } };
664
+ return exchange;
665
+ });
666
+ }
667
+ /**
668
+ * Whether this exact exchange may install its grant now, read under the owner fence in the installing transaction:
669
+ * its marker is open and holds its sealed grant for installation, its deadline has not passed, the connection is
670
+ * still `connecting` at its generation with no grant installed, `permits` holds, and no other revocation or
671
+ * exchange keeps the owner from a new grant. It recognizes only the matching live attempt, never an older or newer
672
+ * one.
673
+ */
674
+ async mayInstallExchange(tx, exchange) {
675
+ this.checkExchange(exchange);
676
+ return this.stored(async () => {
677
+ const scope = exchange.scope;
678
+ const state = await this.store.lockState(tx, scope);
679
+ await this.lockOwner(tx, scope);
680
+ const row = await this.exchangeRow(tx, exchange);
681
+ if (!row || row.outcome !== 'open' || !row.queue_id || this.now() >= exchange.endsAt
682
+ || !state || state.status !== 'connecting' || state.generation !== exchange.generation)
683
+ return false;
684
+ const held = await tx.get(`SELECT * FROM ${this.t.queue} WHERE id=$1 FOR UPDATE`, [row.queue_id]);
685
+ if (!held || held.state !== 'pending' || held.lease_token !== exchange.id)
686
+ return false;
687
+ if (!await this.permits(tx, scope) || await this.barrier(tx, scope, exchange.id))
688
+ return false;
689
+ return await this.options.values(tx, scope).get(SOURCE_GRANT_KEY) === null;
690
+ });
691
+ }
692
+ /**
693
+ * Settles the grant an exchange returned. First the grant's refresh token is sealed into the revoke-only queue,
694
+ * held there until the exchange's end, so a crash cannot lose it. Then, when `mayInstallExchange` holds, `install`
695
+ * runs in the owner's transaction: it saves the grant under `sourceGrantNamespace` and `SOURCE_GRANT_KEY`, records
696
+ * the connection's ready state and finishes the host's start, and the same commit removes the held token. If the
697
+ * exchange may not install, `install` answers anything but true, throws or finishes after the deadline, the whole
698
+ * installation rolls back and the held token becomes due for revocation at once; if this process stops first, it
699
+ * becomes due at the exchange's end. A stale, cancelled or deleted exchange never runs `install`. An exchange that
700
+ * already ended or was recorded unconfirmed takes no token: its notice stands. A grant with no usable refresh token
701
+ * records the exchange unconfirmed.
702
+ *
703
+ * @returns What became of the grant.
704
+ * @throws {SourceSyncFault} `invalid_response` for a malformed exchange, `storage_unavailable` when the store fails.
705
+ */
706
+ async settleExchange(exchange, grant, install) {
707
+ this.checkExchange(exchange);
708
+ if (typeof install !== 'function')
709
+ throw new SourceSyncFault('invalid_response');
710
+ const refresh = grant?.refreshToken;
711
+ const token = typeof refresh === 'string' && refresh.length > 0 && refresh.length <= 16_384 ? refresh : null;
712
+ const removal = { kind: 'permission_removal_required' };
713
+ const settled = await this.stored(() => this.db.transaction(async (tx) => {
714
+ await this.lockOwner(tx, exchange.scope);
715
+ const row = await this.exchangeRow(tx, exchange);
716
+ if (!row)
717
+ return removal;
718
+ if (row.outcome !== 'open')
719
+ return this.exchangeOutcome(tx, row);
720
+ if (row.queue_id)
721
+ return null;
722
+ if (token === null) {
723
+ await tx.run(`UPDATE ${this.t.exchanges} SET outcome='unconfirmed' WHERE id=$1`, [row.id]);
724
+ return removal;
725
+ }
726
+ const now = this.now();
727
+ const id = randomUUID();
728
+ const until = Number(row.ends_at);
729
+ const hold = until > now;
730
+ const sealed = sealSecret(token, this.options.keys.current.key, this.options.keys.current.id, this.context(id));
731
+ await tx.run(`INSERT INTO ${this.t.queue}(id,sealed,state,expires_at,next_at,lease_token,lease_until)
732
+ VALUES($1,$2,'pending',$3,$4,$5,$6)`, [id, sealed, now + 86_340_000, hold ? until : now,
733
+ hold ? row.id : null, hold ? until : null]);
734
+ await tx.run(`UPDATE ${this.t.exchanges} SET queue_id=$2,outcome=$3 WHERE id=$1`, [row.id, id, hold ? 'open' : 'cleanup']);
735
+ return hold ? null : { kind: 'cleanup_pending' };
736
+ }));
737
+ if (settled)
738
+ return settled;
739
+ try {
740
+ const installed = await this.db.transaction(async (tx) => {
741
+ if (!await this.mayInstallExchange(tx, exchange))
742
+ return false;
743
+ if (await install(tx, grant) !== true || this.now() >= exchange.endsAt)
744
+ throw new ExchangeRefused();
745
+ const row = await this.exchangeRow(tx, exchange);
746
+ if (!row?.queue_id)
747
+ throw new ExchangeRefused();
748
+ await tx.run(`DELETE FROM ${this.t.queue} WHERE id=$1 AND lease_token=$2`, [row.queue_id, row.id]);
749
+ await tx.run(`UPDATE ${this.t.exchanges} SET outcome='installed',queue_id=NULL WHERE id=$1`, [row.id]);
750
+ return true;
751
+ });
752
+ if (installed)
753
+ return { kind: 'installed' };
754
+ }
755
+ catch { /* A refused or failed installation rolled back whole; its held grant goes to revocation below. */ }
756
+ return this.stored(() => this.db.transaction(async (tx) => {
757
+ await this.lockOwner(tx, exchange.scope);
758
+ const row = await this.exchangeRow(tx, exchange);
759
+ if (!row)
760
+ return removal;
761
+ if (row.outcome === 'open' && row.queue_id) {
762
+ await tx.run(`UPDATE ${this.t.queue} SET next_at=$3,lease_token=NULL,lease_until=NULL
763
+ WHERE id=$1 AND state='pending' AND lease_token=$2`, [row.queue_id, row.id, this.now()]);
764
+ await tx.run(`UPDATE ${this.t.exchanges} SET outcome='cleanup' WHERE id=$1`, [row.id]);
765
+ row.outcome = 'cleanup';
766
+ }
767
+ return this.exchangeOutcome(tx, row);
768
+ }));
769
+ }
770
+ /**
771
+ * Records how an exchange ended when it returned no grant to settle: `not_dispatched` when its request never left
772
+ * the process, `revoked` when the provider flow revoked a grant it refused and the provider confirmed it, and
773
+ * `unconfirmed` for anything else after the request could have left, such as a timeout, an aborted connection, an
774
+ * unreadable answer or a revocation that was not confirmed. Only an open exchange holding no returned grant takes
775
+ * an ending, and a recorded outcome is never weakened. An unconfirmed exchange keeps new grants blocked until the
776
+ * account owner acknowledges the permission's removal.
777
+ *
778
+ * @throws {SourceSyncFault} `invalid_response` for a malformed exchange or another outcome.
779
+ */
780
+ async endExchange(exchange, outcome) {
781
+ this.checkExchange(exchange);
782
+ if (outcome !== 'not_dispatched' && outcome !== 'revoked' && outcome !== 'unconfirmed') {
783
+ throw new SourceSyncFault('invalid_response');
784
+ }
785
+ await this.stored(() => this.db.transaction(async (tx) => {
786
+ await this.lockOwner(tx, exchange.scope);
787
+ const row = await this.exchangeRow(tx, exchange);
788
+ if (!row || row.outcome !== 'open' || row.queue_id !== null)
789
+ return;
790
+ await tx.run(`UPDATE ${this.t.exchanges} SET outcome=$2 WHERE id=$1`, [row.id, outcome === 'unconfirmed' ? 'unconfirmed' : 'ended']);
791
+ }));
792
+ }
793
+ }
794
+ //# sourceMappingURL=SourceRevocations.js.map