@zackbart/connecta 0.27.0 → 0.28.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/AGENTS.md +6 -6
- package/CHANGELOG.md +229 -0
- package/README.md +23 -24
- package/bin/connecta.mjs +1 -5
- package/dist/activity.d.ts +5 -4
- package/dist/activity.js +1 -10
- package/dist/artifacts/connector.js +1 -1
- package/dist/artifacts/json.js +10 -0
- package/dist/artifacts/operations.js +1 -1
- package/dist/artifacts/validate.js +1 -2
- package/dist/auth/downstream-oauth.js +147 -28
- package/dist/auth/static-oauth.js +504 -0
- package/dist/catalog-service.js +1 -1
- package/dist/connectors/api-connector.d.ts +168 -0
- package/dist/connectors/api-connector.js +127 -0
- package/dist/connectors/api.d.ts +8 -71
- package/dist/connectors/api.js +10 -99
- package/dist/connectors/guarded-fetch.js +37 -3
- package/dist/connectors/remote-mcp.js +7 -32
- package/dist/execute.js +92 -368
- package/dist/executors/quickjs.js +1 -1
- package/dist/exempt-writes.js +162 -0
- package/dist/index.d.ts +13 -32
- package/dist/index.js +14 -47
- package/dist/invocation.js +6 -19
- package/dist/meta-tools.js +1 -2
- package/dist/oauth-partition.js +17 -0
- package/dist/operator-ui/generated.js +1 -1
- package/dist/operator-ui/model.d.ts +3 -3
- package/dist/operator-ui/view.js +6 -4
- package/dist/providers/basecamp.d.ts +65 -0
- package/dist/providers/basecamp.js +445 -0
- package/dist/providers/breeze.d.ts +34 -0
- package/dist/providers/breeze.js +1402 -0
- package/dist/providers/ccb.d.ts +103 -0
- package/dist/providers/ccb.js +1570 -0
- package/dist/providers/cloudflare.js +1 -1
- package/dist/providers/linear.js +1 -1
- package/dist/providers/mixpanel.js +1 -1
- package/dist/providers/notion.js +1 -1
- package/dist/providers/overflow.d.ts +60 -0
- package/dist/providers/overflow.js +1572 -0
- package/dist/providers/planning-center.d.ts +59 -0
- package/dist/providers/planning-center.js +2039 -0
- package/dist/providers/revenuecat.js +1 -1
- package/dist/providers/stripe.js +1 -1
- package/dist/providers/tithely.d.ts +49 -0
- package/dist/providers/tithely.js +1034 -0
- package/dist/providers/vercel.js +1 -1
- package/dist/routes/mcp.js +5 -21
- package/dist/routes/oauth.js +29 -3
- package/dist/routes/shared.d.ts +1 -6
- package/dist/server.js +0 -4
- package/dist/skills.js +8 -23
- package/dist/tool-safety.d.ts +1 -1
- package/dist/tool-safety.js +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +15 -17
- package/documentation/auth.md +120 -6
- package/documentation/code-mode.md +91 -194
- package/documentation/meta-tools.md +38 -41
- package/ethos.md +8 -9
- package/examples/worker/README.md +10 -14
- package/examples/worker/src/d1-activity-row.ts +4 -1
- package/examples/worker/src/index.ts +1 -9
- package/package.json +26 -2
- package/templates/node/AGENTS.md +4 -3
- package/templates/node/README.md +3 -4
- package/templates/node/package.json +1 -1
- package/dist/resumable.js +0 -1323
- package/dist/run-journal.js +0 -347
|
@@ -2,7 +2,30 @@ import { UnauthorizedError } from "@modelcontextprotocol/client";
|
|
|
2
2
|
import { Deferred, Effect } from "effect";
|
|
3
3
|
import { retryAfterMs } from "../connectors/guarded-fetch.js";
|
|
4
4
|
import { ConnectorCallError } from "../errors.js";
|
|
5
|
+
import { attachOAuthPartition, oauthPartitionFor, retainOAuthPartition, } from "../oauth-partition.js";
|
|
6
|
+
import { inheritOAuthSealer } from "../oauth-sealing.js";
|
|
5
7
|
import { detach, fromSignal, runEdge } from "../runtime/run.js";
|
|
8
|
+
/** RFC 6749 section 3.3: one or more scope tokens, separated by single spaces. */
|
|
9
|
+
const OAUTH_SCOPE = /^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/;
|
|
10
|
+
/** Refuse, at construction, a configured scope no authorization server could parse. */
|
|
11
|
+
export function assertOAuthScope(connectorId, scope) {
|
|
12
|
+
if (scope !== undefined && !OAUTH_SCOPE.test(scope)) {
|
|
13
|
+
throw new Error(`[connecta] connector "${connectorId}" OAuth scope must contain space-separated scope tokens.`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The context an explicit authorization start runs under: allowed to begin
|
|
18
|
+
* consent, with a request scope of its own, carrying the registry's sealer and
|
|
19
|
+
* owner partition across the copy. Only `startAuth` builds one; status reads
|
|
20
|
+
* and calls never do, which is what keeps them from starting authorization.
|
|
21
|
+
*/
|
|
22
|
+
export function authorizingContext(ctx) {
|
|
23
|
+
return attachOAuthPartition(inheritOAuthSealer(ctx, {
|
|
24
|
+
...ctx,
|
|
25
|
+
requestScope: ctx.requestScope ?? ctx,
|
|
26
|
+
allowAuthorization: true,
|
|
27
|
+
}), oauthPartitionFor(ctx));
|
|
28
|
+
}
|
|
6
29
|
/** A 256-bit random opaque value, hex-encoded — used for the OAuth `state`. */
|
|
7
30
|
function randomState() {
|
|
8
31
|
const bytes = new Uint8Array(32);
|
|
@@ -18,7 +41,7 @@ function timingSafeEqual(a, b) {
|
|
|
18
41
|
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
19
42
|
return diff === 0;
|
|
20
43
|
}
|
|
21
|
-
const LEGACY_GENERATION = "legacy";
|
|
44
|
+
export const LEGACY_GENERATION = "legacy";
|
|
22
45
|
const ACTIVE_GENERATION_PREFIX = "v2:";
|
|
23
46
|
const RESETTING_GENERATION_PREFIX = "reset:";
|
|
24
47
|
const DISCONNECTED_GENERATION_PREFIX = "disconnected:";
|
|
@@ -592,6 +615,9 @@ export class OAuthRefreshCoordinator {
|
|
|
592
615
|
// reaches the token endpoint.
|
|
593
616
|
if (requestSignal?.aborted)
|
|
594
617
|
throw aborted(requestSignal);
|
|
618
|
+
// The authorization server this redemption answers to, fixed now: a
|
|
619
|
+
// recovery save outlives the SDK flow that would have stamped it.
|
|
620
|
+
const issuer = provider.refreshIssuer(generation);
|
|
595
621
|
const flight = {
|
|
596
622
|
outcome: Deferred.makeUnsafe(),
|
|
597
623
|
stopObservingOwnerAbort: () => { },
|
|
@@ -601,7 +627,7 @@ export class OAuthRefreshCoordinator {
|
|
|
601
627
|
recoveryPending: false,
|
|
602
628
|
settled: false,
|
|
603
629
|
releasePartition: this.retainWork(),
|
|
604
|
-
persist: (tokens) => provider.saveAcceptedRefreshTokens(tokens, generation, flight),
|
|
630
|
+
persist: (tokens) => provider.saveAcceptedRefreshTokens(tokens, generation, flight, issuer),
|
|
605
631
|
};
|
|
606
632
|
this.flights.set(generation, flight);
|
|
607
633
|
this.advanceStateRevision();
|
|
@@ -790,6 +816,25 @@ export class OAuthRefreshCoordinator {
|
|
|
790
816
|
this.settle(generation, flight, { status: "retired" });
|
|
791
817
|
}
|
|
792
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* One refresh coordinator per owner partition, for one connector runtime.
|
|
821
|
+
* Long-lived enough for distinct request scopes to join one token redemption;
|
|
822
|
+
* it owns no client, transport, or request state. Production contexts carry a
|
|
823
|
+
* stable registry partition; hand-written contexts coordinate by their shared
|
|
824
|
+
* storage object instead.
|
|
825
|
+
*/
|
|
826
|
+
export function refreshCoordinatorsByPartition() {
|
|
827
|
+
const coordinators = new WeakMap();
|
|
828
|
+
return (ctx) => {
|
|
829
|
+
const partition = oauthPartitionFor(ctx) ?? ctx.storage;
|
|
830
|
+
let coordinator = coordinators.get(partition);
|
|
831
|
+
if (!coordinator) {
|
|
832
|
+
coordinator = new OAuthRefreshCoordinator(() => retainOAuthPartition(partition));
|
|
833
|
+
coordinators.set(partition, coordinator);
|
|
834
|
+
}
|
|
835
|
+
return coordinator;
|
|
836
|
+
};
|
|
837
|
+
}
|
|
793
838
|
function storedOAuthValue(value) {
|
|
794
839
|
if (!value || typeof value !== "object")
|
|
795
840
|
return false;
|
|
@@ -798,6 +843,7 @@ function storedOAuthValue(value) {
|
|
|
798
843
|
typeof candidate.generation === "string" &&
|
|
799
844
|
(candidate.issuer === undefined || typeof candidate.issuer === "string") &&
|
|
800
845
|
(candidate.binding === undefined || typeof candidate.binding === "string") &&
|
|
846
|
+
(candidate.carried === undefined || candidate.carried === true) &&
|
|
801
847
|
"value" in candidate);
|
|
802
848
|
}
|
|
803
849
|
function legacyStoredOAuthValue(value) {
|
|
@@ -1012,6 +1058,11 @@ export class KvOAuthProvider {
|
|
|
1012
1058
|
this.refreshCoordinator?.fail(owned.generation, owned.flight, error);
|
|
1013
1059
|
}
|
|
1014
1060
|
}
|
|
1061
|
+
/** @internal The issuer this flow's issuer-aware read bound its refresh to. */
|
|
1062
|
+
refreshIssuer(generation) {
|
|
1063
|
+
const basis = this.refreshBasis;
|
|
1064
|
+
return basis?.generation === generation ? basis.issuer : undefined;
|
|
1065
|
+
}
|
|
1015
1066
|
/** True when another request saved a refresh result after this flow's read. */
|
|
1016
1067
|
refreshBasisChanged(current, generation) {
|
|
1017
1068
|
const basis = this.refreshBasis;
|
|
@@ -1226,6 +1277,7 @@ export class KvOAuthProvider {
|
|
|
1226
1277
|
generation,
|
|
1227
1278
|
...(parsed.issuer !== undefined ? { issuer: parsed.issuer } : {}),
|
|
1228
1279
|
...(parsed.binding !== undefined ? { binding: parsed.binding } : {}),
|
|
1280
|
+
...(parsed.carried === true ? { carried: true } : {}),
|
|
1229
1281
|
// Only a finite number is a time; anything else reads as untimed.
|
|
1230
1282
|
...(typeof parsed.writtenAt === "number" &&
|
|
1231
1283
|
Number.isFinite(parsed.writtenAt)
|
|
@@ -1421,6 +1473,7 @@ export class KvOAuthProvider {
|
|
|
1421
1473
|
this.refreshBasis = {
|
|
1422
1474
|
accessToken: tokens.access_token,
|
|
1423
1475
|
generation: refreshGeneration,
|
|
1476
|
+
issuer: ctx.issuer,
|
|
1424
1477
|
...(tokens.refresh_token !== undefined
|
|
1425
1478
|
? { refreshToken: tokens.refresh_token }
|
|
1426
1479
|
: {}),
|
|
@@ -1438,9 +1491,16 @@ export class KvOAuthProvider {
|
|
|
1438
1491
|
await this.persistTokens(tokens, ctx, false, owned);
|
|
1439
1492
|
}
|
|
1440
1493
|
}
|
|
1441
|
-
/**
|
|
1442
|
-
|
|
1443
|
-
|
|
1494
|
+
/**
|
|
1495
|
+
* @internal Commit an already redeemed rotation even after its request
|
|
1496
|
+
* leaves. A token response names no issuer, so the one the refreshed grant
|
|
1497
|
+
* was bound to is stamped here as the SDK's own save would: an unbound
|
|
1498
|
+
* rotation would read as legacy state and bind to whichever authorization
|
|
1499
|
+
* server the connector names next.
|
|
1500
|
+
*/
|
|
1501
|
+
async saveAcceptedRefreshTokens(tokens, generation, flight, issuer) {
|
|
1502
|
+
const stamped = issuer !== undefined ? { ...tokens, issuer } : tokens;
|
|
1503
|
+
await this.persistAcceptedTokens(stamped, issuer !== undefined ? { issuer } : undefined, { generation, flight });
|
|
1444
1504
|
}
|
|
1445
1505
|
persistAcceptedTokens(tokens, ctx, owned) {
|
|
1446
1506
|
// The SDK can save the same response while cancellation recovery is
|
|
@@ -1542,13 +1602,27 @@ export class KvOAuthProvider {
|
|
|
1542
1602
|
* direction to tolerate clock skew between isolates. A URL with no write
|
|
1543
1603
|
* time — stored by an earlier release, or under a pre-envelope generation —
|
|
1544
1604
|
* is treated as stale, so the caller starts a fresh flow instead.
|
|
1605
|
+
*
|
|
1606
|
+
* The URL must also still name the epoch's client. A callback whose code
|
|
1607
|
+
* exchange the server refused with `invalid_client` leaves the URL behind
|
|
1608
|
+
* but drops the client; handing that URL back would send the operator to a
|
|
1609
|
+
* consent the server has already refused, where a fresh flow registers.
|
|
1545
1610
|
*/
|
|
1546
1611
|
async reusablePendingAuthorizationUrl() {
|
|
1547
1612
|
const pending = await this.readValue("oauth:pending", (raw) => raw);
|
|
1548
|
-
if (pending?.writtenAt === undefined
|
|
1613
|
+
if (pending?.writtenAt === undefined ||
|
|
1614
|
+
Math.abs(Date.now() - pending.writtenAt) >= PENDING_AUTHORIZATION_MAX_AGE_MS) {
|
|
1615
|
+
return undefined;
|
|
1616
|
+
}
|
|
1617
|
+
let clientId;
|
|
1618
|
+
try {
|
|
1619
|
+
clientId = new URL(pending.value).searchParams.get("client_id");
|
|
1620
|
+
}
|
|
1621
|
+
catch {
|
|
1549
1622
|
return undefined;
|
|
1550
|
-
|
|
1551
|
-
|
|
1623
|
+
}
|
|
1624
|
+
const client = await this.clientInformation();
|
|
1625
|
+
return clientId !== null && client?.client_id === clientId
|
|
1552
1626
|
? pending.value
|
|
1553
1627
|
: undefined;
|
|
1554
1628
|
}
|
|
@@ -1616,24 +1690,13 @@ export class KvOAuthProvider {
|
|
|
1616
1690
|
async performResetAuthorization(operatorDisconnected, preserveClient) {
|
|
1617
1691
|
const nonce = crypto.randomUUID();
|
|
1618
1692
|
const previous = await this.generation();
|
|
1619
|
-
// Only an explicitly forced restart may carry a registration forward
|
|
1620
|
-
//
|
|
1621
|
-
//
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
}
|
|
1627
|
-
catch {
|
|
1628
|
-
return {};
|
|
1629
|
-
}
|
|
1630
|
-
})
|
|
1631
|
-
: undefined;
|
|
1632
|
-
const reusableClient = priorClient?.generation === previous &&
|
|
1633
|
-
priorClient.issuer !== undefined &&
|
|
1634
|
-
priorClient.binding === this.clientBinding &&
|
|
1635
|
-
typeof priorClient.value?.client_id === "string"
|
|
1636
|
-
? priorClient
|
|
1693
|
+
// Only an explicitly forced restart may carry a registration forward, and
|
|
1694
|
+
// only one this connector registered: an operator disconnect, an issuer
|
|
1695
|
+
// mismatch, and every unforced reset discard it. Discovery is never
|
|
1696
|
+
// copied: the SDK must select the authorization server again, then its
|
|
1697
|
+
// issuer-aware clientInformation hook checks the issuer.
|
|
1698
|
+
const reusableClient = preserveClient && !operatorDisconnected && this.clientBinding
|
|
1699
|
+
? await this.carriableClient(previous)
|
|
1637
1700
|
: undefined;
|
|
1638
1701
|
const now = Date.now();
|
|
1639
1702
|
const inherited = await this.cleanupBacklog(previous, now);
|
|
@@ -1678,10 +1741,17 @@ export class KvOAuthProvider {
|
|
|
1678
1741
|
await this.storage.set("oauth:generation", active);
|
|
1679
1742
|
this.refreshCoordinator?.retire(previous);
|
|
1680
1743
|
if (reusableClient && !this.signal?.aborted) {
|
|
1681
|
-
// readValue opened the old physical key;
|
|
1744
|
+
// readValue opened the old physical key; the write seals the plaintext
|
|
1682
1745
|
// under the new one. Copying ciphertext would fail its AAD check.
|
|
1683
1746
|
this.captureGeneration(active);
|
|
1684
|
-
await this.
|
|
1747
|
+
await this.storeInGeneration("oauth:client", active, () => JSON.stringify({
|
|
1748
|
+
connectaOAuthVersion: STORED_VALUE_VERSION,
|
|
1749
|
+
generation: active,
|
|
1750
|
+
issuer: reusableClient.issuer,
|
|
1751
|
+
binding: reusableClient.binding,
|
|
1752
|
+
carried: true,
|
|
1753
|
+
value: reusableClient.value,
|
|
1754
|
+
}));
|
|
1685
1755
|
}
|
|
1686
1756
|
// Delete the generation just retired: its values, then the lineage it
|
|
1687
1757
|
// published, which the new epoch has copied. Then retry the newest few
|
|
@@ -1713,6 +1783,55 @@ export class KvOAuthProvider {
|
|
|
1713
1783
|
if (failure)
|
|
1714
1784
|
throw failure.reason;
|
|
1715
1785
|
}
|
|
1786
|
+
/**
|
|
1787
|
+
* The registration a forced restart may copy out of `previous`, or nothing.
|
|
1788
|
+
*
|
|
1789
|
+
* It must have been registered in that epoch for this authorization server
|
|
1790
|
+
* (an issuer stamp) under this connector's exact configuration, redirect
|
|
1791
|
+
* URI, and client metadata (the binding), and it is read through this
|
|
1792
|
+
* owner's own storage, so another principal's partition is never a source.
|
|
1793
|
+
* Three registrations are never carried:
|
|
1794
|
+
*
|
|
1795
|
+
* - A URL-based client (`clientMetadataUrl`). Nothing was registered, so
|
|
1796
|
+
* nothing is saved by copying it, and leaving it behind makes the SDK ask
|
|
1797
|
+
* the freshly discovered metadata whether the server still accepts one.
|
|
1798
|
+
* - One whose `client_secret_expires_at` has passed (RFC 7591: zero means
|
|
1799
|
+
* never). The token endpoint would refuse it.
|
|
1800
|
+
* - One a previous restart carried that has not since earned a grant. The
|
|
1801
|
+
* SDK builds a consent URL locally and sends nothing that could answer
|
|
1802
|
+
* `invalid_client`, and RFC 6749 section 4.1.2.1 forbids an authorization
|
|
1803
|
+
* server from redirecting a client it does not recognize, so a forgotten
|
|
1804
|
+
* registration never reaches the callback. Tokens in the epoch are the
|
|
1805
|
+
* only proof the server still knows the client. Without them the second
|
|
1806
|
+
* restart registers again, which bounds a forgotten registration to one
|
|
1807
|
+
* refused consent rather than a restart loop only a disconnect escapes.
|
|
1808
|
+
*/
|
|
1809
|
+
async carriableClient(previous) {
|
|
1810
|
+
const prior = await this.readValue("oauth:client", (raw) => {
|
|
1811
|
+
try {
|
|
1812
|
+
return JSON.parse(raw);
|
|
1813
|
+
}
|
|
1814
|
+
catch {
|
|
1815
|
+
return {};
|
|
1816
|
+
}
|
|
1817
|
+
});
|
|
1818
|
+
const clientId = prior?.value?.client_id;
|
|
1819
|
+
const expiresAt = prior?.value?.client_secret_expires_at;
|
|
1820
|
+
if (prior?.generation !== previous ||
|
|
1821
|
+
prior.issuer === undefined ||
|
|
1822
|
+
prior.binding === undefined ||
|
|
1823
|
+
prior.binding !== this.clientBinding ||
|
|
1824
|
+
typeof clientId !== "string" ||
|
|
1825
|
+
clientId === this.clientMetadataUrl ||
|
|
1826
|
+
(typeof expiresAt === "number" && expiresAt > 0 && expiresAt * 1000 <= Date.now())) {
|
|
1827
|
+
return undefined;
|
|
1828
|
+
}
|
|
1829
|
+
const carriable = { ...prior, issuer: prior.issuer, binding: prior.binding };
|
|
1830
|
+
if (!prior.carried)
|
|
1831
|
+
return carriable;
|
|
1832
|
+
const earnedGrant = (await this.storage.get(oauthValueStorageKey("oauth:tokens", previous))) !== null;
|
|
1833
|
+
return earnedGrant ? carriable : undefined;
|
|
1834
|
+
}
|
|
1716
1835
|
async invalidateCredentials(scope) {
|
|
1717
1836
|
const endsRefresh = scope === "all" || scope === "tokens";
|
|
1718
1837
|
try {
|