@framers/agentos-ext-account-sources 0.6.0 → 0.7.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/README.md +75 -9
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/sync/AccountSourceSync.d.ts +4 -3
- package/dist/sync/AccountSourceSync.d.ts.map +1 -1
- package/dist/sync/AccountSourceSync.js +32 -10
- package/dist/sync/AccountSourceSync.js.map +1 -1
- package/dist/sync/SourceRevocations.d.ts +311 -0
- package/dist/sync/SourceRevocations.d.ts.map +1 -0
- package/dist/sync/SourceRevocations.js +786 -0
- package/dist/sync/SourceRevocations.js.map +1 -0
- package/dist/sync/SqlAccountSourceSyncStore.d.ts +60 -3
- package/dist/sync/SqlAccountSourceSyncStore.d.ts.map +1 -1
- package/dist/sync/SqlAccountSourceSyncStore.js +139 -5
- package/dist/sync/SqlAccountSourceSyncStore.js.map +1 -1
- package/dist/sync/types.d.ts +4 -1
- package/dist/sync/types.d.ts.map +1 -1
- package/manifest.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,786 @@
|
|
|
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
|
|
567
|
+
* deadline, exchanges that installed, ended, were acknowledged or whose returned grant's revocation was confirmed,
|
|
568
|
+
* then terminal queue rows no stop or exchange refers to, at most 1,000 of each a call.
|
|
569
|
+
*
|
|
570
|
+
* @returns How many rows were removed.
|
|
571
|
+
*/
|
|
572
|
+
async compact() {
|
|
573
|
+
return this.stored(async () => {
|
|
574
|
+
const cutoff = this.now() - 86_400_000;
|
|
575
|
+
const exchanges = await this.db.run(`DELETE FROM ${this.t.exchanges} WHERE id IN (
|
|
576
|
+
SELECT e.id FROM ${this.t.exchanges} e LEFT JOIN ${this.t.queue} q ON q.id=e.queue_id
|
|
577
|
+
WHERE e.ends_at<$1 AND (e.outcome IN ('installed','ended') OR e.acknowledged OR q.state='confirmed')
|
|
578
|
+
ORDER BY e.ends_at,e.id LIMIT 1000)`, [cutoff]);
|
|
579
|
+
const changed = await this.db.run(`DELETE FROM ${this.t.queue} WHERE id IN (
|
|
580
|
+
SELECT q.id FROM ${this.t.queue} q WHERE q.state<>'pending' AND q.expires_at<$1
|
|
581
|
+
AND NOT EXISTS(SELECT 1 FROM ${this.t.intents} i WHERE i.queue_id=q.id)
|
|
582
|
+
AND NOT EXISTS(SELECT 1 FROM ${this.t.exchanges} e WHERE e.queue_id=q.id)
|
|
583
|
+
ORDER BY q.expires_at,q.id LIMIT 1000) AND state<>'pending'`, [cutoff]);
|
|
584
|
+
return (exchanges.changes ?? 0) + (changed.changes ?? 0);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
/** Records exchanges whose deadline passed with no grant returned as unconfirmed: their outcome is unknown. */
|
|
588
|
+
async lapseExchanges() {
|
|
589
|
+
const now = this.now();
|
|
590
|
+
const changed = await this.db.run(`UPDATE ${this.t.exchanges} SET outcome='unconfirmed' WHERE id IN (
|
|
591
|
+
SELECT id FROM ${this.t.exchanges} WHERE outcome='open' AND queue_id IS NULL AND ends_at<=$1
|
|
592
|
+
ORDER BY ends_at,id LIMIT 1000) AND outcome='open' AND queue_id IS NULL AND ends_at<=$1`, [now]);
|
|
593
|
+
return changed.changes ?? 0;
|
|
594
|
+
}
|
|
595
|
+
/** Takes the owner's lifecycle row lock, creating the row unblocked when missing; exchange changes take it first. */
|
|
596
|
+
async lockOwner(tx, scope) {
|
|
597
|
+
await tx.run(`INSERT INTO ${this.t.blocks}(tenant_id,owner_id) VALUES($1,$2)
|
|
598
|
+
ON CONFLICT(tenant_id,owner_id) DO NOTHING`, ownerFields(scope));
|
|
599
|
+
await tx.get(`SELECT pending FROM ${this.t.blocks} WHERE tenant_id=$1 AND owner_id=$2 FOR UPDATE`, ownerFields(scope));
|
|
600
|
+
}
|
|
601
|
+
checkExchange(exchange) {
|
|
602
|
+
const scope = exchange?.scope;
|
|
603
|
+
if (!scope || !key(exchange.id) || !key(scope.tenantId) || !key(scope.ownerId) || !key(scope.connectionId)
|
|
604
|
+
|| !integer(exchange.generation) || !integer(exchange.startedAt) || !integer(exchange.endsAt)) {
|
|
605
|
+
throw new SourceSyncFault('invalid_response');
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/** The exchange's row under its lock, or null when no row matches every field of the exchange. */
|
|
609
|
+
async exchangeRow(tx, exchange) {
|
|
610
|
+
const row = await tx.get(`SELECT * FROM ${this.t.exchanges} WHERE id=$1 FOR UPDATE`, [exchange.id]);
|
|
611
|
+
return row && row.tenant_id === exchange.scope.tenantId && row.owner_id === exchange.scope.ownerId
|
|
612
|
+
&& row.connection_id === exchange.scope.connectionId && Number(row.generation) === exchange.generation
|
|
613
|
+
&& Number(row.started_at) === exchange.startedAt && Number(row.ends_at) === exchange.endsAt ? row : null;
|
|
614
|
+
}
|
|
615
|
+
/** What a settled exchange did with its grant, from its row and its queued token's state. */
|
|
616
|
+
async exchangeOutcome(tx, row) {
|
|
617
|
+
if (row.outcome === 'installed')
|
|
618
|
+
return { kind: 'installed' };
|
|
619
|
+
if (!row.queue_id)
|
|
620
|
+
return { kind: 'permission_removal_required' };
|
|
621
|
+
const queue = await tx.get(`SELECT state FROM ${this.t.queue} WHERE id=$1`, [row.queue_id]);
|
|
622
|
+
return queue?.state === 'pending' || queue?.state === 'confirmed' ? { kind: 'cleanup_pending' }
|
|
623
|
+
: { kind: 'permission_removal_required' };
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Records an authorization exchange before its request is sent. Call it inside the host transaction that consumes
|
|
627
|
+
* the authenticated start, under the owner fence: it answers null, and the caller rolls that whole transaction
|
|
628
|
+
* back, unless the connection is `connecting` at `generation` and `canConnect` holds, so one exchange at a time is
|
|
629
|
+
* in flight for the owner and none while a revocation of the owner's is pending or unacknowledged. The exchange's
|
|
630
|
+
* marker holds opaque ids and times only, and the exchange ends 60 seconds after it begins: a grant returned later
|
|
631
|
+
* is not installed. Send the request only after the transaction commits.
|
|
632
|
+
*
|
|
633
|
+
* @param exchangeId - The host's opaque id for this attempt, 1 to 512 characters, never reused.
|
|
634
|
+
* @returns The exchange, or null.
|
|
635
|
+
* @throws {SourceSyncFault} `invalid_response` for a malformed scope, generation or id.
|
|
636
|
+
*/
|
|
637
|
+
async beginExchange(tx, scope, generation, exchangeId) {
|
|
638
|
+
if (!key(exchangeId) || !integer(generation) || !key(scope?.tenantId) || !key(scope?.ownerId)
|
|
639
|
+
|| !key(scope?.connectionId))
|
|
640
|
+
throw new SourceSyncFault('invalid_response');
|
|
641
|
+
return this.stored(async () => {
|
|
642
|
+
const state = await this.store.lockState(tx, scope);
|
|
643
|
+
if (!state || state.status !== 'connecting' || state.generation !== generation)
|
|
644
|
+
return null;
|
|
645
|
+
await this.lockOwner(tx, scope);
|
|
646
|
+
if (!await this.canConnect(tx, scope))
|
|
647
|
+
return null;
|
|
648
|
+
const startedAt = this.now();
|
|
649
|
+
const endsAt = startedAt + EXCHANGE_MS;
|
|
650
|
+
const inserted = await tx.run(`INSERT INTO ${this.t.exchanges}(id,tenant_id,owner_id,connection_id,generation,
|
|
651
|
+
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]);
|
|
652
|
+
if (inserted.changes !== 1)
|
|
653
|
+
return null;
|
|
654
|
+
const exchange = { id: exchangeId, generation, startedAt, endsAt,
|
|
655
|
+
scope: { tenantId: scope.tenantId, ownerId: scope.ownerId, connectionId: scope.connectionId } };
|
|
656
|
+
return exchange;
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Whether this exact exchange may install its grant now, read under the owner fence in the installing transaction:
|
|
661
|
+
* its marker is open and holds its sealed grant for installation, its deadline has not passed, the connection is
|
|
662
|
+
* still `connecting` at its generation with no grant installed, `permits` holds, and no other revocation or
|
|
663
|
+
* exchange keeps the owner from a new grant. It recognizes only the matching live attempt, never an older or newer
|
|
664
|
+
* one.
|
|
665
|
+
*/
|
|
666
|
+
async mayInstallExchange(tx, exchange) {
|
|
667
|
+
this.checkExchange(exchange);
|
|
668
|
+
return this.stored(async () => {
|
|
669
|
+
const scope = exchange.scope;
|
|
670
|
+
const state = await this.store.lockState(tx, scope);
|
|
671
|
+
await this.lockOwner(tx, scope);
|
|
672
|
+
const row = await this.exchangeRow(tx, exchange);
|
|
673
|
+
if (!row || row.outcome !== 'open' || !row.queue_id || this.now() >= exchange.endsAt
|
|
674
|
+
|| !state || state.status !== 'connecting' || state.generation !== exchange.generation)
|
|
675
|
+
return false;
|
|
676
|
+
const held = await tx.get(`SELECT * FROM ${this.t.queue} WHERE id=$1 FOR UPDATE`, [row.queue_id]);
|
|
677
|
+
if (!held || held.state !== 'pending' || held.lease_token !== exchange.id)
|
|
678
|
+
return false;
|
|
679
|
+
if (!await this.permits(tx, scope) || await this.barrier(tx, scope, exchange.id))
|
|
680
|
+
return false;
|
|
681
|
+
return await this.options.values(tx, scope).get(SOURCE_GRANT_KEY) === null;
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Settles the grant an exchange returned. First the grant's refresh token is sealed into the revoke-only queue,
|
|
686
|
+
* held there until the exchange's end, so a crash cannot lose it. Then, when `mayInstallExchange` holds, `install`
|
|
687
|
+
* runs in the owner's transaction: it saves the grant under `sourceGrantNamespace` and `SOURCE_GRANT_KEY`, records
|
|
688
|
+
* the connection's ready state and finishes the host's start, and the same commit removes the held token. If the
|
|
689
|
+
* exchange may not install, `install` answers anything but true, throws or finishes after the deadline, the whole
|
|
690
|
+
* installation rolls back and the held token becomes due for revocation at once; if this process stops first, it
|
|
691
|
+
* becomes due at the exchange's end. A stale, cancelled or deleted exchange never runs `install`. An exchange that
|
|
692
|
+
* already ended or was recorded unconfirmed takes no token: its notice stands. A grant with no usable refresh token
|
|
693
|
+
* records the exchange unconfirmed.
|
|
694
|
+
*
|
|
695
|
+
* @returns What became of the grant.
|
|
696
|
+
* @throws {SourceSyncFault} `invalid_response` for a malformed exchange, `storage_unavailable` when the store fails.
|
|
697
|
+
*/
|
|
698
|
+
async settleExchange(exchange, grant, install) {
|
|
699
|
+
this.checkExchange(exchange);
|
|
700
|
+
if (typeof install !== 'function')
|
|
701
|
+
throw new SourceSyncFault('invalid_response');
|
|
702
|
+
const refresh = grant?.refreshToken;
|
|
703
|
+
const token = typeof refresh === 'string' && refresh.length > 0 && refresh.length <= 16_384 ? refresh : null;
|
|
704
|
+
const removal = { kind: 'permission_removal_required' };
|
|
705
|
+
const settled = await this.stored(() => this.db.transaction(async (tx) => {
|
|
706
|
+
await this.lockOwner(tx, exchange.scope);
|
|
707
|
+
const row = await this.exchangeRow(tx, exchange);
|
|
708
|
+
if (!row)
|
|
709
|
+
return removal;
|
|
710
|
+
if (row.outcome !== 'open')
|
|
711
|
+
return this.exchangeOutcome(tx, row);
|
|
712
|
+
if (row.queue_id)
|
|
713
|
+
return null;
|
|
714
|
+
if (token === null) {
|
|
715
|
+
await tx.run(`UPDATE ${this.t.exchanges} SET outcome='unconfirmed' WHERE id=$1`, [row.id]);
|
|
716
|
+
return removal;
|
|
717
|
+
}
|
|
718
|
+
const now = this.now();
|
|
719
|
+
const id = randomUUID();
|
|
720
|
+
const until = Number(row.ends_at);
|
|
721
|
+
const hold = until > now;
|
|
722
|
+
const sealed = sealSecret(token, this.options.keys.current.key, this.options.keys.current.id, this.context(id));
|
|
723
|
+
await tx.run(`INSERT INTO ${this.t.queue}(id,sealed,state,expires_at,next_at,lease_token,lease_until)
|
|
724
|
+
VALUES($1,$2,'pending',$3,$4,$5,$6)`, [id, sealed, now + 86_340_000, hold ? until : now,
|
|
725
|
+
hold ? row.id : null, hold ? until : null]);
|
|
726
|
+
await tx.run(`UPDATE ${this.t.exchanges} SET queue_id=$2,outcome=$3 WHERE id=$1`, [row.id, id, hold ? 'open' : 'cleanup']);
|
|
727
|
+
return hold ? null : { kind: 'cleanup_pending' };
|
|
728
|
+
}));
|
|
729
|
+
if (settled)
|
|
730
|
+
return settled;
|
|
731
|
+
try {
|
|
732
|
+
const installed = await this.db.transaction(async (tx) => {
|
|
733
|
+
if (!await this.mayInstallExchange(tx, exchange))
|
|
734
|
+
return false;
|
|
735
|
+
if (await install(tx, grant) !== true || this.now() >= exchange.endsAt)
|
|
736
|
+
throw new ExchangeRefused();
|
|
737
|
+
const row = await this.exchangeRow(tx, exchange);
|
|
738
|
+
if (!row?.queue_id)
|
|
739
|
+
throw new ExchangeRefused();
|
|
740
|
+
await tx.run(`DELETE FROM ${this.t.queue} WHERE id=$1 AND lease_token=$2`, [row.queue_id, row.id]);
|
|
741
|
+
await tx.run(`UPDATE ${this.t.exchanges} SET outcome='installed',queue_id=NULL WHERE id=$1`, [row.id]);
|
|
742
|
+
return true;
|
|
743
|
+
});
|
|
744
|
+
if (installed)
|
|
745
|
+
return { kind: 'installed' };
|
|
746
|
+
}
|
|
747
|
+
catch { /* A refused or failed installation rolled back whole; its held grant goes to revocation below. */ }
|
|
748
|
+
return this.stored(() => this.db.transaction(async (tx) => {
|
|
749
|
+
await this.lockOwner(tx, exchange.scope);
|
|
750
|
+
const row = await this.exchangeRow(tx, exchange);
|
|
751
|
+
if (!row)
|
|
752
|
+
return removal;
|
|
753
|
+
if (row.outcome === 'open' && row.queue_id) {
|
|
754
|
+
await tx.run(`UPDATE ${this.t.queue} SET next_at=$3,lease_token=NULL,lease_until=NULL
|
|
755
|
+
WHERE id=$1 AND state='pending' AND lease_token=$2`, [row.queue_id, row.id, this.now()]);
|
|
756
|
+
await tx.run(`UPDATE ${this.t.exchanges} SET outcome='cleanup' WHERE id=$1`, [row.id]);
|
|
757
|
+
row.outcome = 'cleanup';
|
|
758
|
+
}
|
|
759
|
+
return this.exchangeOutcome(tx, row);
|
|
760
|
+
}));
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Records how an exchange ended when it returned no grant to settle: `not_dispatched` when its request never left
|
|
764
|
+
* the process, `revoked` when the provider flow revoked a grant it refused and the provider confirmed it, and
|
|
765
|
+
* `unconfirmed` for anything else after the request could have left, such as a timeout, an aborted connection, an
|
|
766
|
+
* unreadable answer or a revocation that was not confirmed. Only an open exchange holding no returned grant takes
|
|
767
|
+
* an ending, and a recorded outcome is never weakened. An unconfirmed exchange keeps new grants blocked until the
|
|
768
|
+
* account owner acknowledges the permission's removal.
|
|
769
|
+
*
|
|
770
|
+
* @throws {SourceSyncFault} `invalid_response` for a malformed exchange or another outcome.
|
|
771
|
+
*/
|
|
772
|
+
async endExchange(exchange, outcome) {
|
|
773
|
+
this.checkExchange(exchange);
|
|
774
|
+
if (outcome !== 'not_dispatched' && outcome !== 'revoked' && outcome !== 'unconfirmed') {
|
|
775
|
+
throw new SourceSyncFault('invalid_response');
|
|
776
|
+
}
|
|
777
|
+
await this.stored(() => this.db.transaction(async (tx) => {
|
|
778
|
+
await this.lockOwner(tx, exchange.scope);
|
|
779
|
+
const row = await this.exchangeRow(tx, exchange);
|
|
780
|
+
if (!row || row.outcome !== 'open' || row.queue_id !== null)
|
|
781
|
+
return;
|
|
782
|
+
await tx.run(`UPDATE ${this.t.exchanges} SET outcome=$2 WHERE id=$1`, [row.id, outcome === 'unconfirmed' ? 'unconfirmed' : 'ended']);
|
|
783
|
+
}));
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
//# sourceMappingURL=SourceRevocations.js.map
|