@zackbart/connecta 0.26.3 → 0.27.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/AGENTS.md +5 -5
- package/CHANGELOG.md +72 -1
- package/bin/connecta.mjs +9 -1
- package/dist/access-tokens.js +13 -2
- package/dist/artifacts/connector.d.ts +3 -0
- package/dist/artifacts/connector.js +6 -0
- package/dist/artifacts/document.js +6 -0
- package/dist/artifacts/module.js +7 -1
- package/dist/artifacts/operations.d.ts +3 -1
- package/dist/artifacts/operations.js +68 -10
- package/dist/artifacts/refresh.d.ts +2 -0
- package/dist/artifacts/refresh.js +3 -2
- package/dist/artifacts/routes.js +4 -15
- package/dist/artifacts/validate.js +1 -2
- package/dist/auth/downstream-oauth.js +95 -36
- package/dist/connector-scope.d.ts +2 -0
- package/dist/connector-scope.js +27 -0
- package/dist/connectors/api.js +8 -2
- package/dist/connectors/remote-mcp.js +57 -17
- package/dist/credentials.js +2 -1
- package/dist/execute.js +15 -0
- package/dist/index.js +1 -0
- package/dist/oauth-partition.js +32 -0
- package/dist/operator-ui/generated.js +1 -1
- package/dist/providers/notion.js +4 -1
- package/dist/providers/vercel.js +13 -9
- package/dist/registry.d.ts +1 -0
- package/dist/registry.js +58 -25
- package/dist/resumable.js +42 -10
- package/dist/routes/credentials.js +16 -8
- package/dist/routes/oauth.js +70 -64
- package/dist/runtime/run.js +7 -1
- package/dist/storage/file.js +57 -20
- package/dist/storage/memory.js +8 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +44 -3
- package/documentation/auth.md +7 -4
- package/examples/worker/README.md +4 -3
- package/examples/worker/src/d1-storage.ts +27 -5
- package/package.json +3 -3
- package/templates/node/package.json +2 -2
|
@@ -428,6 +428,19 @@ function waitForRefreshFlight(flight, signal) {
|
|
|
428
428
|
* operation, so a second isolate can still race the same refresh token.
|
|
429
429
|
*/
|
|
430
430
|
export class OAuthRefreshCoordinator {
|
|
431
|
+
retainPartition;
|
|
432
|
+
constructor(retainPartition = () => () => { }) {
|
|
433
|
+
this.retainPartition = retainPartition;
|
|
434
|
+
}
|
|
435
|
+
/** Keep an accepted credential write alive independently of its caller. */
|
|
436
|
+
retainWork() {
|
|
437
|
+
return this.retainPartition();
|
|
438
|
+
}
|
|
439
|
+
releaseFlightPartition(flight) {
|
|
440
|
+
if (flight.settled && !flight.answerPending && !flight.recoveryPending) {
|
|
441
|
+
flight.releasePartition();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
431
444
|
flights = new Map();
|
|
432
445
|
/** Opaque identities only: no request promise, signal, callback, or response. */
|
|
433
446
|
pendingMutations = new Map();
|
|
@@ -457,8 +470,10 @@ export class OAuthRefreshCoordinator {
|
|
|
457
470
|
if (this.flights.get(generation) !== flight)
|
|
458
471
|
return;
|
|
459
472
|
this.flights.delete(generation);
|
|
473
|
+
flight.settled = true;
|
|
460
474
|
this.advanceStateRevision();
|
|
461
475
|
flight.stopObservingOwnerAbort();
|
|
476
|
+
this.releaseFlightPartition(flight);
|
|
462
477
|
// Last, because joined fibers resume inside this call.
|
|
463
478
|
Deferred.doneUnsafe(flight.outcome, Effect.succeed(outcome));
|
|
464
479
|
}
|
|
@@ -582,7 +597,11 @@ export class OAuthRefreshCoordinator {
|
|
|
582
597
|
stopObservingOwnerAbort: () => { },
|
|
583
598
|
mutationId: {},
|
|
584
599
|
writing: false,
|
|
585
|
-
|
|
600
|
+
answerPending: true,
|
|
601
|
+
recoveryPending: false,
|
|
602
|
+
settled: false,
|
|
603
|
+
releasePartition: this.retainWork(),
|
|
604
|
+
persist: (tokens) => provider.saveAcceptedRefreshTokens(tokens, generation, flight),
|
|
586
605
|
};
|
|
587
606
|
this.flights.set(generation, flight);
|
|
588
607
|
this.advanceStateRevision();
|
|
@@ -628,7 +647,11 @@ export class OAuthRefreshCoordinator {
|
|
|
628
647
|
if (!this.markMutationPending(generation, flight)) {
|
|
629
648
|
// Already settled (the owner was cancelled first). Still write the
|
|
630
649
|
// rotation: losing it would leave a dead credential.
|
|
631
|
-
|
|
650
|
+
flight.recoveryPending = true;
|
|
651
|
+
detach(Effect.promise(() => flight.persist(accepted)).pipe(Effect.ensuring(Effect.sync(() => {
|
|
652
|
+
flight.recoveryPending = false;
|
|
653
|
+
this.releaseFlightPartition(flight);
|
|
654
|
+
}))));
|
|
632
655
|
return Effect.fail(new Error("OAuth refresh ended before tokens could be saved."));
|
|
633
656
|
}
|
|
634
657
|
// The SDK holds the answer from here, after this fiber is gone, so
|
|
@@ -639,6 +662,11 @@ export class OAuthRefreshCoordinator {
|
|
|
639
662
|
}
|
|
640
663
|
return Effect.succeed(response);
|
|
641
664
|
};
|
|
665
|
+
const finishAnswer = Effect.sync(() => {
|
|
666
|
+
flight.answerPending = false;
|
|
667
|
+
this.releaseFlightPartition(flight);
|
|
668
|
+
});
|
|
669
|
+
const commitAnswer = (value) => commit(value).pipe(Effect.ensuring(finishAnswer));
|
|
642
670
|
// The owner's redemption. Its abort interrupts the wait for the token
|
|
643
671
|
// endpoint and nothing after it: an answer, once it exists, is
|
|
644
672
|
// committed before anyone is released, so no caller is let go to
|
|
@@ -647,7 +675,7 @@ export class OAuthRefreshCoordinator {
|
|
|
647
675
|
// as the foreground would have.
|
|
648
676
|
const redemption = Effect.uninterruptibleMask((restore) => restore(Effect.tryPromise({ try: () => answer, catch: (error) => error })).pipe(Effect.onInterrupt(() => Effect.sync(() => {
|
|
649
677
|
this.fail(generation, flight, aborted(requestSignal));
|
|
650
|
-
detach(Effect.promise(() => answer).pipe(Effect.flatMap(
|
|
678
|
+
detach(Effect.promise(() => answer).pipe(Effect.flatMap(commitAnswer), Effect.ensuring(finishAnswer)));
|
|
651
679
|
})), Effect.catch((error) => {
|
|
652
680
|
// No answer at all — the network, or a body too large to be
|
|
653
681
|
// one — is an outage. A refusal connecta itself raised (a
|
|
@@ -661,9 +689,13 @@ export class OAuthRefreshCoordinator {
|
|
|
661
689
|
: undefined;
|
|
662
690
|
if (verdict)
|
|
663
691
|
provider.recordRefreshFailure(verdict);
|
|
692
|
+
flight.answerPending = false;
|
|
664
693
|
this.fail(generation, flight, error, verdict);
|
|
694
|
+
// Retirement may already have removed the flight from the map.
|
|
695
|
+
// Its rejected answer still finishes the retained partition work.
|
|
696
|
+
this.releaseFlightPartition(flight);
|
|
665
697
|
return Effect.fail(error);
|
|
666
|
-
}), Effect.flatMap(
|
|
698
|
+
}), Effect.flatMap(commitAnswer)));
|
|
667
699
|
return await runEdge(requestSignal
|
|
668
700
|
? Effect.raceAllFirst([
|
|
669
701
|
redemption,
|
|
@@ -712,6 +744,7 @@ export class OAuthRefreshCoordinator {
|
|
|
712
744
|
if (tokens !== undefined &&
|
|
713
745
|
this.pendingMutations.get(generation) === flight.mutationId) {
|
|
714
746
|
flight.writing = true;
|
|
747
|
+
flight.recoveryPending = true;
|
|
715
748
|
const written = (outcome) => {
|
|
716
749
|
if (this.finishMutation(generation, flight)) {
|
|
717
750
|
this.settle(generation, flight, outcome);
|
|
@@ -723,7 +756,10 @@ export class OAuthRefreshCoordinator {
|
|
|
723
756
|
}).pipe(Effect.match({
|
|
724
757
|
onSuccess: () => written({ status: "refreshed" }),
|
|
725
758
|
onFailure: (error) => written({ status: "failed", error }),
|
|
726
|
-
}))
|
|
759
|
+
}), Effect.ensuring(Effect.sync(() => {
|
|
760
|
+
flight.recoveryPending = false;
|
|
761
|
+
this.releaseFlightPartition(flight);
|
|
762
|
+
}))));
|
|
727
763
|
return;
|
|
728
764
|
}
|
|
729
765
|
// A response alone is not a write and must never strand the generation.
|
|
@@ -1001,7 +1037,7 @@ export class KvOAuthProvider {
|
|
|
1001
1037
|
* check-then-write race: a late old write cannot overwrite a replacement
|
|
1002
1038
|
* flow's value because the two writes have different physical keys.
|
|
1003
1039
|
*/
|
|
1004
|
-
async writeValue(key, value, serializeLegacy, issuer, writtenAt, binding) {
|
|
1040
|
+
async writeValue(key, value, serializeLegacy, issuer, writtenAt, binding, commitAcceptedRefresh = false) {
|
|
1005
1041
|
const generation = await this.writeGeneration();
|
|
1006
1042
|
await this.storeInGeneration(key, generation, () => {
|
|
1007
1043
|
const stored = {
|
|
@@ -1015,7 +1051,7 @@ export class KvOAuthProvider {
|
|
|
1015
1051
|
return isModernGeneration(generation) || issuer !== undefined
|
|
1016
1052
|
? JSON.stringify(stored)
|
|
1017
1053
|
: serializeLegacy(value);
|
|
1018
|
-
});
|
|
1054
|
+
}, undefined, commitAcceptedRefresh);
|
|
1019
1055
|
}
|
|
1020
1056
|
/**
|
|
1021
1057
|
* The generation fence every write goes through: refuse unless `generation`
|
|
@@ -1025,8 +1061,8 @@ export class KvOAuthProvider {
|
|
|
1025
1061
|
* still hold exactly that raw value, so re-encoding cannot resurrect a value
|
|
1026
1062
|
* another request replaced in the meantime.
|
|
1027
1063
|
*/
|
|
1028
|
-
async storeInGeneration(key, generation, serialize, expected) {
|
|
1029
|
-
if (this.signal?.aborted ||
|
|
1064
|
+
async storeInGeneration(key, generation, serialize, expected, commitAcceptedRefresh = false) {
|
|
1065
|
+
if ((!commitAcceptedRefresh && this.signal?.aborted) ||
|
|
1030
1066
|
generation.startsWith(RESETTING_GENERATION_PREFIX) ||
|
|
1031
1067
|
generation.startsWith(DISCONNECTED_GENERATION_PREFIX) ||
|
|
1032
1068
|
(await this.generation()) !== generation) {
|
|
@@ -1040,7 +1076,7 @@ export class KvOAuthProvider {
|
|
|
1040
1076
|
sealed: await this.sealer.seal(physicalKey, plaintext),
|
|
1041
1077
|
})
|
|
1042
1078
|
: plaintext;
|
|
1043
|
-
if (this.signal?.aborted)
|
|
1079
|
+
if (!commitAcceptedRefresh && this.signal?.aborted)
|
|
1044
1080
|
return;
|
|
1045
1081
|
if (expected === undefined) {
|
|
1046
1082
|
await this.storage.set(physicalKey, serialized);
|
|
@@ -1052,7 +1088,7 @@ export class KvOAuthProvider {
|
|
|
1052
1088
|
// before this set, remove the now-unreachable residue ourselves. The epoch
|
|
1053
1089
|
// key already provides correctness; this second check is physical hygiene.
|
|
1054
1090
|
const current = await this.generation();
|
|
1055
|
-
if (current !== generation || this.signal?.aborted) {
|
|
1091
|
+
if (current !== generation || (!commitAcceptedRefresh && this.signal?.aborted)) {
|
|
1056
1092
|
try {
|
|
1057
1093
|
// On cancellation the generation may still be active. A newer flow
|
|
1058
1094
|
// could have written this key while the old storage set was pending.
|
|
@@ -1216,12 +1252,26 @@ export class KvOAuthProvider {
|
|
|
1216
1252
|
* returning no credentials, so every isolate drops the old registration and
|
|
1217
1253
|
* token set and the SDK starts authorization from scratch.
|
|
1218
1254
|
*/
|
|
1219
|
-
async readIssuerBoundValue(key, parseLegacy,
|
|
1220
|
-
const
|
|
1221
|
-
if (!
|
|
1222
|
-
return
|
|
1255
|
+
async readIssuerBoundValue(key, parseLegacy, ctx) {
|
|
1256
|
+
const read = await this.readStoredValue(key, parseLegacy);
|
|
1257
|
+
if (!read)
|
|
1258
|
+
return undefined;
|
|
1259
|
+
const stored = read.stored;
|
|
1260
|
+
if (!ctx || stored.issuer !== undefined) {
|
|
1261
|
+
if (read.plaintextCredential) {
|
|
1262
|
+
await this.sealInPlace(key, stored.generation, read.raw);
|
|
1263
|
+
}
|
|
1264
|
+
if (!ctx)
|
|
1265
|
+
return stored.value;
|
|
1266
|
+
}
|
|
1223
1267
|
if (stored.issuer === undefined) {
|
|
1224
|
-
|
|
1268
|
+
// Binding is a migration of the value just read, never a new grant.
|
|
1269
|
+
// A rotation or revocation that replaced it must win this race.
|
|
1270
|
+
await this.storeInGeneration(key, stored.generation, () => JSON.stringify({
|
|
1271
|
+
connectaOAuthVersion: STORED_VALUE_VERSION,
|
|
1272
|
+
...stored,
|
|
1273
|
+
issuer: ctx.issuer,
|
|
1274
|
+
}), read.raw);
|
|
1225
1275
|
return stored.value;
|
|
1226
1276
|
}
|
|
1227
1277
|
if (stored.issuer === ctx.issuer)
|
|
@@ -1350,7 +1400,7 @@ export class KvOAuthProvider {
|
|
|
1350
1400
|
};
|
|
1351
1401
|
}
|
|
1352
1402
|
async clientInformation(ctx) {
|
|
1353
|
-
return this.readIssuerBoundValue("oauth:client", (raw) => JSON.parse(raw),
|
|
1403
|
+
return this.readIssuerBoundValue("oauth:client", (raw) => JSON.parse(raw), ctx);
|
|
1354
1404
|
}
|
|
1355
1405
|
async saveClientInformation(info, ctx) {
|
|
1356
1406
|
await this.writeValue("oauth:client", info, (value) => JSON.stringify(value), ctx?.issuer, undefined, this.clientBinding);
|
|
@@ -1366,7 +1416,7 @@ export class KvOAuthProvider {
|
|
|
1366
1416
|
const successIdentity = refreshGeneration !== undefined
|
|
1367
1417
|
? this.refreshCoordinator?.successfulRefreshIdentity(refreshGeneration)
|
|
1368
1418
|
: undefined;
|
|
1369
|
-
const tokens = await this.readIssuerBoundValue("oauth:tokens", (raw) => JSON.parse(raw),
|
|
1419
|
+
const tokens = await this.readIssuerBoundValue("oauth:tokens", (raw) => JSON.parse(raw), ctx);
|
|
1370
1420
|
if (ctx && tokens && refreshGeneration !== undefined) {
|
|
1371
1421
|
this.refreshBasis = {
|
|
1372
1422
|
accessToken: tokens.access_token,
|
|
@@ -1380,16 +1430,35 @@ export class KvOAuthProvider {
|
|
|
1380
1430
|
return tokens;
|
|
1381
1431
|
}
|
|
1382
1432
|
async saveTokens(tokens, ctx) {
|
|
1433
|
+
const owned = this.refreshFlight;
|
|
1434
|
+
if (owned?.flight.acceptedTokens !== undefined) {
|
|
1435
|
+
await this.persistAcceptedTokens(tokens, ctx, owned);
|
|
1436
|
+
}
|
|
1437
|
+
else {
|
|
1438
|
+
await this.persistTokens(tokens, ctx, false, owned);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
/** @internal Commit an already redeemed rotation even after its request leaves. */
|
|
1442
|
+
async saveAcceptedRefreshTokens(tokens, generation, flight) {
|
|
1443
|
+
await this.persistAcceptedTokens(tokens, undefined, { generation, flight });
|
|
1444
|
+
}
|
|
1445
|
+
persistAcceptedTokens(tokens, ctx, owned) {
|
|
1446
|
+
// The SDK can save the same response while cancellation recovery is
|
|
1447
|
+
// writing it. A second write could land after the next rotation, so both
|
|
1448
|
+
// paths await this exact flight's one commit, including its failure.
|
|
1449
|
+
return owned.flight.persistence ??= this.persistTokens(tokens, ctx, true, owned);
|
|
1450
|
+
}
|
|
1451
|
+
async persistTokens(tokens, ctx, commitAcceptedRefresh, owned) {
|
|
1452
|
+
const releasePartition = this.refreshCoordinator?.retainWork();
|
|
1383
1453
|
// A retired flight (the owner was cancelled or superseded after the
|
|
1384
1454
|
// token response) still writes: the authorization server has already
|
|
1385
1455
|
// consumed the old refresh token, so dropping the rotated one would leave a
|
|
1386
1456
|
// dead credential. writeValue itself refuses when the generation moved.
|
|
1387
1457
|
// Only the coordinator bookkeeping belongs to the exact live flight.
|
|
1388
|
-
const owned = this.refreshFlight;
|
|
1389
1458
|
const coordinated = owned !== undefined &&
|
|
1390
1459
|
this.refreshCoordinator?.beginMutation(owned.generation, owned.flight) === true;
|
|
1391
1460
|
try {
|
|
1392
|
-
await this.writeValue("oauth:tokens", tokens, (value) => JSON.stringify(value), ctx?.issuer);
|
|
1461
|
+
await this.writeValue("oauth:tokens", tokens, (value) => JSON.stringify(value), ctx?.issuer, undefined, undefined, commitAcceptedRefresh);
|
|
1393
1462
|
if (coordinated)
|
|
1394
1463
|
this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
|
|
1395
1464
|
this.refreshFailure = undefined;
|
|
@@ -1404,6 +1473,7 @@ export class KvOAuthProvider {
|
|
|
1404
1473
|
// already detached the provider's flight while storage was pending.
|
|
1405
1474
|
if (this.refreshFlight === owned)
|
|
1406
1475
|
this.refreshFlight = undefined;
|
|
1476
|
+
releasePartition?.();
|
|
1407
1477
|
}
|
|
1408
1478
|
}
|
|
1409
1479
|
/**
|
|
@@ -1602,22 +1672,11 @@ export class KvOAuthProvider {
|
|
|
1602
1672
|
// old physical namespace is unreadable. There is deliberately no second
|
|
1603
1673
|
// "finalize" write: concurrent resets therefore cannot overwrite a newer
|
|
1604
1674
|
// reset's epoch after their cleanup finishes out of order.
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
try {
|
|
1611
|
-
await this.deleteAll([
|
|
1612
|
-
cleanupBacklogKey(active),
|
|
1613
|
-
cleanupTimesKey(active),
|
|
1614
|
-
]);
|
|
1615
|
-
}
|
|
1616
|
-
catch {
|
|
1617
|
-
// Best-effort removal of a manifest for an epoch never activated.
|
|
1618
|
-
}
|
|
1619
|
-
throw error;
|
|
1620
|
-
}
|
|
1675
|
+
// A rejection may follow a committed fence. Keep its lineage even when
|
|
1676
|
+
// the answer is lost: the next reset must still find the retired grants.
|
|
1677
|
+
// A manifest for an epoch that never activated is harmless residue.
|
|
1678
|
+
await this.storage.set("oauth:generation", active);
|
|
1679
|
+
this.refreshCoordinator?.retire(previous);
|
|
1621
1680
|
if (reusableClient && !this.signal?.aborted) {
|
|
1622
1681
|
// readValue opened the old physical key; writeValue seals the plaintext
|
|
1623
1682
|
// under the new one. Copying ciphertext would fail its AAD check.
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Connector, ConnectorContext } from "./types.js";
|
|
2
|
+
export declare function claimConnectorScopeCleanup(ctx: ConnectorContext, connectorId: string): () => void;
|
|
3
|
+
export declare function connectorScopeCleanupClaimed(ctx: ConnectorContext, connectorId: string): boolean;
|
|
2
4
|
/** Runtime hook for work that may safely continue after a response is ready. */
|
|
3
5
|
export type DeferredWork = (promise: Promise<unknown>) => void;
|
|
4
6
|
/**
|
package/dist/connector-scope.js
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
import { closeScope } from "./runtime/connector-scope.js";
|
|
2
2
|
import { runEdge } from "./runtime/run.js";
|
|
3
|
+
// Internal ownership for credential probes invoked by a route. Context copies
|
|
4
|
+
// keep the requestScope, and each connector retains its own cleanup ownership.
|
|
5
|
+
// Standalone connector tests still close themselves.
|
|
6
|
+
const cleanupOwners = new WeakMap();
|
|
7
|
+
export function claimConnectorScopeCleanup(ctx, connectorId) {
|
|
8
|
+
const scope = ctx.requestScope ?? ctx;
|
|
9
|
+
let owners = cleanupOwners.get(scope);
|
|
10
|
+
if (!owners)
|
|
11
|
+
cleanupOwners.set(scope, owners = new Map());
|
|
12
|
+
owners.set(connectorId, (owners.get(connectorId) ?? 0) + 1);
|
|
13
|
+
let released = false;
|
|
14
|
+
return () => {
|
|
15
|
+
if (released)
|
|
16
|
+
return;
|
|
17
|
+
released = true;
|
|
18
|
+
const remaining = owners.get(connectorId) - 1;
|
|
19
|
+
if (remaining)
|
|
20
|
+
owners.set(connectorId, remaining);
|
|
21
|
+
else
|
|
22
|
+
owners.delete(connectorId);
|
|
23
|
+
if (!owners.size)
|
|
24
|
+
cleanupOwners.delete(scope);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function connectorScopeCleanupClaimed(ctx, connectorId) {
|
|
28
|
+
return cleanupOwners.get(ctx.requestScope ?? ctx)?.has(connectorId) === true;
|
|
29
|
+
}
|
|
3
30
|
/**
|
|
4
31
|
* Tell a connector that a scope owned by the core has ended.
|
|
5
32
|
*
|
package/dist/connectors/api.js
CHANGED
|
@@ -30,8 +30,14 @@ function checkToolContract(id, tool) {
|
|
|
30
30
|
}
|
|
31
31
|
/** A static connector; every tool passes {@link checkToolContract} first. */
|
|
32
32
|
export function api(id, opts) {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const names = new Set();
|
|
34
|
+
for (const tool of opts.tools) {
|
|
35
|
+
if (names.has(tool.name)) {
|
|
36
|
+
throw new Error(`api() tool "${id}.${tool.name}" is declared more than once; discovery and dispatch must use one definition.`);
|
|
37
|
+
}
|
|
38
|
+
names.add(tool.name);
|
|
39
|
+
checkToolContract(id, tool);
|
|
40
|
+
}
|
|
35
41
|
const defs = opts.tools.map((t) => ({
|
|
36
42
|
name: t.name,
|
|
37
43
|
description: t.description,
|
|
@@ -3,10 +3,12 @@ import { Deferred, Duration, Effect, Exit, Scope } from "effect";
|
|
|
3
3
|
import { KvOAuthProvider, OAuthRefreshCoordinator, } from "../auth/downstream-oauth.js";
|
|
4
4
|
import { trackOAuthStartReset } from "../auth/oauth-start-reset.js";
|
|
5
5
|
import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
|
|
6
|
+
import { connectorScopeCleanupClaimed } from "../connector-scope.js";
|
|
6
7
|
import { boundedEchoText, ConnectorCallError, msg, unavailableCallError, } from "../errors.js";
|
|
7
8
|
import { CONNECTA_VERSION } from "../version.js";
|
|
8
9
|
import { learnedUrlRefusal } from "../url-safety.js";
|
|
9
10
|
import { inheritOAuthSealer, oauthSealerFor } from "../oauth-sealing.js";
|
|
11
|
+
import { attachOAuthPartition, oauthPartitionFor, retainOAuthPartition } from "../oauth-partition.js";
|
|
10
12
|
import { detach, runEdge } from "../runtime/run.js";
|
|
11
13
|
/**
|
|
12
14
|
* Apply a maintained provider's slot copy and header framing to credential
|
|
@@ -426,7 +428,18 @@ export function remoteMcp(id, opts) {
|
|
|
426
428
|
const isOauth = opts.auth?.type === "oauth";
|
|
427
429
|
// Long-lived enough for distinct request scopes in this connector runtime to
|
|
428
430
|
// join one token redemption. It owns no client, transport, or request state.
|
|
429
|
-
const
|
|
431
|
+
const refreshCoordinators = new WeakMap();
|
|
432
|
+
const refreshCoordinatorFor = (ctx) => {
|
|
433
|
+
// Production contexts carry a stable registry partition. Hand-written
|
|
434
|
+
// contexts can coordinate by their shared storage object instead.
|
|
435
|
+
const partition = oauthPartitionFor(ctx) ?? ctx.storage;
|
|
436
|
+
let coordinator = refreshCoordinators.get(partition);
|
|
437
|
+
if (!coordinator) {
|
|
438
|
+
coordinator = new OAuthRefreshCoordinator(() => retainOAuthPartition(partition));
|
|
439
|
+
refreshCoordinators.set(partition, coordinator);
|
|
440
|
+
}
|
|
441
|
+
return coordinator;
|
|
442
|
+
};
|
|
430
443
|
const logger = opts.logger ?? console;
|
|
431
444
|
const credentialAuth = opts.auth?.type === "credential" ? opts.auth : undefined;
|
|
432
445
|
if (credentialAuth?.credential?.fields?.length) {
|
|
@@ -604,7 +617,7 @@ export function remoteMcp(id, opts) {
|
|
|
604
617
|
const newProvider = (ctx, state) => {
|
|
605
618
|
if (state?.provider)
|
|
606
619
|
return state.provider;
|
|
607
|
-
const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`,
|
|
620
|
+
const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`, refreshCoordinatorFor(ctx), ctx.allowAuthorization === true, oauthSealerFor(ctx), ctx.signal, JSON.stringify({
|
|
608
621
|
url: new URL(opts.url).href,
|
|
609
622
|
redirectUri: `${ctx.baseUrl}/oauth/callback/${id}`,
|
|
610
623
|
clientMetadata: {
|
|
@@ -639,7 +652,7 @@ export function remoteMcp(id, opts) {
|
|
|
639
652
|
const oauthProvider = provider ?? newProvider(ctx);
|
|
640
653
|
return new StreamableHTTPClientTransport(url, {
|
|
641
654
|
authProvider: oauthProvider,
|
|
642
|
-
fetch:
|
|
655
|
+
fetch: refreshCoordinatorFor(ctx).coordinatedFetch(oauthProvider, learnedUrlSafeFetch(id, url, guardedFetch), ctx.signal),
|
|
643
656
|
});
|
|
644
657
|
}
|
|
645
658
|
const headers = opts.auth?.type === "headers"
|
|
@@ -975,9 +988,10 @@ export function remoteMcp(id, opts) {
|
|
|
975
988
|
return { ok: false, message: msg(err) };
|
|
976
989
|
}
|
|
977
990
|
finally {
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
|
|
991
|
+
// Standalone tests own their scope; an operator route owns
|
|
992
|
+
// bounded teardown for the context it supplied to this hook.
|
|
993
|
+
if (!connectorScopeCleanupClaimed(ctx, id))
|
|
994
|
+
await connector.closeScope?.(ctx);
|
|
981
995
|
}
|
|
982
996
|
},
|
|
983
997
|
}
|
|
@@ -1198,17 +1212,23 @@ export function remoteMcp(id, opts) {
|
|
|
1198
1212
|
const leased = state.transport;
|
|
1199
1213
|
const t = (leased ??
|
|
1200
1214
|
buildTransport(ctx, provider));
|
|
1201
|
-
|
|
1202
|
-
|
|
1215
|
+
try {
|
|
1216
|
+
if (callbackParams !== undefined) {
|
|
1217
|
+
await t.finishAuth(callbackParams);
|
|
1218
|
+
}
|
|
1219
|
+
else {
|
|
1220
|
+
await t.finishAuth(code);
|
|
1221
|
+
}
|
|
1222
|
+
await provider.clearPending();
|
|
1223
|
+
// Reset so the next use reconnects with the freshly stored tokens.
|
|
1224
|
+
closeHalf(state);
|
|
1203
1225
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1226
|
+
finally {
|
|
1227
|
+
// This exchange-only transport has no lease in the request scope.
|
|
1228
|
+
// It must close even when redemption or pending-state cleanup fails.
|
|
1229
|
+
if (!leased)
|
|
1230
|
+
detach(closeConnection(null, t, ctx.logger));
|
|
1206
1231
|
}
|
|
1207
|
-
await provider.clearPending();
|
|
1208
|
-
// Reset so the next use reconnects with the freshly stored tokens.
|
|
1209
|
-
closeHalf(state);
|
|
1210
|
-
if (!leased)
|
|
1211
|
-
detach(closeConnection(null, t, ctx.logger));
|
|
1212
1232
|
},
|
|
1213
1233
|
};
|
|
1214
1234
|
if (opts.auth?.type === "oauth") {
|
|
@@ -1220,11 +1240,11 @@ export function remoteMcp(id, opts) {
|
|
|
1220
1240
|
await disconnectAuthorization(ctx, stateFor(ctx), true);
|
|
1221
1241
|
};
|
|
1222
1242
|
connector.startAuth = async (ctx, startOpts) => {
|
|
1223
|
-
ctx = inheritOAuthSealer(ctx, {
|
|
1243
|
+
ctx = attachOAuthPartition(inheritOAuthSealer(ctx, {
|
|
1224
1244
|
...ctx,
|
|
1225
1245
|
requestScope: ctx.requestScope ?? ctx,
|
|
1226
1246
|
allowAuthorization: true,
|
|
1227
|
-
});
|
|
1247
|
+
}), oauthPartitionFor(ctx));
|
|
1228
1248
|
const state = stateFor(ctx);
|
|
1229
1249
|
state.provider = null;
|
|
1230
1250
|
const p = newProvider(ctx, state);
|
|
@@ -1274,5 +1294,25 @@ export function remoteMcp(id, opts) {
|
|
|
1274
1294
|
}
|
|
1275
1295
|
};
|
|
1276
1296
|
}
|
|
1297
|
+
if (isOauth) {
|
|
1298
|
+
// Pin before the first asynchronous storage/discovery read, not only once
|
|
1299
|
+
// a refresh flight exists. These operations do not take call admission.
|
|
1300
|
+
const retain = (operation, contextIndex) => async (...args) => {
|
|
1301
|
+
const release = retainOAuthPartition(oauthPartitionFor(args[contextIndex]));
|
|
1302
|
+
try {
|
|
1303
|
+
return await operation(...args);
|
|
1304
|
+
}
|
|
1305
|
+
finally {
|
|
1306
|
+
release();
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
connector.listTools = retain(connector.listTools, 0);
|
|
1310
|
+
connector.callTool = retain(connector.callTool, 2);
|
|
1311
|
+
connector.status = retain(connector.status, 0);
|
|
1312
|
+
connector.startAuth = retain(connector.startAuth, 0);
|
|
1313
|
+
connector.disconnectAuth = retain(connector.disconnectAuth, 0);
|
|
1314
|
+
connector.verifyState = retain(connector.verifyState, 1);
|
|
1315
|
+
connector.finishAuth = retain(connector.finishAuth, 1);
|
|
1316
|
+
}
|
|
1277
1317
|
return connector;
|
|
1278
1318
|
}
|
package/dist/credentials.js
CHANGED
|
@@ -134,7 +134,8 @@ export class CredentialVault {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
async get(connectorId, field = "value", owner) {
|
|
137
|
-
|
|
137
|
+
const values = (await this.read(connectorId, owner))?.values;
|
|
138
|
+
return values && Object.hasOwn(values, field) ? values[field] : null;
|
|
138
139
|
}
|
|
139
140
|
async getAll(connectorId, owner) {
|
|
140
141
|
const credential = await this.read(connectorId, owner);
|
package/dist/execute.js
CHANGED
|
@@ -966,7 +966,22 @@ function failedRun(err, logger, { emitted, diagnostics }) {
|
|
|
966
966
|
}
|
|
967
967
|
/** The response for an ExecuteResult: the program's error or its value. */
|
|
968
968
|
function finishedRun(outcome, { emitted, diagnostics, invocationFailures }) {
|
|
969
|
+
if (outcome === null || typeof outcome !== "object" || Array.isArray(outcome)) {
|
|
970
|
+
return failureResponse("Executor failed: expected an ExecuteResult object.", {
|
|
971
|
+
emitted,
|
|
972
|
+
diagnostics,
|
|
973
|
+
code: "executor_failed",
|
|
974
|
+
});
|
|
975
|
+
}
|
|
969
976
|
const logs = executeLogs(outcome.logs);
|
|
977
|
+
if (outcome.error !== undefined && typeof outcome.error !== "string") {
|
|
978
|
+
return failureResponse("Executor failed: ExecuteResult.error must be a string.", {
|
|
979
|
+
logs,
|
|
980
|
+
emitted,
|
|
981
|
+
diagnostics,
|
|
982
|
+
code: "executor_failed",
|
|
983
|
+
});
|
|
984
|
+
}
|
|
970
985
|
if (outcome.error !== undefined) {
|
|
971
986
|
// Executor bridges necessarily reduce thrown host errors to strings.
|
|
972
987
|
// Match that terminal string back to the request-local typed failure so
|
package/dist/index.js
CHANGED
|
@@ -582,6 +582,7 @@ export function createConnecta(config) {
|
|
|
582
582
|
};
|
|
583
583
|
const refreshRunner = createProgramRunner(registry.scoped({ connectorIds: [] }), config.publicUrl, executor, logger, undefined, refreshConfig);
|
|
584
584
|
config.artifacts.bindRefresh({
|
|
585
|
+
...(config.ui?.branding ? { branding: config.ui.branding } : {}),
|
|
585
586
|
claimMs: refreshRunner.claimMs,
|
|
586
587
|
execute: async (program, owner, signal) => {
|
|
587
588
|
if (!owner)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Registry-owned identity for downstream credential coordination. Storage
|
|
2
|
+
// wrappers are rebuilt per context, and epochs can be identical across owners.
|
|
3
|
+
// Keep this beside the context rather than widening the public connector API.
|
|
4
|
+
const partitions = new WeakMap();
|
|
5
|
+
const activeWork = new WeakMap();
|
|
6
|
+
export function retainOAuthPartition(partition) {
|
|
7
|
+
if (!partition)
|
|
8
|
+
return () => { };
|
|
9
|
+
activeWork.set(partition, (activeWork.get(partition) ?? 0) + 1);
|
|
10
|
+
let released = false;
|
|
11
|
+
return () => {
|
|
12
|
+
if (released)
|
|
13
|
+
return;
|
|
14
|
+
released = true;
|
|
15
|
+
const remaining = (activeWork.get(partition) ?? 1) - 1;
|
|
16
|
+
if (remaining === 0)
|
|
17
|
+
activeWork.delete(partition);
|
|
18
|
+
else
|
|
19
|
+
activeWork.set(partition, remaining);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function oauthPartitionIdle(partition) {
|
|
23
|
+
return !activeWork.has(partition);
|
|
24
|
+
}
|
|
25
|
+
export function attachOAuthPartition(ctx, partition) {
|
|
26
|
+
if (partition)
|
|
27
|
+
partitions.set(ctx, partition);
|
|
28
|
+
return ctx;
|
|
29
|
+
}
|
|
30
|
+
export function oauthPartitionFor(ctx) {
|
|
31
|
+
return partitions.get(ctx);
|
|
32
|
+
}
|