@zackbart/connecta 0.26.0 → 0.26.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.
@@ -44,7 +44,123 @@ const SEALED_OAUTH_KEYS = new Set([
44
44
  "oauth:verifier",
45
45
  ]);
46
46
  const SEALED_VALUE_VERSION = 1;
47
- const MAX_CLEANUP_BACKLOG = 1_000;
47
+ /**
48
+ * The longest cleanup lineage a reset publishes. A reset's storage work no
49
+ * longer depends on the lineage's length — it deletes one retired generation
50
+ * after the fence and sweeps at most MAX_EXPIRED_SWEEP before it — so this
51
+ * guards only the size of the two records every reset reads. A generation
52
+ * name is 39 characters, so 5,000 entries is about 210 KB of manifest and
53
+ * 280 KB of times: far under Workers KV's 25 MiB value limit, and a few
54
+ * milliseconds to parse. Reaching it takes more than 4,000 resets of one
55
+ * connector in a day on top of the 1,000 an earlier release allowed, which
56
+ * is a loop, not an operator. An earlier release refuses a lineage longer
57
+ * than 1,000, so rolling back past this one wedges only a connector that
58
+ * went beyond that — which that release would have wedged anyway.
59
+ */
60
+ const MAX_CLEANUP_BACKLOG = 5_000;
61
+ /**
62
+ * How long a retired generation stays in the cleanup lineage. A late write
63
+ * lands in a retired namespace only from a request that captured that
64
+ * generation before the reset and is still running — an OAuth flow, a token
65
+ * refresh, or a reader whose eventually consistent store (Workers KV serves a
66
+ * stale generation for a minute or more) has not yet seen the fence. None of
67
+ * those outlive a day. A late writer's own cleanup deletes what it wrote, and
68
+ * if that delete fails it records the generation as retired again from that
69
+ * moment; residue it leaves is unreadable behind the fence either way. Once
70
+ * the grace has passed, a reset sweeps the generation, and one whose keys
71
+ * are confirmed deleted cannot be written again, so it leaves the lineage.
72
+ * That is an assumption, not a proof: a request that holds a retired
73
+ * generation for longer than this and dies between its write and its own
74
+ * cleanup leaves residue no reset tracks. It is still unreadable — the epoch
75
+ * fence, not this cleanup, is what keeps an old namespace out of use.
76
+ */
77
+ const CLEANUP_GRACE_MS = 24 * 60 * 60 * 1000;
78
+ /**
79
+ * The most past-grace generations one reset sweeps before publishing. The
80
+ * rest stay in the lineage for a later reset, so a backlog built by a burst
81
+ * of resets drains over several resets instead of in one request.
82
+ */
83
+ const MAX_EXPIRED_SWEEP = 16;
84
+ /**
85
+ * Independent storage deletes one cleanup keeps in flight: Workers allows six
86
+ * simultaneous open connections per invocation, and more would only queue.
87
+ */
88
+ const DELETE_CONCURRENCY = 6;
89
+ /**
90
+ * How many of the most recently retired generations a reset checks for an
91
+ * unfinished cleanup. The case that matters is the one just before: an
92
+ * operator whose Disconnect or Restart reported a failed cleanup retries, and
93
+ * that retry must delete the grant the failure left behind. Eight covers that
94
+ * with room for a burst of failing restarts, costs eight small reads per
95
+ * reset, and bounds a retry to eight generations' deletes; anything older is
96
+ * reclaimed by the past-grace sweep instead.
97
+ */
98
+ const RETRY_PROBES = 8;
99
+ /**
100
+ * How long a pending authorization URL may be handed out again instead of
101
+ * starting a fresh flow. The URL itself never expires on connecta's side, but
102
+ * the authorization server's half of it does: a pushed request URI lives
103
+ * seconds to minutes (RFC 9126), and login transactions are commonly held
104
+ * for minutes, not hours. Past this age a reissued URL is more likely to land
105
+ * the operator on an expired-session page than on consent, and a fresh start
106
+ * costs one authorization request, not a client registration. It also sits
107
+ * inside the registry's 15-minute personal-auth handoff.
108
+ */
109
+ const PENDING_AUTHORIZATION_MAX_AGE_MS = 10 * 60 * 1000;
110
+ /**
111
+ * Union two lineages by generation, keeping the later retirement time, so a
112
+ * merge can only ever lengthen a generation's grace. Order is first-seen.
113
+ */
114
+ function mergeLineage(...lineages) {
115
+ const merged = new Map();
116
+ for (const lineage of lineages) {
117
+ for (const { generation, retiredAt } of lineage) {
118
+ const known = merged.get(generation);
119
+ merged.set(generation, known === undefined ? retiredAt : Math.max(known, retiredAt));
120
+ }
121
+ }
122
+ return [...merged].map(([generation, retiredAt]) => ({
123
+ generation,
124
+ retiredAt,
125
+ }));
126
+ }
127
+ /**
128
+ * A limiter admitting at most `limit` storage operations at once. A finished
129
+ * operation hands its slot straight to the next waiter, so the bound holds
130
+ * even when new callers arrive between the two.
131
+ */
132
+ function concurrencyLimit(limit) {
133
+ let active = 0;
134
+ const waiting = [];
135
+ return async (run) => {
136
+ if (active < limit)
137
+ active++;
138
+ else
139
+ await new Promise((resume) => waiting.push(resume));
140
+ try {
141
+ return await run();
142
+ }
143
+ finally {
144
+ const next = waiting.shift();
145
+ if (next)
146
+ next();
147
+ else
148
+ active--;
149
+ }
150
+ };
151
+ }
152
+ /**
153
+ * The first rejection in input order, as `{ reason }`. The wrapper matters: a
154
+ * store may reject with `undefined` or another falsy value, and that is still
155
+ * a failed delete.
156
+ */
157
+ function firstRejection(results) {
158
+ for (const result of results) {
159
+ if (result.status === "rejected")
160
+ return { reason: result.reason };
161
+ }
162
+ return undefined;
163
+ }
48
164
  function isRefreshTokenRequest(init) {
49
165
  if ((init?.method ?? "GET").toUpperCase() !== "POST")
50
166
  return false;
@@ -372,8 +488,16 @@ export class OAuthRefreshCoordinator {
372
488
  // Await passthrough failures here so workerd associates the rejection
373
489
  // with the fetch the SDK is already awaiting, rather than reporting the
374
490
  // adopted inner promise as an unhandled rejection.
375
- if (!isRefreshTokenRequest(init))
376
- return await baseFetch(input, init);
491
+ if (!isRefreshTokenRequest(init)) {
492
+ if (requestSignal?.aborted)
493
+ throw aborted(requestSignal);
494
+ const signal = requestSignal
495
+ ? init?.signal
496
+ ? AbortSignal.any([requestSignal, init.signal])
497
+ : requestSignal
498
+ : init?.signal;
499
+ return await baseFetch(input, { ...init, ...(signal ? { signal } : {}) });
500
+ }
377
501
  // A verdict belongs to one refresh; never let an older one decide this.
378
502
  provider.recordRefreshFailure(undefined);
379
503
  const generation = await provider.flowGeneration();
@@ -637,6 +761,7 @@ function storedOAuthValue(value) {
637
761
  return (candidate.connectaOAuthVersion === STORED_VALUE_VERSION &&
638
762
  typeof candidate.generation === "string" &&
639
763
  (candidate.issuer === undefined || typeof candidate.issuer === "string") &&
764
+ (candidate.binding === undefined || typeof candidate.binding === "string") &&
640
765
  "value" in candidate);
641
766
  }
642
767
  function legacyStoredOAuthValue(value) {
@@ -673,6 +798,39 @@ export function oauthValueStorageKey(key, generation) {
673
798
  function cleanupBacklogKey(generation) {
674
799
  return `oauth:cleanup:${encodeURIComponent(generation)}`;
675
800
  }
801
+ /**
802
+ * Retirement times for a lineage, beside it rather than inside it: the
803
+ * manifest stays the plain array of generation names every release reads, so
804
+ * rolling back to one that predates the times still resets and cleans up.
805
+ * Membership belongs to the manifest alone. A time with no manifest entry is
806
+ * ignored, and an entry with no time reads as retired "now", so any
807
+ * interleaving of the two keys errs toward a longer grace.
808
+ */
809
+ function cleanupTimesKey(generation) {
810
+ return `oauth:cleanup-at:${encodeURIComponent(generation)}`;
811
+ }
812
+ /** Parse a times record, reading anything malformed as "no times known". */
813
+ function retirementTimes(raw) {
814
+ const times = new Map();
815
+ if (raw === null)
816
+ return times;
817
+ let parsed;
818
+ try {
819
+ parsed = JSON.parse(raw);
820
+ }
821
+ catch {
822
+ return times;
823
+ }
824
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
825
+ return times;
826
+ }
827
+ for (const [generation, retiredAt] of Object.entries(parsed)) {
828
+ if (typeof retiredAt === "number" && Number.isFinite(retiredAt)) {
829
+ times.set(generation, retiredAt);
830
+ }
831
+ }
832
+ return times;
833
+ }
676
834
  /**
677
835
  * OAuthClientProvider implemented over KVStorage for a single downstream
678
836
  * connector. Keys live in the connector's namespace as `oauth:<field>`.
@@ -689,6 +847,9 @@ export class KvOAuthProvider {
689
847
  refreshCoordinator;
690
848
  allowAuthorization;
691
849
  sealer;
850
+ signal;
851
+ clientBinding;
852
+ onReset;
692
853
  /**
693
854
  * The reset generation this provider's flow started under. Every OAuth value
694
855
  * it writes carries this epoch, so a late write can land after a reset without
@@ -702,13 +863,22 @@ export class KvOAuthProvider {
702
863
  refreshBasis;
703
864
  constructor(connectorId, storage, redirectUri, refreshCoordinator, allowAuthorization = true,
704
865
  /** Present when the deployment's vault can seal; credentials are then ciphertext. */
705
- sealer) {
866
+ sealer,
867
+ /** Request ownership for a start; a timed-out SDK hook may still resume. */
868
+ signal,
869
+ /** Stable connector configuration bound to dynamically registered clients. */
870
+ clientBinding,
871
+ /** Request-local observer so the operator route drains every reset it began. */
872
+ onReset) {
706
873
  this.connectorId = connectorId;
707
874
  this.storage = storage;
708
875
  this.redirectUri = redirectUri;
709
876
  this.refreshCoordinator = refreshCoordinator;
710
877
  this.allowAuthorization = allowAuthorization;
711
878
  this.sealer = sealer;
879
+ this.signal = signal;
880
+ this.clientBinding = clientBinding;
881
+ this.onReset = onReset;
712
882
  }
713
883
  /**
714
884
  * Stamp the force-reauth generation the current connect flow started under.
@@ -824,13 +994,15 @@ export class KvOAuthProvider {
824
994
  * check-then-write race: a late old write cannot overwrite a replacement
825
995
  * flow's value because the two writes have different physical keys.
826
996
  */
827
- async writeValue(key, value, serializeLegacy, issuer) {
997
+ async writeValue(key, value, serializeLegacy, issuer, writtenAt, binding) {
828
998
  const generation = await this.writeGeneration();
829
999
  await this.storeInGeneration(key, generation, () => {
830
1000
  const stored = {
831
1001
  connectaOAuthVersion: STORED_VALUE_VERSION,
832
1002
  generation,
833
1003
  ...(issuer !== undefined ? { issuer } : {}),
1004
+ ...(binding !== undefined ? { binding } : {}),
1005
+ ...(writtenAt !== undefined ? { writtenAt } : {}),
834
1006
  value,
835
1007
  };
836
1008
  return isModernGeneration(generation) || issuer !== undefined
@@ -847,7 +1019,8 @@ export class KvOAuthProvider {
847
1019
  * another request replaced in the meantime.
848
1020
  */
849
1021
  async storeInGeneration(key, generation, serialize, expected) {
850
- if (generation.startsWith(RESETTING_GENERATION_PREFIX) ||
1022
+ if (this.signal?.aborted ||
1023
+ generation.startsWith(RESETTING_GENERATION_PREFIX) ||
851
1024
  generation.startsWith(DISCONNECTED_GENERATION_PREFIX) ||
852
1025
  (await this.generation()) !== generation) {
853
1026
  return;
@@ -860,6 +1033,8 @@ export class KvOAuthProvider {
860
1033
  sealed: await this.sealer.seal(physicalKey, plaintext),
861
1034
  })
862
1035
  : plaintext;
1036
+ if (this.signal?.aborted)
1037
+ return;
863
1038
  if (expected === undefined) {
864
1039
  await this.storage.set(physicalKey, serialized);
865
1040
  }
@@ -870,9 +1045,16 @@ export class KvOAuthProvider {
870
1045
  // before this set, remove the now-unreachable residue ourselves. The epoch
871
1046
  // key already provides correctness; this second check is physical hygiene.
872
1047
  const current = await this.generation();
873
- if (current !== generation) {
1048
+ if (current !== generation || this.signal?.aborted) {
874
1049
  try {
875
- await this.storage.delete(physicalKey);
1050
+ // On cancellation the generation may still be active. A newer flow
1051
+ // could have written this key while the old storage set was pending.
1052
+ if (this.storage.compareAndSet) {
1053
+ await this.storage.compareAndSet(physicalKey, serialized, null);
1054
+ }
1055
+ else if ((await this.storage.get(physicalKey)) === serialized) {
1056
+ await this.storage.delete(physicalKey);
1057
+ }
876
1058
  }
877
1059
  catch {
878
1060
  // Make a transient cleanup failure retryable by the next force reset.
@@ -1000,6 +1182,12 @@ export class KvOAuthProvider {
1000
1182
  value: parsed.value,
1001
1183
  generation,
1002
1184
  ...(parsed.issuer !== undefined ? { issuer: parsed.issuer } : {}),
1185
+ ...(parsed.binding !== undefined ? { binding: parsed.binding } : {}),
1186
+ // Only a finite number is a time; anything else reads as untimed.
1187
+ ...(typeof parsed.writtenAt === "number" &&
1188
+ Number.isFinite(parsed.writtenAt)
1189
+ ? { writtenAt: parsed.writtenAt }
1190
+ : {}),
1003
1191
  }
1004
1192
  : undefined;
1005
1193
  }
@@ -1031,6 +1219,8 @@ export class KvOAuthProvider {
1031
1219
  }
1032
1220
  if (stored.issuer === ctx.issuer)
1033
1221
  return stored.value;
1222
+ if (this.signal?.aborted)
1223
+ throw this.signal.reason;
1034
1224
  try {
1035
1225
  await this.resetAuthorization();
1036
1226
  }
@@ -1042,40 +1232,99 @@ export class KvOAuthProvider {
1042
1232
  }
1043
1233
  return undefined;
1044
1234
  }
1045
- /** Attempt every key deletion, then report the first backend failure. */
1046
- async deleteAll(keys) {
1047
- let firstError;
1048
- for (const key of keys) {
1049
- try {
1050
- await this.storage.delete(key);
1051
- }
1052
- catch (error) {
1053
- firstError ??= error;
1054
- }
1055
- }
1056
- if (firstError)
1057
- throw firstError;
1235
+ /**
1236
+ * Attempt every key deletion, a bounded number at a time, then report the
1237
+ * first failure in key order. The deletes are independent, so none waits
1238
+ * on another's round trip.
1239
+ */
1240
+ async deleteAll(keys, limit = concurrencyLimit(DELETE_CONCURRENCY)) {
1241
+ const failure = firstRejection(await Promise.allSettled(keys.map((key) => limit(() => this.storage.delete(key)))));
1242
+ if (failure)
1243
+ throw failure.reason;
1058
1244
  }
1059
- async cleanupBacklog(generation) {
1060
- const raw = await this.storage.get(cleanupBacklogKey(generation));
1061
- if (raw === null)
1062
- return [];
1245
+ /**
1246
+ * Delete each retired generation's values, then its own manifest and times
1247
+ * once every value is gone, sharing one bound across all of them. Settles
1248
+ * one result per generation, in input order.
1249
+ */
1250
+ cleanupGenerations(generations, limit) {
1251
+ return Promise.allSettled(generations.map(async (generation) => {
1252
+ await this.deleteAll(this.valueKeysForGeneration(generation), limit);
1253
+ await this.deleteAll([cleanupBacklogKey(generation), cleanupTimesKey(generation)], limit);
1254
+ }));
1255
+ }
1256
+ /**
1257
+ * The cleanup lineage published for `generation`, with retirement times,
1258
+ * and the times record exactly as stored. An entry with no recorded time —
1259
+ * written by an earlier release, or whose time write failed — reads as
1260
+ * retired `now`, which can only lengthen its grace.
1261
+ */
1262
+ async cleanupBacklog(generation, now) {
1263
+ const reads = await Promise.allSettled([
1264
+ this.storage.get(cleanupBacklogKey(generation)),
1265
+ this.storage.get(cleanupTimesKey(generation)),
1266
+ ]);
1267
+ const failure = firstRejection(reads);
1268
+ if (failure)
1269
+ throw failure.reason;
1270
+ const [raw, rawTimes] = reads.map((read) => read.status === "fulfilled" ? read.value : null);
1271
+ const recorded = retirementTimes(rawTimes ?? null);
1272
+ if (raw === null || raw === undefined)
1273
+ return { lineage: [], recorded };
1063
1274
  const parsed = JSON.parse(raw);
1064
1275
  if (!Array.isArray(parsed) ||
1065
1276
  parsed.length > MAX_CLEANUP_BACKLOG ||
1066
1277
  !parsed.every((value) => typeof value === "string")) {
1067
1278
  throw new Error(`Invalid OAuth cleanup backlog for "${this.connectorId}"`);
1068
1279
  }
1069
- return [...new Set(parsed)];
1280
+ return {
1281
+ lineage: mergeLineage(parsed.map((entry) => ({
1282
+ generation: entry,
1283
+ retiredAt: recorded.get(entry) ?? now,
1284
+ }))),
1285
+ recorded,
1286
+ };
1287
+ }
1288
+ timesRecord(lineage) {
1289
+ return JSON.stringify(Object.fromEntries(lineage));
1070
1290
  }
1291
+ /** Write a lineage's manifest and its times, reporting the first failure. */
1292
+ async publishLineage(generation, lineage) {
1293
+ const failure = firstRejection(await Promise.allSettled([
1294
+ this.storage.set(cleanupBacklogKey(generation), JSON.stringify(lineage.map((entry) => entry.generation))),
1295
+ this.storage.set(cleanupTimesKey(generation), this.timesRecord(lineage.map((entry) => [entry.generation, entry.retiredAt]))),
1296
+ ]));
1297
+ if (failure)
1298
+ throw failure.reason;
1299
+ }
1300
+ /**
1301
+ * Record that a write just landed in `retired` after `active` fenced it,
1302
+ * so its grace starts over from now. A generation not yet listed is
1303
+ * appended. One already listed keeps its place in the manifest and has
1304
+ * only its time moved: a reset that is sweeping it re-reads the lineage
1305
+ * before publishing, sees the new time, and keeps it.
1306
+ *
1307
+ * Only the times record is rewritten for a listed generation, so this can
1308
+ * never drop a manifest entry. Two late writers racing here can lose one
1309
+ * time update to the other: a lost bump leaves the older time, which
1310
+ * shortens that generation's grace back to what it was before the write
1311
+ * (no worse than never recording it), and a time dropped from the record
1312
+ * reads as retired "now", which only lengthens a grace.
1313
+ */
1071
1314
  async rememberRetiredGeneration(active, retired) {
1072
- const backlog = await this.cleanupBacklog(active);
1073
- if (backlog.includes(retired))
1315
+ const now = Date.now();
1316
+ const { lineage, recorded } = await this.cleanupBacklog(active, now);
1317
+ if (lineage.some((entry) => entry.generation === retired)) {
1318
+ await this.storage.set(cleanupTimesKey(active), this.timesRecord([...recorded, [retired, now]]));
1074
1319
  return;
1075
- if (backlog.length >= MAX_CLEANUP_BACKLOG) {
1320
+ }
1321
+ if (lineage.length >= MAX_CLEANUP_BACKLOG) {
1076
1322
  throw new Error(`OAuth cleanup backlog for "${this.connectorId}" is full`);
1077
1323
  }
1078
- await this.storage.set(cleanupBacklogKey(active), JSON.stringify([...backlog, retired]));
1324
+ await this.publishLineage(active, [
1325
+ ...lineage,
1326
+ { generation: retired, retiredAt: now },
1327
+ ]);
1079
1328
  }
1080
1329
  valueKeysForGeneration(generation) {
1081
1330
  return OAUTH_VALUE_KEYS.map((key) => oauthValueStorageKey(key, generation));
@@ -1096,7 +1345,7 @@ export class KvOAuthProvider {
1096
1345
  return this.readIssuerBoundValue("oauth:client", (raw) => JSON.parse(raw), (value) => JSON.stringify(value), ctx);
1097
1346
  }
1098
1347
  async saveClientInformation(info, ctx) {
1099
- await this.writeValue("oauth:client", info, (value) => JSON.stringify(value), ctx?.issuer);
1348
+ await this.writeValue("oauth:client", info, (value) => JSON.stringify(value), ctx?.issuer, undefined, this.clientBinding);
1100
1349
  }
1101
1350
  async discoveryState() {
1102
1351
  return (await this.readValue("oauth:discovery", (raw) => JSON.parse(raw)))?.value;
@@ -1194,7 +1443,10 @@ export class KvOAuthProvider {
1194
1443
  try {
1195
1444
  if (!this.allowAuthorization)
1196
1445
  throw this.authorizationRefused();
1197
- await this.writeValue("oauth:pending", authorizationUrl.toString(), (raw) => raw);
1446
+ // Timestamped so a later start can tell a URL worth reissuing from a
1447
+ // stale one. A pre-envelope (legacy-generation) write keeps its raw
1448
+ // format for older readers and carries no time.
1449
+ await this.writeValue("oauth:pending", authorizationUrl.toString(), (raw) => raw, undefined, Date.now());
1198
1450
  this.failRefreshFlight(new Error("OAuth refresh required reauthorization before tokens were saved."));
1199
1451
  }
1200
1452
  catch (error) {
@@ -1206,6 +1458,22 @@ export class KvOAuthProvider {
1206
1458
  async pendingAuthorizationUrl() {
1207
1459
  return (await this.readValue("oauth:pending", (raw) => raw))?.value;
1208
1460
  }
1461
+ /**
1462
+ * The pending authorization URL, only while it is still worth handing out
1463
+ * again: written within PENDING_AUTHORIZATION_MAX_AGE_MS of now, in either
1464
+ * direction to tolerate clock skew between isolates. A URL with no write
1465
+ * time — stored by an earlier release, or under a pre-envelope generation —
1466
+ * is treated as stale, so the caller starts a fresh flow instead.
1467
+ */
1468
+ async reusablePendingAuthorizationUrl() {
1469
+ const pending = await this.readValue("oauth:pending", (raw) => raw);
1470
+ if (pending?.writtenAt === undefined)
1471
+ return undefined;
1472
+ return Math.abs(Date.now() - pending.writtenAt) <
1473
+ PENDING_AUTHORIZATION_MAX_AGE_MS
1474
+ ? pending.value
1475
+ : undefined;
1476
+ }
1209
1477
  /** Clear one-shot flow state after the callback completes. */
1210
1478
  async clearPending() {
1211
1479
  const generation = await this.writeGeneration();
@@ -1246,22 +1514,82 @@ export class KvOAuthProvider {
1246
1514
  * Once the fence is durable, attempt every deletion even if one fails. A
1247
1515
  * partial backend outage should not leave unrelated secrets behind merely
1248
1516
  * because an earlier key happened to be the first failed delete.
1517
+ *
1518
+ * A reset's storage work does not grow with the resets before it. After
1519
+ * the fence it deletes the one generation it retired, and retries any of
1520
+ * the RETRY_PROBES most recent ones whose cleanup failed. Before publishing,
1521
+ * it sweeps at most MAX_EXPIRED_SWEEP generations retired more than
1522
+ * CLEANUP_GRACE_MS ago — every value, then its manifest and times — and
1523
+ * only a generation whose sweep fully succeeded is left out of the new
1524
+ * lineage. Everything else is carried with its retirement time: a
1525
+ * generation still inside its grace whose cleanup finished is not deleted
1526
+ * again, because a late write into it is unreadable behind the fence and
1527
+ * the late writer deletes it itself, or records the generation again if it
1528
+ * cannot; the sweep reclaims whatever is left once the grace has passed.
1529
+ * The live manifest is never rewritten: an entry leaves the lineage only by
1530
+ * being absent from the next epoch's manifest, which no other request
1531
+ * writes until that epoch is active.
1249
1532
  */
1250
- async resetAuthorization(operatorDisconnected = false) {
1533
+ resetAuthorization(operatorDisconnected = false, preserveClient = false) {
1534
+ const reset = this.performResetAuthorization(operatorDisconnected, preserveClient);
1535
+ this.onReset?.(reset);
1536
+ return reset;
1537
+ }
1538
+ async performResetAuthorization(operatorDisconnected, preserveClient) {
1251
1539
  const nonce = crypto.randomUUID();
1252
1540
  const previous = await this.generation();
1253
- const inherited = await this.cleanupBacklog(previous);
1541
+ // Only an explicitly forced restart may carry a registration forward.
1542
+ // Discovery is never copied: the SDK must select the authorization server
1543
+ // again, then its issuer-aware clientInformation hook checks the issuer.
1544
+ const priorClient = preserveClient && !operatorDisconnected && this.clientBinding
1545
+ ? await this.readValue("oauth:client", (raw) => {
1546
+ try {
1547
+ return JSON.parse(raw);
1548
+ }
1549
+ catch {
1550
+ return {};
1551
+ }
1552
+ })
1553
+ : undefined;
1554
+ const reusableClient = priorClient?.generation === previous &&
1555
+ priorClient.issuer !== undefined &&
1556
+ priorClient.binding === this.clientBinding &&
1557
+ typeof priorClient.value?.client_id === "string"
1558
+ ? priorClient
1559
+ : undefined;
1560
+ const now = Date.now();
1561
+ const inherited = await this.cleanupBacklog(previous, now);
1254
1562
  const active = `${operatorDisconnected
1255
1563
  ? DISCONNECTED_GENERATION_PREFIX
1256
1564
  : ACTIVE_GENERATION_PREFIX}${nonce}`;
1257
- const retired = [...new Set([...inherited, previous])];
1565
+ const limit = concurrencyLimit(DELETE_CONCURRENCY);
1566
+ const withinGrace = (entry) => now - entry.retiredAt < CLEANUP_GRACE_MS;
1567
+ let lineage = inherited.lineage;
1568
+ const expired = lineage
1569
+ .filter((entry) => !withinGrace(entry))
1570
+ .sort((a, b) => a.retiredAt - b.retiredAt)
1571
+ .slice(0, MAX_EXPIRED_SWEEP)
1572
+ .map((entry) => entry.generation);
1573
+ if (expired.length > 0) {
1574
+ const swept = await this.cleanupGenerations(expired, limit);
1575
+ const reclaimed = new Set(expired.filter((_, index) => swept[index]?.status === "fulfilled"));
1576
+ // The sweep widened the window between reading the live manifest and
1577
+ // publishing its successor. A stale writer's cleanup may have appended
1578
+ // to it meanwhile, so read it again and carry the union — and keep a
1579
+ // swept generation after all if that append restarted its grace.
1580
+ const latest = await this.cleanupBacklog(previous, now);
1581
+ lineage = mergeLineage(lineage, latest.lineage).filter((entry) => withinGrace(entry) || !reclaimed.has(entry.generation));
1582
+ }
1583
+ const retired = mergeLineage(lineage, [
1584
+ { generation: previous, retiredAt: now },
1585
+ ]);
1258
1586
  if (retired.length > MAX_CLEANUP_BACKLOG) {
1259
1587
  throw new Error(`OAuth cleanup backlog for "${this.connectorId}" is full`);
1260
1588
  }
1261
1589
  // Publish the complete inherited cleanup work under the prospective epoch
1262
1590
  // before making that epoch active. A crash or later retry can therefore
1263
1591
  // always recover the older namespaces without a storage prefix scan.
1264
- await this.storage.set(cleanupBacklogKey(active), JSON.stringify(retired));
1592
+ await this.publishLineage(active, retired);
1265
1593
  // This is the one authoritative transition. From this point onward every
1266
1594
  // old physical namespace is unreadable. There is deliberately no second
1267
1595
  // "finalize" write: concurrent resets therefore cannot overwrite a newer
@@ -1272,30 +1600,51 @@ export class KvOAuthProvider {
1272
1600
  }
1273
1601
  catch (error) {
1274
1602
  try {
1275
- await this.storage.delete(cleanupBacklogKey(active));
1603
+ await this.deleteAll([
1604
+ cleanupBacklogKey(active),
1605
+ cleanupTimesKey(active),
1606
+ ]);
1276
1607
  }
1277
1608
  catch {
1278
1609
  // Best-effort removal of a manifest for an epoch never activated.
1279
1610
  }
1280
1611
  throw error;
1281
1612
  }
1282
- let firstError;
1283
- for (const generation of retired) {
1284
- try {
1285
- await this.deleteAll(this.valueKeysForGeneration(generation));
1286
- await this.storage.delete(cleanupBacklogKey(generation));
1287
- }
1288
- catch (error) {
1289
- firstError ??= error;
1290
- }
1613
+ if (reusableClient && !this.signal?.aborted) {
1614
+ // readValue opened the old physical key; writeValue seals the plaintext
1615
+ // under the new one. Copying ciphertext would fail its AAD check.
1616
+ this.captureGeneration(active);
1617
+ await this.writeValue("oauth:client", reusableClient.value, (value) => JSON.stringify(value), reusableClient.issuer, undefined, this.clientBinding);
1291
1618
  }
1619
+ // Delete the generation just retired: its values, then the lineage it
1620
+ // published, which the new epoch has copied. Then retry the newest few
1621
+ // younger entries whose own cleanup failed. A generation's manifest is
1622
+ // deleted only after all of its values, so a manifest still present means
1623
+ // its cleanup did not finish (or a late writer recorded into it after it
1624
+ // did); an absent one means there is nothing to retry. That keeps the
1625
+ // 0.26.0 promise that the next reset retries a failed cleanup of the
1626
+ // grant being retired, at a bounded cost: RETRY_PROBES reads, and a full
1627
+ // cleanup only for generations that need one. Everything older waits for
1628
+ // its grace to pass (see above).
1629
+ const probed = retired
1630
+ .filter((entry) => entry.generation !== previous && withinGrace(entry))
1631
+ .sort((a, b) => b.retiredAt - a.retiredAt)
1632
+ .slice(0, RETRY_PROBES)
1633
+ .map((entry) => entry.generation);
1634
+ const probes = await Promise.allSettled(probed.map((generation) => limit(() => this.storage.get(cleanupBacklogKey(generation)))));
1635
+ const unfinished = probed.filter((_, index) => {
1636
+ const probe = probes[index];
1637
+ // A probe that could not read is retried rather than assumed clean.
1638
+ return probe?.status !== "fulfilled" || probe.value !== null;
1639
+ });
1640
+ const failure = firstRejection(await this.cleanupGenerations([previous, ...unfinished], limit));
1292
1641
  // Keep the active manifest immutable for the epoch's whole lifetime, even
1293
1642
  // after successful cleanup. A late old-epoch write can land after cleanup;
1294
1643
  // if its self-delete fails, the next reset must still inherit the complete
1295
1644
  // lineage without racing a manifest shrink/delete. The successor copies
1296
1645
  // this manifest before activation and then removes this retired copy.
1297
- if (firstError)
1298
- throw firstError;
1646
+ if (failure)
1647
+ throw failure.reason;
1299
1648
  }
1300
1649
  async invalidateCredentials(scope) {
1301
1650
  const endsRefresh = scope === "all" || scope === "tokens";
@@ -0,0 +1,20 @@
1
+ // The operator route must not answer while a storage reset it started can
2
+ // still publish an older generation. Storage has no cancellation contract.
3
+ const startResets = new WeakMap();
4
+ export function trackOAuthStartReset(scope, reset) {
5
+ let pending = startResets.get(scope);
6
+ if (!pending) {
7
+ pending = new Set();
8
+ startResets.set(scope, pending);
9
+ }
10
+ pending.add(reset);
11
+ // Both outcomes have an observer, so a rejected reset never creates an
12
+ // unhandled rejection merely because its caller timed out first.
13
+ void reset.then(() => pending.delete(reset), () => pending.delete(reset));
14
+ }
15
+ export async function drainOAuthStartResets(scope) {
16
+ const pending = startResets.get(scope);
17
+ while (pending && pending.size > 0) {
18
+ await Promise.allSettled(pending);
19
+ }
20
+ }