@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.
- package/CHANGELOG.md +116 -0
- package/dist/artifacts/document.js +6 -2
- package/dist/artifacts/markdown.js +43 -14
- package/dist/artifacts/routes.js +27 -4
- package/dist/artifacts/validate.js +1 -0
- package/dist/auth/downstream-oauth.js +397 -48
- package/dist/auth/oauth-start-reset.js +20 -0
- package/dist/branding.d.ts +87 -0
- package/dist/branding.js +170 -1
- package/dist/connectors/remote-mcp.js +32 -7
- package/dist/execute.js +7 -8
- package/dist/oauth-callback-outcome.js +43 -18
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/view.js +288 -33
- package/dist/page-styles.js +7 -0
- package/dist/registry.js +16 -13
- package/dist/routes/mcp.js +5 -1
- package/dist/routes/oauth-management.js +66 -12
- package/dist/routes/oauth.js +54 -87
- package/dist/routes/ui.js +7 -1
- package/dist/server.js +7 -5
- package/dist/skills.js +6 -6
- package/dist/types.d.ts +5 -0
- package/dist/ui.d.ts +5 -0
- package/dist/ui.js +20 -63
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +18 -8
- package/documentation/auth.md +103 -0
- package/documentation/code-mode.md +15 -15
- package/documentation/meta-tools.md +12 -5
- package/package.json +1 -1
- package/templates/node/package.json +1 -1
|
@@ -44,7 +44,123 @@ const SEALED_OAUTH_KEYS = new Set([
|
|
|
44
44
|
"oauth:verifier",
|
|
45
45
|
]);
|
|
46
46
|
const SEALED_VALUE_VERSION = 1;
|
|
47
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
/**
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
|
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
|
|
1073
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
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 (
|
|
1298
|
-
throw
|
|
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
|
+
}
|