@zackbart/connecta 0.7.6 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/dist/activity.d.ts +17 -0
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js.map +1 -1
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +101 -0
- package/dist/auth/clerk.js.map +1 -1
- package/dist/auth/downstream-oauth.d.ts +57 -20
- package/dist/auth/downstream-oauth.d.ts.map +1 -1
- package/dist/auth/downstream-oauth.js +275 -67
- package/dist/auth/downstream-oauth.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +165 -103
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +12 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +70 -15
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +238 -19
- package/dist/server.js.map +1 -1
- package/dist/storage/file.d.ts.map +1 -1
- package/dist/storage/file.js +8 -0
- package/dist/storage/file.js.map +1 -1
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +7 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +118 -4
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/activity.ts +20 -0
- package/src/auth/clerk.ts +124 -0
- package/src/auth/downstream-oauth.ts +359 -68
- package/src/connectors/remote-mcp.ts +172 -104
- package/src/index.ts +3 -0
- package/src/registry.ts +79 -19
- package/src/server.ts +306 -16
- package/src/storage/file.ts +7 -0
- package/src/types.ts +23 -0
- package/src/ui.ts +124 -3
- package/src/version.ts +1 -1
|
@@ -21,6 +21,65 @@ function timingSafeEqual(a: string, b: string): boolean {
|
|
|
21
21
|
return diff === 0;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
const LEGACY_GENERATION = "legacy";
|
|
25
|
+
const ACTIVE_GENERATION_PREFIX = "v2:";
|
|
26
|
+
const RESETTING_GENERATION_PREFIX = "reset:";
|
|
27
|
+
const DISCONNECTED_GENERATION_PREFIX = "disconnected:";
|
|
28
|
+
const STORED_VALUE_VERSION = 1;
|
|
29
|
+
const OAUTH_VALUE_KEYS = [
|
|
30
|
+
"oauth:client",
|
|
31
|
+
"oauth:tokens",
|
|
32
|
+
"oauth:pending",
|
|
33
|
+
"oauth:verifier",
|
|
34
|
+
"oauth:state",
|
|
35
|
+
] as const;
|
|
36
|
+
const MAX_CLEANUP_BACKLOG = 1_000;
|
|
37
|
+
|
|
38
|
+
interface StoredOAuthValue<T> {
|
|
39
|
+
connectaOAuthVersion: typeof STORED_VALUE_VERSION;
|
|
40
|
+
generation: string;
|
|
41
|
+
value: T;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function storedOAuthValue<T>(
|
|
45
|
+
value: unknown,
|
|
46
|
+
): value is StoredOAuthValue<T> {
|
|
47
|
+
if (!value || typeof value !== "object") return false;
|
|
48
|
+
const candidate = value as Partial<StoredOAuthValue<T>>;
|
|
49
|
+
return (
|
|
50
|
+
candidate.connectaOAuthVersion === STORED_VALUE_VERSION &&
|
|
51
|
+
typeof candidate.generation === "string" &&
|
|
52
|
+
"value" in candidate
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isModernGeneration(generation: string): boolean {
|
|
57
|
+
return (
|
|
58
|
+
generation.startsWith(ACTIVE_GENERATION_PREFIX) ||
|
|
59
|
+
generation.startsWith(RESETTING_GENERATION_PREFIX) ||
|
|
60
|
+
generation.startsWith(DISCONNECTED_GENERATION_PREFIX)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Physical key for an OAuth value in one authorization epoch. Legacy values
|
|
66
|
+
* keep their historical names so upgrades can read an existing grant. Modern
|
|
67
|
+
* values get an epoch-specific namespace: a stale write or delete can then
|
|
68
|
+
* affect only its own flow, even if it lands after a replacement flow.
|
|
69
|
+
*/
|
|
70
|
+
export function oauthValueStorageKey(
|
|
71
|
+
key: string,
|
|
72
|
+
generation: string | null,
|
|
73
|
+
): string {
|
|
74
|
+
return generation !== null && isModernGeneration(generation)
|
|
75
|
+
? `${key}:epoch:${generation}`
|
|
76
|
+
: key;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cleanupBacklogKey(generation: string): string {
|
|
80
|
+
return `oauth:cleanup:${encodeURIComponent(generation)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
24
83
|
/**
|
|
25
84
|
* OAuthClientProvider implemented over KVStorage for a single downstream
|
|
26
85
|
* connector. Keys live in the connector's namespace as `oauth:<field>`.
|
|
@@ -32,11 +91,11 @@ function timingSafeEqual(a: string, b: string): boolean {
|
|
|
32
91
|
*/
|
|
33
92
|
export class KvOAuthProvider implements OAuthClientProvider {
|
|
34
93
|
/**
|
|
35
|
-
* The
|
|
36
|
-
*
|
|
37
|
-
*
|
|
94
|
+
* The reset generation this provider's flow started under. Every OAuth value
|
|
95
|
+
* it writes carries this epoch, so a late write can land after a reset without
|
|
96
|
+
* becoming readable under the new generation.
|
|
38
97
|
*/
|
|
39
|
-
private capturedGeneration:
|
|
98
|
+
private capturedGeneration: string | null = null;
|
|
40
99
|
|
|
41
100
|
constructor(
|
|
42
101
|
private readonly connectorId: string,
|
|
@@ -46,24 +105,161 @@ export class KvOAuthProvider implements OAuthClientProvider {
|
|
|
46
105
|
|
|
47
106
|
/**
|
|
48
107
|
* Stamp the force-reauth generation the current connect flow started under.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* isStale(). Called by the connector once per connect, before c.connect().
|
|
108
|
+
* Called by the connector once per connect, before c.connect(). The callback
|
|
109
|
+
* path captures the generation stored beside its verified state instead.
|
|
52
110
|
*/
|
|
53
|
-
captureGeneration(gen:
|
|
111
|
+
captureGeneration(gen: string): void {
|
|
54
112
|
this.capturedGeneration = gen;
|
|
55
113
|
}
|
|
56
114
|
|
|
57
115
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
|
|
62
|
-
|
|
116
|
+
* The epoch this provider writes under. Direct unit/custom use lazily captures
|
|
117
|
+
* the current generation; connector-driven connect and callback paths stamp it
|
|
118
|
+
* explicitly before the SDK can write.
|
|
119
|
+
*/
|
|
120
|
+
private async writeGeneration(): Promise<string> {
|
|
121
|
+
this.capturedGeneration ??= await this.generation();
|
|
122
|
+
return this.capturedGeneration;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Store a value in the flow's physical epoch namespace. The pre-write check
|
|
127
|
+
* avoids needless stale residue. The namespaced key closes the remaining
|
|
128
|
+
* check-then-write race: a late old write cannot overwrite a replacement
|
|
129
|
+
* flow's value because the two writes have different physical keys.
|
|
63
130
|
*/
|
|
64
|
-
private async
|
|
65
|
-
|
|
66
|
-
|
|
131
|
+
private async writeValue<T>(
|
|
132
|
+
key: string,
|
|
133
|
+
value: T,
|
|
134
|
+
serializeLegacy: (value: T) => string,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
const generation = await this.writeGeneration();
|
|
137
|
+
if (
|
|
138
|
+
generation.startsWith(RESETTING_GENERATION_PREFIX) ||
|
|
139
|
+
generation.startsWith(DISCONNECTED_GENERATION_PREFIX) ||
|
|
140
|
+
(await this.generation()) !== generation
|
|
141
|
+
) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const stored: StoredOAuthValue<T> = {
|
|
145
|
+
connectaOAuthVersion: STORED_VALUE_VERSION,
|
|
146
|
+
generation,
|
|
147
|
+
value,
|
|
148
|
+
};
|
|
149
|
+
const physicalKey = oauthValueStorageKey(key, generation);
|
|
150
|
+
await this.storage.set(
|
|
151
|
+
physicalKey,
|
|
152
|
+
isModernGeneration(generation)
|
|
153
|
+
? JSON.stringify(stored)
|
|
154
|
+
: serializeLegacy(value),
|
|
155
|
+
);
|
|
156
|
+
// If reset landed after the pre-write check and completed its cleanup
|
|
157
|
+
// before this set, remove the now-unreachable residue ourselves. The epoch
|
|
158
|
+
// key already provides correctness; this second check is physical hygiene.
|
|
159
|
+
const current = await this.generation();
|
|
160
|
+
if (current !== generation) {
|
|
161
|
+
try {
|
|
162
|
+
await this.storage.delete(physicalKey);
|
|
163
|
+
} catch {
|
|
164
|
+
// Make a transient cleanup failure retryable by the next force reset.
|
|
165
|
+
// This is still best-effort if storage cannot accept the backlog write.
|
|
166
|
+
try {
|
|
167
|
+
await this.rememberRetiredGeneration(current, generation);
|
|
168
|
+
} catch {
|
|
169
|
+
// The old namespace is already unreadable; storage availability is
|
|
170
|
+
// the remaining physical-hygiene boundary.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Read a value only when it belongs to the active generation. Plain legacy
|
|
178
|
+
* values remain readable until the first v2 reset, so upgrades do not discard
|
|
179
|
+
* an existing grant; once a modern epoch exists, untagged residue fails
|
|
180
|
+
* closed.
|
|
181
|
+
*/
|
|
182
|
+
private async readValue<T>(
|
|
183
|
+
key: string,
|
|
184
|
+
parseLegacy: (raw: string) => T,
|
|
185
|
+
): Promise<{ value: T; generation: string } | undefined> {
|
|
186
|
+
const generation = await this.generation();
|
|
187
|
+
const raw = await this.storage.get(
|
|
188
|
+
oauthValueStorageKey(key, generation),
|
|
189
|
+
);
|
|
190
|
+
if (raw === null) return undefined;
|
|
191
|
+
if (
|
|
192
|
+
generation.startsWith(RESETTING_GENERATION_PREFIX) ||
|
|
193
|
+
generation.startsWith(DISCONNECTED_GENERATION_PREFIX)
|
|
194
|
+
) {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let parsed: unknown;
|
|
199
|
+
try {
|
|
200
|
+
parsed = JSON.parse(raw);
|
|
201
|
+
} catch {
|
|
202
|
+
// Raw string state from a pre-envelope deployment is handled below.
|
|
203
|
+
}
|
|
204
|
+
if (storedOAuthValue<T>(parsed)) {
|
|
205
|
+
return parsed.generation === generation
|
|
206
|
+
? { value: parsed.value, generation }
|
|
207
|
+
: undefined;
|
|
208
|
+
}
|
|
209
|
+
if (isModernGeneration(generation)) return undefined;
|
|
210
|
+
return { value: parseLegacy(raw), generation };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Attempt every key deletion, then report the first backend failure. */
|
|
214
|
+
private async deleteAll(keys: readonly string[]): Promise<void> {
|
|
215
|
+
let firstError: unknown;
|
|
216
|
+
for (const key of keys) {
|
|
217
|
+
try {
|
|
218
|
+
await this.storage.delete(key);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
firstError ??= error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (firstError) throw firstError;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async cleanupBacklog(generation: string): Promise<string[]> {
|
|
227
|
+
const raw = await this.storage.get(cleanupBacklogKey(generation));
|
|
228
|
+
if (raw === null) return [];
|
|
229
|
+
const parsed: unknown = JSON.parse(raw);
|
|
230
|
+
if (
|
|
231
|
+
!Array.isArray(parsed) ||
|
|
232
|
+
parsed.length > MAX_CLEANUP_BACKLOG ||
|
|
233
|
+
!parsed.every((value) => typeof value === "string")
|
|
234
|
+
) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Invalid OAuth cleanup backlog for "${this.connectorId}"`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return [...new Set(parsed)];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async rememberRetiredGeneration(
|
|
243
|
+
active: string,
|
|
244
|
+
retired: string,
|
|
245
|
+
): Promise<void> {
|
|
246
|
+
const backlog = await this.cleanupBacklog(active);
|
|
247
|
+
if (backlog.includes(retired)) return;
|
|
248
|
+
if (backlog.length >= MAX_CLEANUP_BACKLOG) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`OAuth cleanup backlog for "${this.connectorId}" is full`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
await this.storage.set(
|
|
254
|
+
cleanupBacklogKey(active),
|
|
255
|
+
JSON.stringify([...backlog, retired]),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private valueKeysForGeneration(generation: string): string[] {
|
|
260
|
+
return OAUTH_VALUE_KEYS.map((key) =>
|
|
261
|
+
oauthValueStorageKey(key, generation),
|
|
262
|
+
);
|
|
67
263
|
}
|
|
68
264
|
|
|
69
265
|
get redirectUrl(): string {
|
|
@@ -81,39 +277,35 @@ export class KvOAuthProvider implements OAuthClientProvider {
|
|
|
81
277
|
}
|
|
82
278
|
|
|
83
279
|
async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
|
|
84
|
-
|
|
85
|
-
|
|
280
|
+
return (
|
|
281
|
+
await this.readValue(
|
|
282
|
+
"oauth:client",
|
|
283
|
+
(raw) => JSON.parse(raw) as OAuthClientInformationMixed,
|
|
284
|
+
)
|
|
285
|
+
)?.value;
|
|
86
286
|
}
|
|
87
287
|
|
|
88
288
|
async saveClientInformation(
|
|
89
289
|
info: OAuthClientInformationMixed,
|
|
90
290
|
): Promise<void> {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
// connector's post-connect fence then discards.
|
|
95
|
-
if (await this.isStale()) return;
|
|
96
|
-
await this.storage.set("oauth:client", JSON.stringify(info));
|
|
291
|
+
await this.writeValue("oauth:client", info, (value) =>
|
|
292
|
+
JSON.stringify(value),
|
|
293
|
+
);
|
|
97
294
|
}
|
|
98
295
|
|
|
99
296
|
async tokens(): Promise<OAuthTokens | undefined> {
|
|
100
|
-
|
|
101
|
-
|
|
297
|
+
return (
|
|
298
|
+
await this.readValue(
|
|
299
|
+
"oauth:tokens",
|
|
300
|
+
(raw) => JSON.parse(raw) as OAuthTokens,
|
|
301
|
+
)
|
|
302
|
+
)?.value;
|
|
102
303
|
}
|
|
103
304
|
|
|
104
305
|
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// Persisting here would resurrect those tokens for later isolates to read.
|
|
109
|
-
// A silent skip (not a throw) is deliberate: throwing propagates out of the
|
|
110
|
-
// SDK's auth() and fails the in-flight request/connect noisily, whereas
|
|
111
|
-
// skipping lets the in-memory client finish its current operation while
|
|
112
|
-
// leaving KV wiped — the connector's generation check drops that client on
|
|
113
|
-
// its next call. Fails open when no generation was captured, so ordinary
|
|
114
|
-
// token refresh (no force) still persists.
|
|
115
|
-
if (await this.isStale()) return;
|
|
116
|
-
await this.storage.set("oauth:tokens", JSON.stringify(tokens));
|
|
306
|
+
await this.writeValue("oauth:tokens", tokens, (value) =>
|
|
307
|
+
JSON.stringify(value),
|
|
308
|
+
);
|
|
117
309
|
}
|
|
118
310
|
|
|
119
311
|
/**
|
|
@@ -127,7 +319,7 @@ export class KvOAuthProvider implements OAuthClientProvider {
|
|
|
127
319
|
*/
|
|
128
320
|
async state(): Promise<string> {
|
|
129
321
|
const value = randomState();
|
|
130
|
-
await this.
|
|
322
|
+
await this.writeValue("oauth:state", value, (raw) => raw);
|
|
131
323
|
return value;
|
|
132
324
|
}
|
|
133
325
|
|
|
@@ -136,67 +328,166 @@ export class KvOAuthProvider implements OAuthClientProvider {
|
|
|
136
328
|
* value. Absent stored state or absent candidate → false (fail closed).
|
|
137
329
|
*/
|
|
138
330
|
async verifyState(candidate: string | null): Promise<boolean> {
|
|
139
|
-
const expected = await this.
|
|
331
|
+
const expected = await this.readValue("oauth:state", (raw) => raw);
|
|
140
332
|
if (!expected || candidate === null) return false;
|
|
141
|
-
|
|
333
|
+
const matches = timingSafeEqual(candidate, expected.value);
|
|
334
|
+
if (matches) this.captureGeneration(expected.generation);
|
|
335
|
+
return matches;
|
|
142
336
|
}
|
|
143
337
|
|
|
144
338
|
async saveCodeVerifier(verifier: string): Promise<void> {
|
|
145
|
-
await this.
|
|
339
|
+
await this.writeValue("oauth:verifier", verifier, (raw) => raw);
|
|
146
340
|
}
|
|
147
341
|
|
|
148
342
|
async codeVerifier(): Promise<string> {
|
|
149
|
-
const
|
|
150
|
-
if (!
|
|
151
|
-
|
|
343
|
+
const stored = await this.readValue("oauth:verifier", (raw) => raw);
|
|
344
|
+
if (!stored) {
|
|
345
|
+
throw new Error(`No PKCE code verifier for "${this.connectorId}"`);
|
|
346
|
+
}
|
|
347
|
+
return stored.value;
|
|
152
348
|
}
|
|
153
349
|
|
|
154
350
|
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
|
155
|
-
await this.
|
|
351
|
+
await this.writeValue(
|
|
352
|
+
"oauth:pending",
|
|
353
|
+
authorizationUrl.toString(),
|
|
354
|
+
(raw) => raw,
|
|
355
|
+
);
|
|
156
356
|
}
|
|
157
357
|
|
|
158
358
|
/** The stored authorization URL, if a flow is pending. */
|
|
159
359
|
async pendingAuthorizationUrl(): Promise<string | undefined> {
|
|
160
|
-
return (
|
|
360
|
+
return (
|
|
361
|
+
await this.readValue("oauth:pending", (raw) => raw)
|
|
362
|
+
)?.value;
|
|
161
363
|
}
|
|
162
364
|
|
|
163
365
|
/** Clear one-shot flow state after the callback completes. */
|
|
164
366
|
async clearPending(): Promise<void> {
|
|
165
|
-
await this.
|
|
166
|
-
await this.
|
|
167
|
-
|
|
367
|
+
const generation = await this.writeGeneration();
|
|
368
|
+
await this.deleteAll([
|
|
369
|
+
oauthValueStorageKey("oauth:pending", generation),
|
|
370
|
+
oauthValueStorageKey("oauth:verifier", generation),
|
|
371
|
+
oauthValueStorageKey("oauth:state", generation),
|
|
372
|
+
]);
|
|
168
373
|
}
|
|
169
374
|
|
|
170
375
|
/**
|
|
171
|
-
* Force-reauth
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
* notice it went stale. Defaults to 0 when never bumped.
|
|
376
|
+
* Force-reauth epoch shared across isolates through storage. Old numeric
|
|
377
|
+
* generations remain valid strings for migration; new resets use unique
|
|
378
|
+
* nonces, avoiding the lost-update race of read/increment/write.
|
|
175
379
|
*/
|
|
176
|
-
async generation(): Promise<
|
|
177
|
-
|
|
178
|
-
const n = raw ? Number(raw) : 0;
|
|
179
|
-
return Number.isFinite(n) ? n : 0;
|
|
380
|
+
async generation(): Promise<string> {
|
|
381
|
+
return (await this.storage.get("oauth:generation")) ?? LEGACY_GENERATION;
|
|
180
382
|
}
|
|
181
383
|
|
|
182
|
-
/**
|
|
183
|
-
async
|
|
184
|
-
|
|
185
|
-
|
|
384
|
+
/** True only after an operator disconnect, until an explicit authorization starts. */
|
|
385
|
+
async operatorDisconnected(): Promise<boolean> {
|
|
386
|
+
return this.isOperatorDisconnectedGeneration(await this.generation());
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Interpret a generation already read by a connector without another KV lookup. */
|
|
390
|
+
isOperatorDisconnectedGeneration(generation: string): boolean {
|
|
391
|
+
return generation.startsWith(DISCONNECTED_GENERATION_PREFIX);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Publish a unique active epoch without a read/modify/write race. */
|
|
395
|
+
async bumpGeneration(): Promise<string> {
|
|
396
|
+
const next = `${ACTIVE_GENERATION_PREFIX}${crypto.randomUUID()}`;
|
|
397
|
+
await this.storage.set("oauth:generation", next);
|
|
186
398
|
return next;
|
|
187
399
|
}
|
|
188
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Fence every flow that could still write, then remove all durable and
|
|
403
|
+
* one-shot authorization state. The generation is intentionally retained:
|
|
404
|
+
* it is the epoch fence that tells another isolate not to resurrect
|
|
405
|
+
* credentials it read before this reset.
|
|
406
|
+
*
|
|
407
|
+
* Once the fence is durable, attempt every deletion even if one fails. A
|
|
408
|
+
* partial backend outage should not leave unrelated secrets behind merely
|
|
409
|
+
* because an earlier key happened to be the first failed delete.
|
|
410
|
+
*/
|
|
411
|
+
async resetAuthorization(operatorDisconnected = false): Promise<void> {
|
|
412
|
+
const nonce = crypto.randomUUID();
|
|
413
|
+
const previous = await this.generation();
|
|
414
|
+
const inherited = await this.cleanupBacklog(previous);
|
|
415
|
+
const active = `${
|
|
416
|
+
operatorDisconnected
|
|
417
|
+
? DISCONNECTED_GENERATION_PREFIX
|
|
418
|
+
: ACTIVE_GENERATION_PREFIX
|
|
419
|
+
}${nonce}`;
|
|
420
|
+
const retired = [...new Set([...inherited, previous])];
|
|
421
|
+
if (retired.length > MAX_CLEANUP_BACKLOG) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
`OAuth cleanup backlog for "${this.connectorId}" is full`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
// Publish the complete inherited cleanup work under the prospective epoch
|
|
427
|
+
// before making that epoch active. A crash or later retry can therefore
|
|
428
|
+
// always recover the older namespaces without a storage prefix scan.
|
|
429
|
+
await this.storage.set(
|
|
430
|
+
cleanupBacklogKey(active),
|
|
431
|
+
JSON.stringify(retired),
|
|
432
|
+
);
|
|
433
|
+
// This is the one authoritative transition. From this point onward every
|
|
434
|
+
// old physical namespace is unreadable. There is deliberately no second
|
|
435
|
+
// "finalize" write: concurrent resets therefore cannot overwrite a newer
|
|
436
|
+
// reset's epoch after their cleanup finishes out of order.
|
|
437
|
+
try {
|
|
438
|
+
await this.storage.set("oauth:generation", active);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
try {
|
|
441
|
+
await this.storage.delete(cleanupBacklogKey(active));
|
|
442
|
+
} catch {
|
|
443
|
+
// Best-effort removal of a manifest for an epoch never activated.
|
|
444
|
+
}
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
let firstError: unknown;
|
|
449
|
+
for (const generation of retired) {
|
|
450
|
+
try {
|
|
451
|
+
await this.deleteAll(this.valueKeysForGeneration(generation));
|
|
452
|
+
await this.storage.delete(cleanupBacklogKey(generation));
|
|
453
|
+
} catch (error) {
|
|
454
|
+
firstError ??= error;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// Keep the active manifest immutable for the epoch's whole lifetime, even
|
|
458
|
+
// after successful cleanup. A late old-epoch write can land after cleanup;
|
|
459
|
+
// if its self-delete fails, the next reset must still inherit the complete
|
|
460
|
+
// lineage without racing a manifest shrink/delete. The successor copies
|
|
461
|
+
// this manifest before activation and then removes this retired copy.
|
|
462
|
+
if (firstError) throw firstError;
|
|
463
|
+
}
|
|
464
|
+
|
|
189
465
|
async invalidateCredentials(
|
|
190
466
|
scope: "all" | "client" | "tokens" | "verifier" | "discovery",
|
|
191
467
|
): Promise<void> {
|
|
192
|
-
|
|
193
|
-
|
|
468
|
+
const generation = await this.writeGeneration();
|
|
469
|
+
if (scope === "all") {
|
|
470
|
+
await this.deleteAll([
|
|
471
|
+
oauthValueStorageKey("oauth:client", generation),
|
|
472
|
+
oauthValueStorageKey("oauth:tokens", generation),
|
|
473
|
+
oauthValueStorageKey("oauth:verifier", generation),
|
|
474
|
+
]);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (scope === "client") {
|
|
478
|
+
await this.storage.delete(
|
|
479
|
+
oauthValueStorageKey("oauth:client", generation),
|
|
480
|
+
);
|
|
194
481
|
}
|
|
195
|
-
if (scope === "
|
|
196
|
-
await this.storage.delete(
|
|
482
|
+
if (scope === "tokens") {
|
|
483
|
+
await this.storage.delete(
|
|
484
|
+
oauthValueStorageKey("oauth:tokens", generation),
|
|
485
|
+
);
|
|
197
486
|
}
|
|
198
|
-
if (scope === "
|
|
199
|
-
await this.storage.delete(
|
|
487
|
+
if (scope === "verifier") {
|
|
488
|
+
await this.storage.delete(
|
|
489
|
+
oauthValueStorageKey("oauth:verifier", generation),
|
|
490
|
+
);
|
|
200
491
|
}
|
|
201
492
|
}
|
|
202
493
|
}
|