@zackbart/connecta 0.23.0 → 0.24.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 +5 -0
- package/CHANGELOG.md +53 -0
- package/README.md +18 -10
- package/dist/activity-friction.d.ts +3 -0
- package/dist/activity-friction.js +19 -0
- package/dist/activity.d.ts +11 -2
- package/dist/activity.js +15 -19
- package/dist/auth/downstream-oauth.d.ts +2 -1
- package/dist/auth/downstream-oauth.js +10 -1
- package/dist/branding.d.ts +67 -0
- package/dist/branding.js +176 -0
- package/dist/connectors/remote-mcp.js +3 -5
- package/dist/credential-contract.d.ts +24 -0
- package/dist/credential-contract.js +1 -0
- package/dist/credential-rules.d.ts +85 -0
- package/dist/credential-rules.js +107 -0
- package/dist/credentials.d.ts +4 -100
- package/dist/credentials.js +3 -107
- package/dist/execute.d.ts +6 -0
- package/dist/execute.js +17 -7
- package/dist/index.d.ts +35 -56
- package/dist/index.js +34 -58
- package/dist/invocation.js +19 -4
- package/dist/meta-tools.d.ts +4 -0
- package/dist/meta-tools.js +8 -4
- package/dist/module-contracts.d.ts +19 -0
- package/dist/module-contracts.js +1 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +6 -3
- package/dist/operator-ui/view.d.ts +2 -18
- package/dist/operator-ui/view.js +3 -20
- package/dist/registry.d.ts +4 -1
- package/dist/registry.js +4 -6
- package/dist/routes/activity.js +1 -1
- package/dist/routes/credentials.js +5 -2
- package/dist/routes/mcp.js +13 -3
- package/dist/routes/oauth-management.d.ts +2 -0
- package/dist/routes/oauth-management.js +108 -0
- package/dist/routes/oauth.d.ts +0 -1
- package/dist/routes/oauth.js +21 -121
- package/dist/routes/shared.d.ts +23 -17
- package/dist/routes/shared.js +48 -44
- package/dist/routes/ui.js +36 -33
- package/dist/server.js +6 -26
- package/dist/types.d.ts +2 -0
- package/dist/ui.d.ts +15 -70
- package/dist/ui.js +176 -317
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +26 -17
- package/documentation/auth.md +61 -106
- package/documentation/cloudflare.md +1 -1
- package/documentation/code-mode.md +2 -2
- package/documentation/connectors.md +1 -1
- package/documentation/linear.md +1 -1
- package/documentation/meta-tools.md +6 -4
- package/documentation/mixpanel.md +1 -1
- package/documentation/notion.md +2 -2
- package/documentation/operations.md +14 -14
- package/documentation/operator-ui.md +82 -104
- package/documentation/optional-modules-upgrade.md +243 -0
- package/documentation/provider-conventions.md +5 -3
- package/documentation/revenuecat.md +1 -1
- package/documentation/storage-and-credentials.md +59 -40
- package/documentation/stripe.md +1 -1
- package/documentation/upgrading.md +33 -4
- package/ethos.md +22 -30
- package/examples/worker/AGENTS.md +3 -1
- package/examples/worker/README.md +68 -84
- package/examples/worker/src/d1-activity.ts +1 -1
- package/examples/worker/src/index.ts +11 -6
- package/package.json +17 -1
- package/templates/node/AGENTS.md +8 -6
- package/templates/node/README.md +56 -67
- package/templates/node/package.json +1 -1
- package/templates/node/src/file-activity.ts +1 -1
- package/templates/node/src/index.ts +11 -12
- package/dist/access-tokens.d.ts +0 -31
- package/dist/access-tokens.js +0 -236
- package/dist/routes/access-tokens.d.ts +0 -6
- package/dist/routes/access-tokens.js +0 -83
package/dist/operator-ui/view.js
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
export const OPERATOR_PAGES = [
|
|
2
2
|
"connections",
|
|
3
|
-
"credentials",
|
|
4
|
-
"tokens",
|
|
5
3
|
"activity",
|
|
6
4
|
];
|
|
7
5
|
export const PAGE_META = {
|
|
8
6
|
connections: { path: "/", label: "Connections" },
|
|
9
|
-
credentials: { path: "/credentials", label: "Credentials" },
|
|
10
|
-
tokens: { path: "/tokens", label: "Access tokens" },
|
|
11
7
|
activity: { path: "/activity", label: "Activity" },
|
|
12
8
|
};
|
|
13
9
|
export function pageForPath(path) {
|
|
@@ -45,12 +41,6 @@ function identityScopedState() {
|
|
|
45
41
|
credentialNotice: null,
|
|
46
42
|
credentialEditing: null,
|
|
47
43
|
credentialBusy: null,
|
|
48
|
-
tokenPhase: "idle",
|
|
49
|
-
tokenNotice: null,
|
|
50
|
-
tokens: [],
|
|
51
|
-
createdToken: null,
|
|
52
|
-
tokenRenaming: null,
|
|
53
|
-
tokenBusy: false,
|
|
54
44
|
activityPhase: "idle",
|
|
55
45
|
activityNotice: null,
|
|
56
46
|
activityEvents: [],
|
|
@@ -82,9 +72,6 @@ export function withPage(state, page) {
|
|
|
82
72
|
return {
|
|
83
73
|
...state,
|
|
84
74
|
page,
|
|
85
|
-
createdToken: null,
|
|
86
|
-
tokenRenaming: null,
|
|
87
|
-
tokenNotice: null,
|
|
88
75
|
credentialEditing: null,
|
|
89
76
|
credentialNotice: null,
|
|
90
77
|
};
|
|
@@ -94,17 +81,13 @@ export function credentialUnavailableCopy(capability) {
|
|
|
94
81
|
return "No connectors declare operator-managed credential slots. Connector credentials remain configuration-as-code until a slot is declared.";
|
|
95
82
|
}
|
|
96
83
|
if (capability === "vault_not_configured") {
|
|
97
|
-
return "Credential storage is not configured.
|
|
84
|
+
return "Credential storage is not configured. Configure a vault before managing connector credentials here.";
|
|
98
85
|
}
|
|
99
86
|
return "Credential management requires an interactive user. Bearer-authenticated sessions can inspect connections but cannot manage stored credentials.";
|
|
100
87
|
}
|
|
101
|
-
export function accessTokenUnavailableCopy(capability) {
|
|
102
|
-
if (capability === "not_configured") {
|
|
103
|
-
return "Access tokens are not configured for this deployment. Add accessTokens to the deployment configuration to enable them.";
|
|
104
|
-
}
|
|
105
|
-
return "Access token management requires an eligible interactive operator. A Bearer token can connect to MCP, but it cannot create or revoke other tokens.";
|
|
106
|
-
}
|
|
107
88
|
export function connectorStatusLabel(status) {
|
|
89
|
+
if (status === "loading")
|
|
90
|
+
return "Loading details";
|
|
108
91
|
if (status === "ok")
|
|
109
92
|
return "Connected";
|
|
110
93
|
if (status === "auth_required")
|
package/dist/registry.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import type { CredentialVault } from "./credential-contract.js";
|
|
1
2
|
import type { CatalogDriftReport, Connector, ConnectorContext, ConnectorStatus, KVStorage, Logger, ToolDef } from "./types.js";
|
|
2
3
|
import { type DeferredWork } from "./connector-scope.js";
|
|
3
4
|
import { type CatalogDriftActivityContext } from "./activity.js";
|
|
4
|
-
import { type CredentialVault } from "./credentials.js";
|
|
5
5
|
import { type CallAdmissionPermit, type ConnectorCallAdmissionSnapshot } from "./call-admission.js";
|
|
6
6
|
/**
|
|
7
7
|
* Split `"<connectorId>.<toolName>"` on the first dot. Connector ids contain
|
|
@@ -47,6 +47,7 @@ export interface RegistryOptions {
|
|
|
47
47
|
storage: KVStorage;
|
|
48
48
|
logger: Logger;
|
|
49
49
|
credentialVault?: CredentialVault | undefined;
|
|
50
|
+
credentialUi?: boolean | undefined;
|
|
50
51
|
/** Internal owner partition used by a personal registry. */
|
|
51
52
|
credentialOwner?: string | undefined;
|
|
52
53
|
/** Internal child registries skip deployment-wide construction warnings. */
|
|
@@ -84,6 +85,7 @@ export interface CatalogReadOptions {
|
|
|
84
85
|
* or its construction-only methods.
|
|
85
86
|
*/
|
|
86
87
|
export interface RegistryView {
|
|
88
|
+
credentialUiAvailable(): boolean;
|
|
87
89
|
/** Deployment-wide result-size cap threaded to the meta-tools. */
|
|
88
90
|
readonly maxResultBytes: number;
|
|
89
91
|
listConnectors(): Connector[];
|
|
@@ -217,6 +219,7 @@ export declare class Registry implements RegistryView {
|
|
|
217
219
|
* separate from any connector's `conn:<id>:` namespace. Backs get_result.
|
|
218
220
|
*/
|
|
219
221
|
resultsStorage(): KVStorage;
|
|
222
|
+
credentialUiAvailable(): boolean;
|
|
220
223
|
bindOAuthHandoff(): Promise<void>;
|
|
221
224
|
observedOutputSchema(connectorId: string, definition: ToolDef): ToolDef["outputSchema"] | undefined;
|
|
222
225
|
observeOutputShape(connectorId: string, definition: ToolDef, value: unknown): void;
|
package/dist/registry.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { closeConnectorScope, } from "./connector-scope.js";
|
|
2
|
-
import {
|
|
3
|
-
import { storedCredentialShape, } from "./credentials.js";
|
|
2
|
+
import { storedCredentialShape, } from "./credential-rules.js";
|
|
4
3
|
import { ConnectorCallError, msg } from "./errors.js";
|
|
5
4
|
import { ConnectorCallAdmissionController, } from "./call-admission.js";
|
|
6
5
|
import { boundedCatalogDrift } from "./catalog-drift.js";
|
|
@@ -393,7 +392,7 @@ export class Registry {
|
|
|
393
392
|
this.reportedDrift.set(connector.id, counts);
|
|
394
393
|
if (previous === undefined && clean)
|
|
395
394
|
return;
|
|
396
|
-
|
|
395
|
+
this.opts.catalogDriftActivity?.recordDrift?.(this.opts.catalogDriftActivity
|
|
397
396
|
? { ...this.opts.catalogDriftActivity, logger: this.opts.logger }
|
|
398
397
|
: undefined, { connectorId: connector.id, ...counts });
|
|
399
398
|
}
|
|
@@ -409,6 +408,7 @@ export class Registry {
|
|
|
409
408
|
resultsStorage() {
|
|
410
409
|
return namespaced(this.opts.storage, "results:");
|
|
411
410
|
}
|
|
411
|
+
credentialUiAvailable() { return Boolean(this.opts.credentialUi); }
|
|
412
412
|
async bindOAuthHandoff() {
|
|
413
413
|
// Shared OAuth already resolves in deployment-wide connector storage.
|
|
414
414
|
}
|
|
@@ -990,9 +990,6 @@ export class Registry {
|
|
|
990
990
|
// observation when this runtime has not made one.
|
|
991
991
|
const boundedStatus = {
|
|
992
992
|
state: status.state,
|
|
993
|
-
...(status.authorizationUrl !== undefined
|
|
994
|
-
? { authorizationUrl: status.authorizationUrl }
|
|
995
|
-
: {}),
|
|
996
993
|
...(status.message !== undefined ? { message: status.message } : {}),
|
|
997
994
|
};
|
|
998
995
|
return {
|
|
@@ -1127,6 +1124,7 @@ class ScopedRegistryView {
|
|
|
1127
1124
|
const registry = this.registryFor(id);
|
|
1128
1125
|
return registry ? registry.invalidateStored(id) : Promise.resolve();
|
|
1129
1126
|
}
|
|
1127
|
+
credentialUiAvailable() { return this.root.credentialUiAvailable(); }
|
|
1130
1128
|
async bindOAuthHandoff(id, authorizationUrl) {
|
|
1131
1129
|
const connector = this.getConnector(id);
|
|
1132
1130
|
if (connector?.authScope !== "personal" || !this.scope.principalKey)
|
package/dist/routes/activity.js
CHANGED
|
@@ -137,7 +137,7 @@ export async function routeActivity(context) {
|
|
|
137
137
|
const authz = await authorize(request, baseUrl, opts.auth, runtimeContext, opts.identity, false);
|
|
138
138
|
if (!authz.ok)
|
|
139
139
|
return authz.response;
|
|
140
|
-
if (
|
|
140
|
+
if (!authz.operator) {
|
|
141
141
|
return privateJson({ error: "operator access required" }, { status: 403 });
|
|
142
142
|
}
|
|
143
143
|
if (opts.activityReadGate &&
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape, } from "../
|
|
2
|
-
import { authorizeUiIdentity, isSameOrigin, msg, privateJson, } from "./shared.js";
|
|
1
|
+
import { credentialTestRule, describeCredentialTestMismatch, storedCredentialShape, } from "../credential-rules.js";
|
|
2
|
+
import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, isSameOrigin, msg, privateJson, } from "./shared.js";
|
|
3
3
|
async function readCredentialInput(request, config) {
|
|
4
4
|
if (!request.headers
|
|
5
5
|
.get("content-type")
|
|
@@ -78,6 +78,7 @@ async function handleCredentialRequest(context, connectorId, action) {
|
|
|
78
78
|
return authz.response;
|
|
79
79
|
let registry;
|
|
80
80
|
try {
|
|
81
|
+
validateAuthPermissions(authz, opts.registry);
|
|
81
82
|
registry = opts.registry.scoped({
|
|
82
83
|
connectorIds: authz.connectorIds,
|
|
83
84
|
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
@@ -91,6 +92,8 @@ async function handleCredentialRequest(context, connectorId, action) {
|
|
|
91
92
|
if (!connector?.credential) {
|
|
92
93
|
return privateJson({ error: "unknown credential slot" }, { status: 404 });
|
|
93
94
|
}
|
|
95
|
+
if (!mayManageConnector(authz, connector))
|
|
96
|
+
return privateJson({ error: "credential management is not permitted" }, { status: 403 });
|
|
94
97
|
const personal = connector.authScope === "personal";
|
|
95
98
|
if (personal && !authz.principalKey) {
|
|
96
99
|
return privateJson({ error: "forbidden" }, { status: 403 });
|
package/dist/routes/mcp.js
CHANGED
|
@@ -4,7 +4,7 @@ import { ExecutorAdmissionError, } from "../executor-admission.js";
|
|
|
4
4
|
import { registerMetaTools } from "../meta-tools.js";
|
|
5
5
|
import { instructionsFor } from "../skills.js";
|
|
6
6
|
import { msg } from "../errors.js";
|
|
7
|
-
import { authorize, } from "./shared.js";
|
|
7
|
+
import { authorize, mayManageConnector, validateAuthPermissions, } from "./shared.js";
|
|
8
8
|
export const MCP_CORS_HEADERS = {
|
|
9
9
|
"Access-Control-Allow-Origin": "*",
|
|
10
10
|
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
@@ -148,7 +148,7 @@ function toolkitRetired(logger) {
|
|
|
148
148
|
},
|
|
149
149
|
});
|
|
150
150
|
}
|
|
151
|
-
async function serveMcp(request, opts, baseUrl, actor, registry, runtimeContext) {
|
|
151
|
+
async function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtimeContext) {
|
|
152
152
|
const createServer = () => {
|
|
153
153
|
const server = new McpServer(opts.serverInfo, {
|
|
154
154
|
instructions: instructionsFor(),
|
|
@@ -162,6 +162,7 @@ async function serveMcp(request, opts, baseUrl, actor, registry, runtimeContext)
|
|
|
162
162
|
const activity = opts.activity
|
|
163
163
|
? {
|
|
164
164
|
sink: opts.activity,
|
|
165
|
+
recordTool: opts.activityModule?.recordTool,
|
|
165
166
|
actor,
|
|
166
167
|
requestId: crypto.randomUUID(),
|
|
167
168
|
serverInfo: opts.serverInfo,
|
|
@@ -176,6 +177,8 @@ async function serveMcp(request, opts, baseUrl, actor, registry, runtimeContext)
|
|
|
176
177
|
: undefined;
|
|
177
178
|
registerMetaTools(server, registry, {
|
|
178
179
|
baseUrl,
|
|
180
|
+
canManageAuth,
|
|
181
|
+
credentialHandoffUrl: opts.ui?.credentialHandoffUrl(baseUrl),
|
|
179
182
|
...(activity ? { activity } : {}),
|
|
180
183
|
...(opts.defaultToolTimeoutMs !== undefined
|
|
181
184
|
? { defaultToolTimeoutMs: opts.defaultToolTimeoutMs }
|
|
@@ -212,6 +215,12 @@ async function serveMcp(request, opts, baseUrl, actor, registry, runtimeContext)
|
|
|
212
215
|
...(opts.maxEmittedBlocks !== undefined
|
|
213
216
|
? { maxEmittedBlocks: opts.maxEmittedBlocks }
|
|
214
217
|
: {}),
|
|
218
|
+
...(opts.maxHostCalls !== undefined
|
|
219
|
+
? { maxHostCalls: opts.maxHostCalls }
|
|
220
|
+
: {}),
|
|
221
|
+
...(opts.hostCallTimeoutMs !== undefined
|
|
222
|
+
? { hostCallTimeoutMs: opts.hostCallTimeoutMs }
|
|
223
|
+
: {}),
|
|
215
224
|
});
|
|
216
225
|
return server;
|
|
217
226
|
};
|
|
@@ -288,6 +297,7 @@ export function createMcpRoute(opts) {
|
|
|
288
297
|
}
|
|
289
298
|
let scopedRegistry;
|
|
290
299
|
try {
|
|
300
|
+
validateAuthPermissions(authz, opts.registry);
|
|
291
301
|
scopedRegistry = opts.registry.scoped({
|
|
292
302
|
connectorIds: authz.connectorIds,
|
|
293
303
|
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
@@ -303,7 +313,7 @@ export function createMcpRoute(opts) {
|
|
|
303
313
|
if (new URL(request.url).searchParams.has("toolkit")) {
|
|
304
314
|
return releaseAdmissionWithResponse(withMcpCors(toolkitRetired(opts.logger)), admission, request.signal);
|
|
305
315
|
}
|
|
306
|
-
return releaseAdmissionWithResponse(withMcpCors(await serveMcp(request, opts, baseUrl, authz.actor, scopedRegistry, runtimeContext)), admission, request.signal);
|
|
316
|
+
return releaseAdmissionWithResponse(withMcpCors(await serveMcp(request, opts, baseUrl, authz.actor, scopedRegistry, id => { const connector = scopedRegistry.getConnector(id); return Boolean(connector && mayManageConnector(authz, connector)); }, runtimeContext)), admission, request.signal);
|
|
307
317
|
}
|
|
308
318
|
catch (error) {
|
|
309
319
|
admission.release();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { closeConnectorScope } from "../connector-scope.js";
|
|
2
|
+
import { isSafeHttpUrl } from "../ui.js";
|
|
3
|
+
import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, isSameOrigin, msg, privateJson, } from "./shared.js";
|
|
4
|
+
async function handleOAuthManagementRequest(context, connectorId) {
|
|
5
|
+
const { request, baseUrl, opts, defer } = context;
|
|
6
|
+
if (!isSameOrigin(request, baseUrl)) {
|
|
7
|
+
return privateJson({ error: "same-origin request required" }, { status: 403 });
|
|
8
|
+
}
|
|
9
|
+
const authz = await authorizeUiIdentity(request, baseUrl, opts.auth, "OAuth management", context.runtimeContext, opts.identity);
|
|
10
|
+
if (!authz.ok)
|
|
11
|
+
return authz.response;
|
|
12
|
+
let registry;
|
|
13
|
+
try {
|
|
14
|
+
validateAuthPermissions(authz, opts.registry);
|
|
15
|
+
registry = opts.registry.scoped({
|
|
16
|
+
connectorIds: authz.connectorIds,
|
|
17
|
+
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
18
|
+
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
return privateJson({ error: msg(error) }, { status: 403 });
|
|
23
|
+
}
|
|
24
|
+
const connector = registry.getConnector(connectorId);
|
|
25
|
+
if (!connector?.disconnectAuth || !connector.startAuth) {
|
|
26
|
+
return privateJson({ error: "unknown OAuth connector" }, { status: 404 });
|
|
27
|
+
}
|
|
28
|
+
if (!mayManageConnector(authz, connector))
|
|
29
|
+
return privateJson({ error: "credential management is not permitted" }, { status: 403 });
|
|
30
|
+
if (connector.authScope === "personal" && !authz.principalKey) {
|
|
31
|
+
return privateJson({ error: "forbidden" }, { status: 403 });
|
|
32
|
+
}
|
|
33
|
+
if (request.method !== "DELETE" && request.method !== "POST") {
|
|
34
|
+
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
35
|
+
}
|
|
36
|
+
const requestScope = {};
|
|
37
|
+
const ctx = registry.contextFor(connectorId, baseUrl, requestScope);
|
|
38
|
+
try {
|
|
39
|
+
let result;
|
|
40
|
+
let operationError;
|
|
41
|
+
try {
|
|
42
|
+
if (request.method === "DELETE") {
|
|
43
|
+
await connector.disconnectAuth(ctx);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
result = await connector.startAuth(ctx, { force: true });
|
|
47
|
+
if (result.authorizationUrl) {
|
|
48
|
+
await registry.bindOAuthHandoff(connectorId, result.authorizationUrl);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
operationError = error;
|
|
54
|
+
}
|
|
55
|
+
// The old grant and its cached catalog are invalid after either operation,
|
|
56
|
+
// including a partially failed physical cleanup whose epoch fence succeeded.
|
|
57
|
+
try {
|
|
58
|
+
await registry.invalidateStored(connectorId);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
operationError ??= error;
|
|
62
|
+
}
|
|
63
|
+
if (operationError) {
|
|
64
|
+
return privateJson({ error: msg(operationError) }, { status: 400 });
|
|
65
|
+
}
|
|
66
|
+
if (request.method === "DELETE") {
|
|
67
|
+
return new Response(null, {
|
|
68
|
+
status: 204,
|
|
69
|
+
headers: {
|
|
70
|
+
"Cache-Control": "no-store",
|
|
71
|
+
"Referrer-Policy": "no-referrer",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const authorizationUrl = isSafeHttpUrl(result.authorizationUrl)
|
|
76
|
+
? result.authorizationUrl
|
|
77
|
+
: undefined;
|
|
78
|
+
if (result.state === "error") {
|
|
79
|
+
return privateJson({ error: result.message || "OAuth authorization could not start" }, { status: 502 });
|
|
80
|
+
}
|
|
81
|
+
if (result.state === "auth_required" && !authorizationUrl) {
|
|
82
|
+
return privateJson({
|
|
83
|
+
error: result.message ||
|
|
84
|
+
"OAuth authorization requires consent but no safe URL is available",
|
|
85
|
+
}, { status: 502 });
|
|
86
|
+
}
|
|
87
|
+
return privateJson({
|
|
88
|
+
state: result.state,
|
|
89
|
+
...(result.message ? { message: result.message } : {}),
|
|
90
|
+
...(authorizationUrl ? { authorizationUrl } : {}),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await closeConnectorScope(connector, ctx, defer);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export async function routeOAuthManagement(context) {
|
|
98
|
+
const match = /^\/ui\/oauth\/([a-z0-9_-]+)$/.exec(context.path);
|
|
99
|
+
if (!match)
|
|
100
|
+
return null;
|
|
101
|
+
const connectorId = match[1];
|
|
102
|
+
if (!connectorId)
|
|
103
|
+
return null;
|
|
104
|
+
if (context.request.method === "OPTIONS") {
|
|
105
|
+
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
106
|
+
}
|
|
107
|
+
return handleOAuthManagementRequest(context, connectorId);
|
|
108
|
+
}
|
package/dist/routes/oauth.d.ts
CHANGED
package/dist/routes/oauth.js
CHANGED
|
@@ -1,109 +1,6 @@
|
|
|
1
1
|
import { oauthValueStorageKey } from "../auth/downstream-oauth.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { authorizeUiIdentity, isSameOrigin, loggableValue, msg, privateJson, } from "./shared.js";
|
|
5
|
-
async function handleOAuthManagementRequest(context, connectorId) {
|
|
6
|
-
const { request, baseUrl, opts, defer } = context;
|
|
7
|
-
if (!isSameOrigin(request, baseUrl)) {
|
|
8
|
-
return privateJson({ error: "same-origin request required" }, { status: 403 });
|
|
9
|
-
}
|
|
10
|
-
const authz = await authorizeUiIdentity(request, baseUrl, opts.auth, "OAuth management", context.runtimeContext, opts.identity);
|
|
11
|
-
if (!authz.ok)
|
|
12
|
-
return authz.response;
|
|
13
|
-
let registry;
|
|
14
|
-
try {
|
|
15
|
-
registry = opts.registry.scoped({
|
|
16
|
-
connectorIds: authz.connectorIds,
|
|
17
|
-
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
18
|
-
...(authz.principalKey ? { principalKey: authz.principalKey } : {}),
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
catch (error) {
|
|
22
|
-
return privateJson({ error: msg(error) }, { status: 403 });
|
|
23
|
-
}
|
|
24
|
-
const connector = registry.getConnector(connectorId);
|
|
25
|
-
if (!connector?.disconnectAuth || !connector.startAuth) {
|
|
26
|
-
return privateJson({ error: "unknown OAuth connector" }, { status: 404 });
|
|
27
|
-
}
|
|
28
|
-
if (connector.authScope === "personal" && !authz.principalKey) {
|
|
29
|
-
return privateJson({ error: "forbidden" }, { status: 403 });
|
|
30
|
-
}
|
|
31
|
-
if (request.method !== "DELETE" && request.method !== "POST") {
|
|
32
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
33
|
-
}
|
|
34
|
-
const requestScope = {};
|
|
35
|
-
const ctx = registry.contextFor(connectorId, baseUrl, requestScope);
|
|
36
|
-
try {
|
|
37
|
-
let result;
|
|
38
|
-
let operationError;
|
|
39
|
-
try {
|
|
40
|
-
if (request.method === "DELETE") {
|
|
41
|
-
await connector.disconnectAuth(ctx);
|
|
42
|
-
}
|
|
43
|
-
else {
|
|
44
|
-
result = await connector.startAuth(ctx, { force: true });
|
|
45
|
-
if (result.authorizationUrl) {
|
|
46
|
-
await registry.bindOAuthHandoff(connectorId, result.authorizationUrl);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
catch (error) {
|
|
51
|
-
operationError = error;
|
|
52
|
-
}
|
|
53
|
-
// The old grant and its cached catalog are invalid after either operation,
|
|
54
|
-
// including a partially failed physical cleanup whose epoch fence succeeded.
|
|
55
|
-
try {
|
|
56
|
-
await registry.invalidateStored(connectorId);
|
|
57
|
-
}
|
|
58
|
-
catch (error) {
|
|
59
|
-
operationError ??= error;
|
|
60
|
-
}
|
|
61
|
-
if (operationError) {
|
|
62
|
-
return privateJson({ error: msg(operationError) }, { status: 400 });
|
|
63
|
-
}
|
|
64
|
-
if (request.method === "DELETE") {
|
|
65
|
-
return new Response(null, {
|
|
66
|
-
status: 204,
|
|
67
|
-
headers: {
|
|
68
|
-
"Cache-Control": "no-store",
|
|
69
|
-
"Referrer-Policy": "no-referrer",
|
|
70
|
-
},
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
const authorizationUrl = isSafeHttpUrl(result.authorizationUrl)
|
|
74
|
-
? result.authorizationUrl
|
|
75
|
-
: undefined;
|
|
76
|
-
if (result.state === "error") {
|
|
77
|
-
return privateJson({ error: result.message || "OAuth authorization could not start" }, { status: 502 });
|
|
78
|
-
}
|
|
79
|
-
if (result.state === "auth_required" && !authorizationUrl) {
|
|
80
|
-
return privateJson({
|
|
81
|
-
error: result.message ||
|
|
82
|
-
"OAuth authorization requires consent but no safe URL is available",
|
|
83
|
-
}, { status: 502 });
|
|
84
|
-
}
|
|
85
|
-
return privateJson({
|
|
86
|
-
state: result.state,
|
|
87
|
-
...(result.message ? { message: result.message } : {}),
|
|
88
|
-
...(authorizationUrl ? { authorizationUrl } : {}),
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
finally {
|
|
92
|
-
await closeConnectorScope(connector, ctx, defer);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
export async function routeOAuthManagement(context) {
|
|
96
|
-
const match = /^\/ui\/oauth\/([a-z0-9_-]+)$/.exec(context.path);
|
|
97
|
-
if (!match)
|
|
98
|
-
return null;
|
|
99
|
-
const connectorId = match[1];
|
|
100
|
-
if (!connectorId)
|
|
101
|
-
return null;
|
|
102
|
-
if (context.request.method === "OPTIONS") {
|
|
103
|
-
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
104
|
-
}
|
|
105
|
-
return handleOAuthManagementRequest(context, connectorId);
|
|
106
|
-
}
|
|
2
|
+
import { resolveBranding } from "../branding.js";
|
|
3
|
+
import { authorizeUiIdentity, mayManageConnector, validateAuthPermissions, loggableValue, msg, } from "./shared.js";
|
|
107
4
|
function escapeHtml(value) {
|
|
108
5
|
return value
|
|
109
6
|
.replaceAll("&", "&")
|
|
@@ -112,7 +9,7 @@ function escapeHtml(value) {
|
|
|
112
9
|
.replaceAll('"', """);
|
|
113
10
|
}
|
|
114
11
|
/** `body` is escaped — callback params and error messages are attacker-influenced. */
|
|
115
|
-
function html(body, status = 200, branding) {
|
|
12
|
+
function html(body, status = 200, branding, hasUi = false) {
|
|
116
13
|
const brand = resolveBranding(branding);
|
|
117
14
|
const title = brand.pageTitle;
|
|
118
15
|
const owner = brand.ownerName
|
|
@@ -173,7 +70,7 @@ function html(body, status = 200, branding) {
|
|
|
173
70
|
<h1>Connection status</h1>
|
|
174
71
|
<div class="copy">
|
|
175
72
|
<p>${escapeHtml(body)}</p>
|
|
176
|
-
|
|
73
|
+
${hasUi ? `<p><a href="/">Return to ${escapeHtml(brand.productName)}</a></p>` : ""}
|
|
177
74
|
</div>
|
|
178
75
|
</main>
|
|
179
76
|
</body>
|
|
@@ -216,10 +113,10 @@ export async function routeOAuthCallback(context) {
|
|
|
216
113
|
return null;
|
|
217
114
|
const error = url.searchParams.get("error");
|
|
218
115
|
if (error)
|
|
219
|
-
return html(`Authorization denied: ${error}`, 400, opts.branding);
|
|
116
|
+
return html(`Authorization denied: ${error}`, 400, opts.branding, Boolean(opts.ui));
|
|
220
117
|
const code = url.searchParams.get("code");
|
|
221
118
|
if (!code)
|
|
222
|
-
return html("Missing authorization code.", 400, opts.branding);
|
|
119
|
+
return html("Missing authorization code.", 400, opts.branding, Boolean(opts.ui));
|
|
223
120
|
const id = path.slice("/oauth/callback/".length);
|
|
224
121
|
const state = url.searchParams.get("state");
|
|
225
122
|
const callbackTarget = await opts.registry.oauthCallbackView(id, state);
|
|
@@ -234,22 +131,25 @@ export async function routeOAuthCallback(context) {
|
|
|
234
131
|
? callbackRegistry.contextFor(id, baseUrl)
|
|
235
132
|
: opts.registry.contextFor(id, baseUrl);
|
|
236
133
|
const refused = () => html("Authorization could not be completed. Re-run authorization from " +
|
|
237
|
-
"connecta and try again.", 400, opts.branding);
|
|
134
|
+
"connecta and try again.", 400, opts.branding, Boolean(opts.ui));
|
|
238
135
|
if (!connector || !connector.finishAuth) {
|
|
239
136
|
await equalizeRefusalCost(connectorContext);
|
|
240
137
|
return refused();
|
|
241
138
|
}
|
|
242
139
|
const expectedPrincipalKey = callbackTarget?.principalKey;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
browserIdentity.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
"did not start this personal authorization flow. No authorization " +
|
|
250
|
-
"code was exchanged.");
|
|
140
|
+
const browserIdentity = await authorizeUiIdentity(context.request, baseUrl, opts.auth, "OAuth callback", context.runtimeContext, opts.identity);
|
|
141
|
+
if (browserIdentity.ok) {
|
|
142
|
+
try {
|
|
143
|
+
validateAuthPermissions(browserIdentity, opts.registry);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
251
146
|
return refused();
|
|
252
147
|
}
|
|
148
|
+
if (!mayManageConnector(browserIdentity, connector) || (expectedPrincipalKey && browserIdentity.principalKey !== expectedPrincipalKey))
|
|
149
|
+
return refused();
|
|
150
|
+
}
|
|
151
|
+
else if (browserIdentity.response.status === 403 && opts.auth.some(provider => provider.interactiveOperator)) {
|
|
152
|
+
return refused();
|
|
253
153
|
}
|
|
254
154
|
// CSRF / login-fixation guard: this route is intentionally public, so verify
|
|
255
155
|
// the `state` matches the flow connecta started BEFORE exchanging the code.
|
|
@@ -292,15 +192,15 @@ export async function routeOAuthCallback(context) {
|
|
|
292
192
|
opts.logger.warn(`[connecta] refused an OAuth callback for connector ` +
|
|
293
193
|
`${loggableValue(id)} with 500: its principal handoff could not be ` +
|
|
294
194
|
`consumed (${loggableValue(msg(err))}). No authorization code was exchanged.`);
|
|
295
|
-
return html("Authorization could not be completed.", 500, opts.branding);
|
|
195
|
+
return html("Authorization could not be completed.", 500, opts.branding, Boolean(opts.ui));
|
|
296
196
|
}
|
|
297
197
|
}
|
|
298
198
|
try {
|
|
299
199
|
await connector.finishAuth(code, connectorContext, url.searchParams);
|
|
300
200
|
await callbackRegistry.invalidateStored(id);
|
|
301
|
-
return html(`Connected "${id}". You can close this window.`, 200, opts.branding);
|
|
201
|
+
return html(`Connected "${id}". You can close this window.`, 200, opts.branding, Boolean(opts.ui));
|
|
302
202
|
}
|
|
303
203
|
catch (err) {
|
|
304
|
-
return html(`Authorization failed: ${msg(err)}`, 500, opts.branding);
|
|
204
|
+
return html(`Authorization failed: ${msg(err)}`, 500, opts.branding, Boolean(opts.ui));
|
|
305
205
|
}
|
|
306
206
|
}
|
package/dist/routes/shared.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { Implementation } from "@modelcontextprotocol/server";
|
|
2
|
-
import type {
|
|
2
|
+
import type { ActivityModule, OperatorSurface } from "../module-contracts.js";
|
|
3
3
|
import type { ActivityActor, ActivityReadGate, ActivityStore } from "../activity.js";
|
|
4
|
-
import type { CredentialVault } from "../
|
|
4
|
+
import type { CredentialVault } from "../credential-contract.js";
|
|
5
5
|
import type { DeferredWork } from "../connector-scope.js";
|
|
6
6
|
import type { AdmissionController } from "../executor-admission.js";
|
|
7
7
|
import type { Registry } from "../registry.js";
|
|
8
|
-
import type { AuthenticatedIdentity, ConnectaBranding, Executor,
|
|
9
|
-
import type { ConnectaIdentityConfig } from "../index.js";
|
|
8
|
+
import type { AuthenticatedIdentity, ConnectaBranding, Executor, InboundAuth, InboundAuthRuntimeContext, Logger } from "../types.js";
|
|
9
|
+
import type { ConnectorPermission, ConnectaIdentityConfig } from "../index.js";
|
|
10
10
|
export { msg } from "../errors.js";
|
|
11
11
|
export interface ServerOptions {
|
|
12
12
|
registry: Registry;
|
|
@@ -29,6 +29,10 @@ export interface ServerOptions {
|
|
|
29
29
|
maxEmittedBytes?: number | undefined;
|
|
30
30
|
/** Block-count budget for connecta.emit per run. Default 32. */
|
|
31
31
|
maxEmittedBlocks?: number | undefined;
|
|
32
|
+
/** Host calls one execute_code program may make. Default 20. */
|
|
33
|
+
maxHostCalls?: number | undefined;
|
|
34
|
+
/** Deadline per execute_code host call. Default 15_000. */
|
|
35
|
+
hostCallTimeoutMs?: number | undefined;
|
|
32
36
|
/** Required sandbox backing the execute_code meta-tool. */
|
|
33
37
|
executor: Executor;
|
|
34
38
|
/** Sanitized identity of the configured sandbox, when it has one. */
|
|
@@ -37,8 +41,9 @@ export interface ServerOptions {
|
|
|
37
41
|
requestAdmission: AdmissionController;
|
|
38
42
|
/** Encrypted connector-credential storage backing the Credentials page. */
|
|
39
43
|
credentialVault?: CredentialVault | undefined;
|
|
40
|
-
/**
|
|
41
|
-
|
|
44
|
+
/** Optional browser routes, with no implementation import in core. */
|
|
45
|
+
ui?: OperatorSurface | undefined;
|
|
46
|
+
activityModule?: ActivityModule | undefined;
|
|
42
47
|
/** Optional browser UI and OAuth result-page labels. */
|
|
43
48
|
branding?: ConnectaBranding | undefined;
|
|
44
49
|
}
|
|
@@ -74,22 +79,14 @@ export declare function authorize(request: Request, baseUrl: string, auth: Inbou
|
|
|
74
79
|
principalKey?: string;
|
|
75
80
|
connectorIds: "all" | readonly string[];
|
|
76
81
|
operator: boolean;
|
|
82
|
+
credentialAdministration: ConnectorPermission;
|
|
83
|
+
personalConnection: ConnectorPermission;
|
|
77
84
|
/** Backward-compatible name used by operator views. */
|
|
78
85
|
uiAdminEligible?: boolean;
|
|
79
86
|
} | {
|
|
80
87
|
ok: false;
|
|
81
88
|
response: Response;
|
|
82
89
|
}>;
|
|
83
|
-
export declare function authorizeUiAdmin(request: Request, baseUrl: string, auth: InboundAuth[], purpose?: string, runtimeContext?: RuntimeExecutionContext, identityConfig?: ConnectaIdentityConfig): Promise<{
|
|
84
|
-
ok: true;
|
|
85
|
-
userId: string;
|
|
86
|
-
principal?: IdentityReference;
|
|
87
|
-
principalKey?: string;
|
|
88
|
-
connectorIds: "all" | readonly string[];
|
|
89
|
-
} | {
|
|
90
|
-
ok: false;
|
|
91
|
-
response: Response;
|
|
92
|
-
}>;
|
|
93
90
|
export declare function authorizeUiIdentity(request: Request, baseUrl: string, auth: InboundAuth[], purpose: string, runtimeContext?: RuntimeExecutionContext, identityConfig?: ConnectaIdentityConfig): Promise<Extract<Awaited<ReturnType<typeof authorize>>, {
|
|
94
91
|
ok: true;
|
|
95
92
|
}> | {
|
|
@@ -97,4 +94,13 @@ export declare function authorizeUiIdentity(request: Request, baseUrl: string, a
|
|
|
97
94
|
response: Response;
|
|
98
95
|
}>;
|
|
99
96
|
export declare function isSameOrigin(request: Request, baseUrl: string): boolean;
|
|
100
|
-
export declare function withSecurityHeaders(response: Response, requestUrl: URL,
|
|
97
|
+
export declare function withSecurityHeaders(response: Response, requestUrl: URL, _path: string): Response;
|
|
98
|
+
type AuthorizedIdentity = Extract<Awaited<ReturnType<typeof authorize>>, {
|
|
99
|
+
ok: true;
|
|
100
|
+
}>;
|
|
101
|
+
/** Unknown configured ids refuse the complete view, including management rights. */
|
|
102
|
+
export declare function validateAuthPermissions(authz: AuthorizedIdentity, registry: Registry): void;
|
|
103
|
+
export declare function mayManageConnector(authz: AuthorizedIdentity, connector: {
|
|
104
|
+
id: string;
|
|
105
|
+
authScope?: "shared" | "personal";
|
|
106
|
+
}): boolean;
|