@zackbart/connecta 0.26.2 → 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 +86 -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 +104 -37
- 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.d.ts +4 -0
- package/dist/connectors/remote-mcp.js +76 -18
- 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 +30 -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.
|
|
@@ -850,6 +886,8 @@ export class KvOAuthProvider {
|
|
|
850
886
|
signal;
|
|
851
887
|
clientBinding;
|
|
852
888
|
onReset;
|
|
889
|
+
scope;
|
|
890
|
+
clientMetadataUrl;
|
|
853
891
|
/**
|
|
854
892
|
* The reset generation this provider's flow started under. Every OAuth value
|
|
855
893
|
* it writes carries this epoch, so a late write can land after a reset without
|
|
@@ -869,7 +907,9 @@ export class KvOAuthProvider {
|
|
|
869
907
|
/** Stable connector configuration bound to dynamically registered clients. */
|
|
870
908
|
clientBinding,
|
|
871
909
|
/** Request-local observer so the operator route drains every reset it began. */
|
|
872
|
-
onReset
|
|
910
|
+
onReset,
|
|
911
|
+
/** SDK uses this only when the authorization server advertises support. */
|
|
912
|
+
clientMetadataUrl, scope) {
|
|
873
913
|
this.connectorId = connectorId;
|
|
874
914
|
this.storage = storage;
|
|
875
915
|
this.redirectUri = redirectUri;
|
|
@@ -879,6 +919,9 @@ export class KvOAuthProvider {
|
|
|
879
919
|
this.signal = signal;
|
|
880
920
|
this.clientBinding = clientBinding;
|
|
881
921
|
this.onReset = onReset;
|
|
922
|
+
this.scope = scope;
|
|
923
|
+
if (clientMetadataUrl !== undefined)
|
|
924
|
+
this.clientMetadataUrl = clientMetadataUrl;
|
|
882
925
|
}
|
|
883
926
|
/**
|
|
884
927
|
* Stamp the force-reauth generation the current connect flow started under.
|
|
@@ -994,7 +1037,7 @@ export class KvOAuthProvider {
|
|
|
994
1037
|
* check-then-write race: a late old write cannot overwrite a replacement
|
|
995
1038
|
* flow's value because the two writes have different physical keys.
|
|
996
1039
|
*/
|
|
997
|
-
async writeValue(key, value, serializeLegacy, issuer, writtenAt, binding) {
|
|
1040
|
+
async writeValue(key, value, serializeLegacy, issuer, writtenAt, binding, commitAcceptedRefresh = false) {
|
|
998
1041
|
const generation = await this.writeGeneration();
|
|
999
1042
|
await this.storeInGeneration(key, generation, () => {
|
|
1000
1043
|
const stored = {
|
|
@@ -1008,7 +1051,7 @@ export class KvOAuthProvider {
|
|
|
1008
1051
|
return isModernGeneration(generation) || issuer !== undefined
|
|
1009
1052
|
? JSON.stringify(stored)
|
|
1010
1053
|
: serializeLegacy(value);
|
|
1011
|
-
});
|
|
1054
|
+
}, undefined, commitAcceptedRefresh);
|
|
1012
1055
|
}
|
|
1013
1056
|
/**
|
|
1014
1057
|
* The generation fence every write goes through: refuse unless `generation`
|
|
@@ -1018,8 +1061,8 @@ export class KvOAuthProvider {
|
|
|
1018
1061
|
* still hold exactly that raw value, so re-encoding cannot resurrect a value
|
|
1019
1062
|
* another request replaced in the meantime.
|
|
1020
1063
|
*/
|
|
1021
|
-
async storeInGeneration(key, generation, serialize, expected) {
|
|
1022
|
-
if (this.signal?.aborted ||
|
|
1064
|
+
async storeInGeneration(key, generation, serialize, expected, commitAcceptedRefresh = false) {
|
|
1065
|
+
if ((!commitAcceptedRefresh && this.signal?.aborted) ||
|
|
1023
1066
|
generation.startsWith(RESETTING_GENERATION_PREFIX) ||
|
|
1024
1067
|
generation.startsWith(DISCONNECTED_GENERATION_PREFIX) ||
|
|
1025
1068
|
(await this.generation()) !== generation) {
|
|
@@ -1033,7 +1076,7 @@ export class KvOAuthProvider {
|
|
|
1033
1076
|
sealed: await this.sealer.seal(physicalKey, plaintext),
|
|
1034
1077
|
})
|
|
1035
1078
|
: plaintext;
|
|
1036
|
-
if (this.signal?.aborted)
|
|
1079
|
+
if (!commitAcceptedRefresh && this.signal?.aborted)
|
|
1037
1080
|
return;
|
|
1038
1081
|
if (expected === undefined) {
|
|
1039
1082
|
await this.storage.set(physicalKey, serialized);
|
|
@@ -1045,7 +1088,7 @@ export class KvOAuthProvider {
|
|
|
1045
1088
|
// before this set, remove the now-unreachable residue ourselves. The epoch
|
|
1046
1089
|
// key already provides correctness; this second check is physical hygiene.
|
|
1047
1090
|
const current = await this.generation();
|
|
1048
|
-
if (current !== generation || this.signal?.aborted) {
|
|
1091
|
+
if (current !== generation || (!commitAcceptedRefresh && this.signal?.aborted)) {
|
|
1049
1092
|
try {
|
|
1050
1093
|
// On cancellation the generation may still be active. A newer flow
|
|
1051
1094
|
// could have written this key while the old storage set was pending.
|
|
@@ -1209,12 +1252,26 @@ export class KvOAuthProvider {
|
|
|
1209
1252
|
* returning no credentials, so every isolate drops the old registration and
|
|
1210
1253
|
* token set and the SDK starts authorization from scratch.
|
|
1211
1254
|
*/
|
|
1212
|
-
async readIssuerBoundValue(key, parseLegacy,
|
|
1213
|
-
const
|
|
1214
|
-
if (!
|
|
1215
|
-
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
|
+
}
|
|
1216
1267
|
if (stored.issuer === undefined) {
|
|
1217
|
-
|
|
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);
|
|
1218
1275
|
return stored.value;
|
|
1219
1276
|
}
|
|
1220
1277
|
if (stored.issuer === ctx.issuer)
|
|
@@ -1339,10 +1396,11 @@ export class KvOAuthProvider {
|
|
|
1339
1396
|
grant_types: ["authorization_code", "refresh_token"],
|
|
1340
1397
|
response_types: ["code"],
|
|
1341
1398
|
token_endpoint_auth_method: "none",
|
|
1399
|
+
...(this.scope !== undefined ? { scope: this.scope } : {}),
|
|
1342
1400
|
};
|
|
1343
1401
|
}
|
|
1344
1402
|
async clientInformation(ctx) {
|
|
1345
|
-
return this.readIssuerBoundValue("oauth:client", (raw) => JSON.parse(raw),
|
|
1403
|
+
return this.readIssuerBoundValue("oauth:client", (raw) => JSON.parse(raw), ctx);
|
|
1346
1404
|
}
|
|
1347
1405
|
async saveClientInformation(info, ctx) {
|
|
1348
1406
|
await this.writeValue("oauth:client", info, (value) => JSON.stringify(value), ctx?.issuer, undefined, this.clientBinding);
|
|
@@ -1358,7 +1416,7 @@ export class KvOAuthProvider {
|
|
|
1358
1416
|
const successIdentity = refreshGeneration !== undefined
|
|
1359
1417
|
? this.refreshCoordinator?.successfulRefreshIdentity(refreshGeneration)
|
|
1360
1418
|
: undefined;
|
|
1361
|
-
const tokens = await this.readIssuerBoundValue("oauth:tokens", (raw) => JSON.parse(raw),
|
|
1419
|
+
const tokens = await this.readIssuerBoundValue("oauth:tokens", (raw) => JSON.parse(raw), ctx);
|
|
1362
1420
|
if (ctx && tokens && refreshGeneration !== undefined) {
|
|
1363
1421
|
this.refreshBasis = {
|
|
1364
1422
|
accessToken: tokens.access_token,
|
|
@@ -1372,16 +1430,35 @@ export class KvOAuthProvider {
|
|
|
1372
1430
|
return tokens;
|
|
1373
1431
|
}
|
|
1374
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();
|
|
1375
1453
|
// A retired flight (the owner was cancelled or superseded after the
|
|
1376
1454
|
// token response) still writes: the authorization server has already
|
|
1377
1455
|
// consumed the old refresh token, so dropping the rotated one would leave a
|
|
1378
1456
|
// dead credential. writeValue itself refuses when the generation moved.
|
|
1379
1457
|
// Only the coordinator bookkeeping belongs to the exact live flight.
|
|
1380
|
-
const owned = this.refreshFlight;
|
|
1381
1458
|
const coordinated = owned !== undefined &&
|
|
1382
1459
|
this.refreshCoordinator?.beginMutation(owned.generation, owned.flight) === true;
|
|
1383
1460
|
try {
|
|
1384
|
-
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);
|
|
1385
1462
|
if (coordinated)
|
|
1386
1463
|
this.refreshCoordinator?.succeedMutation(owned.generation, owned.flight);
|
|
1387
1464
|
this.refreshFailure = undefined;
|
|
@@ -1396,6 +1473,7 @@ export class KvOAuthProvider {
|
|
|
1396
1473
|
// already detached the provider's flight while storage was pending.
|
|
1397
1474
|
if (this.refreshFlight === owned)
|
|
1398
1475
|
this.refreshFlight = undefined;
|
|
1476
|
+
releasePartition?.();
|
|
1399
1477
|
}
|
|
1400
1478
|
}
|
|
1401
1479
|
/**
|
|
@@ -1594,22 +1672,11 @@ export class KvOAuthProvider {
|
|
|
1594
1672
|
// old physical namespace is unreadable. There is deliberately no second
|
|
1595
1673
|
// "finalize" write: concurrent resets therefore cannot overwrite a newer
|
|
1596
1674
|
// reset's epoch after their cleanup finishes out of order.
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
try {
|
|
1603
|
-
await this.deleteAll([
|
|
1604
|
-
cleanupBacklogKey(active),
|
|
1605
|
-
cleanupTimesKey(active),
|
|
1606
|
-
]);
|
|
1607
|
-
}
|
|
1608
|
-
catch {
|
|
1609
|
-
// Best-effort removal of a manifest for an epoch never activated.
|
|
1610
|
-
}
|
|
1611
|
-
throw error;
|
|
1612
|
-
}
|
|
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);
|
|
1613
1680
|
if (reusableClient && !this.signal?.aborted) {
|
|
1614
1681
|
// readValue opened the old physical key; writeValue seals the plaintext
|
|
1615
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,
|
|
@@ -38,6 +38,10 @@ export type RemoteMcpAuth = {
|
|
|
38
38
|
headers: Record<string, string>;
|
|
39
39
|
} | RemoteMcpCredentialAuth | {
|
|
40
40
|
type: "oauth";
|
|
41
|
+
/** Public HTTPS client metadata document, for servers supporting URL-based client IDs. */
|
|
42
|
+
clientMetadataUrl?: string;
|
|
43
|
+
/** Space-separated default scopes when the resource server does not advertise them. */
|
|
44
|
+
scope?: string;
|
|
41
45
|
};
|
|
42
46
|
/**
|
|
43
47
|
* Apply a maintained provider's slot copy and header framing to credential
|
|
@@ -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
|
|
@@ -396,6 +398,22 @@ function learnedUrlSafeFetch(connectorId, configured, baseFetch) {
|
|
|
396
398
|
* server or hide other connectors).
|
|
397
399
|
*/
|
|
398
400
|
export function remoteMcp(id, opts) {
|
|
401
|
+
const clientMetadataUrl = opts.auth?.type === "oauth" ? opts.auth.clientMetadataUrl : undefined;
|
|
402
|
+
const oauthScope = opts.auth?.type === "oauth" ? opts.auth.scope : undefined;
|
|
403
|
+
if (oauthScope !== undefined && !/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/.test(oauthScope)) {
|
|
404
|
+
throw new Error(`[connecta] connector "${id}" OAuth scope must contain space-separated scope tokens.`);
|
|
405
|
+
}
|
|
406
|
+
if (clientMetadataUrl !== undefined) {
|
|
407
|
+
let valid = false;
|
|
408
|
+
try {
|
|
409
|
+
const url = new URL(clientMetadataUrl);
|
|
410
|
+
valid = url.protocol === "https:" && url.pathname !== "/" &&
|
|
411
|
+
!url.username && !url.password && !url.hash;
|
|
412
|
+
}
|
|
413
|
+
catch { /* Invalid configuration is rejected below without echoing its value. */ }
|
|
414
|
+
if (!valid)
|
|
415
|
+
throw new Error(`[connecta] connector "${id}" clientMetadataUrl must be an HTTPS URL with a non-root path, without credentials or a fragment.`);
|
|
416
|
+
}
|
|
399
417
|
if (opts.authScope === "personal" && opts.auth?.type === "headers") {
|
|
400
418
|
throw new Error(`[connecta] connector "${id}" cannot combine authScope "personal" ` +
|
|
401
419
|
"with static headers. Use credential or OAuth auth so each principal " +
|
|
@@ -410,7 +428,18 @@ export function remoteMcp(id, opts) {
|
|
|
410
428
|
const isOauth = opts.auth?.type === "oauth";
|
|
411
429
|
// Long-lived enough for distinct request scopes in this connector runtime to
|
|
412
430
|
// join one token redemption. It owns no client, transport, or request state.
|
|
413
|
-
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
|
+
};
|
|
414
443
|
const logger = opts.logger ?? console;
|
|
415
444
|
const credentialAuth = opts.auth?.type === "credential" ? opts.auth : undefined;
|
|
416
445
|
if (credentialAuth?.credential?.fields?.length) {
|
|
@@ -588,7 +617,7 @@ export function remoteMcp(id, opts) {
|
|
|
588
617
|
const newProvider = (ctx, state) => {
|
|
589
618
|
if (state?.provider)
|
|
590
619
|
return state.provider;
|
|
591
|
-
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({
|
|
592
621
|
url: new URL(opts.url).href,
|
|
593
622
|
redirectUri: `${ctx.baseUrl}/oauth/callback/${id}`,
|
|
594
623
|
clientMetadata: {
|
|
@@ -597,11 +626,13 @@ export function remoteMcp(id, opts) {
|
|
|
597
626
|
grant_types: ["authorization_code", "refresh_token"],
|
|
598
627
|
response_types: ["code"],
|
|
599
628
|
token_endpoint_auth_method: "none",
|
|
629
|
+
...(oauthScope !== undefined ? { scope: oauthScope } : {}),
|
|
600
630
|
},
|
|
601
631
|
authScope: opts.authScope ?? "shared",
|
|
602
632
|
versionNegotiation: opts.versionNegotiation ?? "auto",
|
|
603
633
|
redirects: opts.redirects ?? "none",
|
|
604
|
-
|
|
634
|
+
clientMetadataUrl,
|
|
635
|
+
}), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset), clientMetadataUrl, oauthScope);
|
|
605
636
|
if (state)
|
|
606
637
|
state.provider = provider;
|
|
607
638
|
return provider;
|
|
@@ -621,7 +652,7 @@ export function remoteMcp(id, opts) {
|
|
|
621
652
|
const oauthProvider = provider ?? newProvider(ctx);
|
|
622
653
|
return new StreamableHTTPClientTransport(url, {
|
|
623
654
|
authProvider: oauthProvider,
|
|
624
|
-
fetch:
|
|
655
|
+
fetch: refreshCoordinatorFor(ctx).coordinatedFetch(oauthProvider, learnedUrlSafeFetch(id, url, guardedFetch), ctx.signal),
|
|
625
656
|
});
|
|
626
657
|
}
|
|
627
658
|
const headers = opts.auth?.type === "headers"
|
|
@@ -957,9 +988,10 @@ export function remoteMcp(id, opts) {
|
|
|
957
988
|
return { ok: false, message: msg(err) };
|
|
958
989
|
}
|
|
959
990
|
finally {
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
|
|
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);
|
|
963
995
|
}
|
|
964
996
|
},
|
|
965
997
|
}
|
|
@@ -1180,17 +1212,23 @@ export function remoteMcp(id, opts) {
|
|
|
1180
1212
|
const leased = state.transport;
|
|
1181
1213
|
const t = (leased ??
|
|
1182
1214
|
buildTransport(ctx, provider));
|
|
1183
|
-
|
|
1184
|
-
|
|
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);
|
|
1185
1225
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
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));
|
|
1188
1231
|
}
|
|
1189
|
-
await provider.clearPending();
|
|
1190
|
-
// Reset so the next use reconnects with the freshly stored tokens.
|
|
1191
|
-
closeHalf(state);
|
|
1192
|
-
if (!leased)
|
|
1193
|
-
detach(closeConnection(null, t, ctx.logger));
|
|
1194
1232
|
},
|
|
1195
1233
|
};
|
|
1196
1234
|
if (opts.auth?.type === "oauth") {
|
|
@@ -1202,11 +1240,11 @@ export function remoteMcp(id, opts) {
|
|
|
1202
1240
|
await disconnectAuthorization(ctx, stateFor(ctx), true);
|
|
1203
1241
|
};
|
|
1204
1242
|
connector.startAuth = async (ctx, startOpts) => {
|
|
1205
|
-
ctx = inheritOAuthSealer(ctx, {
|
|
1243
|
+
ctx = attachOAuthPartition(inheritOAuthSealer(ctx, {
|
|
1206
1244
|
...ctx,
|
|
1207
1245
|
requestScope: ctx.requestScope ?? ctx,
|
|
1208
1246
|
allowAuthorization: true,
|
|
1209
|
-
});
|
|
1247
|
+
}), oauthPartitionFor(ctx));
|
|
1210
1248
|
const state = stateFor(ctx);
|
|
1211
1249
|
state.provider = null;
|
|
1212
1250
|
const p = newProvider(ctx, state);
|
|
@@ -1256,5 +1294,25 @@ export function remoteMcp(id, opts) {
|
|
|
1256
1294
|
}
|
|
1257
1295
|
};
|
|
1258
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
|
+
}
|
|
1259
1317
|
return connector;
|
|
1260
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
|
+
}
|