@zackbart/connecta 0.22.3 → 0.24.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 -0
- package/CHANGELOG.md +82 -0
- package/README.md +19 -11
- 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/catalog-service.d.ts +1 -6
- package/dist/catalog-service.js +3 -53
- 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/errors.d.ts +1 -1
- package/dist/execute.d.ts +5 -52
- package/dist/execute.js +40 -347
- package/dist/executors/quickjs-protocol.d.ts +0 -7
- package/dist/executors/quickjs-protocol.js +2 -10
- package/dist/executors/quickjs.js +1 -1
- package/dist/index.d.ts +23 -56
- package/dist/index.js +30 -58
- package/dist/invocation.d.ts +0 -33
- package/dist/invocation.js +56 -124
- package/dist/meta-tools.d.ts +7 -6
- package/dist/meta-tools.js +14 -21
- 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 +8 -6
- package/dist/routes/activity.js +1 -1
- package/dist/routes/credentials.js +5 -2
- package/dist/routes/mcp.js +7 -51
- 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 +19 -17
- package/dist/routes/shared.js +48 -44
- package/dist/routes/ui.js +36 -33
- package/dist/server.d.ts +1 -2
- package/dist/server.js +7 -45
- package/dist/skills.d.ts +1 -1
- package/dist/skills.js +55 -19
- package/dist/types.d.ts +3 -15
- 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 +31 -26
- package/documentation/auth.md +65 -111
- package/documentation/call-admission.md +10 -11
- package/documentation/cloudflare.md +1 -1
- package/documentation/code-mode.md +65 -260
- package/documentation/connectors.md +8 -6
- package/documentation/linear.md +1 -1
- package/documentation/meta-tools.md +37 -18
- package/documentation/mixpanel.md +1 -1
- package/documentation/notion.md +2 -2
- package/documentation/operations.md +19 -23
- package/documentation/operator-ui.md +82 -104
- package/documentation/optional-modules-upgrade.md +243 -0
- package/documentation/provider-conventions.md +7 -5
- package/documentation/revenuecat.md +1 -1
- package/documentation/storage-and-credentials.md +59 -40
- package/documentation/stripe.md +1 -1
- package/documentation/upgrading.md +83 -8
- package/ethos.md +36 -43
- 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 +18 -2
- 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/apps-shell.d.ts +0 -37
- package/dist/apps-shell.js +0 -174
- 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";
|
|
@@ -131,6 +130,10 @@ export class Registry {
|
|
|
131
130
|
this.persistToolCatalog = opts.persistToolCatalog ?? true;
|
|
132
131
|
this.maxResultBytes = resolveMaxResultBytes(opts.maxResultBytes, DEFAULT_MAX_RESULT_BYTES);
|
|
133
132
|
for (const c of connectors) {
|
|
133
|
+
if ("handleRequest" in c) {
|
|
134
|
+
throw new Error(`Connector "${c.id}" declares removed handleRequest. ` +
|
|
135
|
+
"Move custom HTTP routes into the deployment's fetch handler.");
|
|
136
|
+
}
|
|
134
137
|
if (!ID_RE.test(c.id)) {
|
|
135
138
|
throw new Error(`Invalid connector id "${c.id}": must match ${ID_RE.source}`);
|
|
136
139
|
}
|
|
@@ -389,7 +392,7 @@ export class Registry {
|
|
|
389
392
|
this.reportedDrift.set(connector.id, counts);
|
|
390
393
|
if (previous === undefined && clean)
|
|
391
394
|
return;
|
|
392
|
-
|
|
395
|
+
this.opts.catalogDriftActivity?.recordDrift?.(this.opts.catalogDriftActivity
|
|
393
396
|
? { ...this.opts.catalogDriftActivity, logger: this.opts.logger }
|
|
394
397
|
: undefined, { connectorId: connector.id, ...counts });
|
|
395
398
|
}
|
|
@@ -405,6 +408,7 @@ export class Registry {
|
|
|
405
408
|
resultsStorage() {
|
|
406
409
|
return namespaced(this.opts.storage, "results:");
|
|
407
410
|
}
|
|
411
|
+
credentialUiAvailable() { return Boolean(this.opts.credentialUi); }
|
|
408
412
|
async bindOAuthHandoff() {
|
|
409
413
|
// Shared OAuth already resolves in deployment-wide connector storage.
|
|
410
414
|
}
|
|
@@ -986,9 +990,6 @@ export class Registry {
|
|
|
986
990
|
// observation when this runtime has not made one.
|
|
987
991
|
const boundedStatus = {
|
|
988
992
|
state: status.state,
|
|
989
|
-
...(status.authorizationUrl !== undefined
|
|
990
|
-
? { authorizationUrl: status.authorizationUrl }
|
|
991
|
-
: {}),
|
|
992
993
|
...(status.message !== undefined ? { message: status.message } : {}),
|
|
993
994
|
};
|
|
994
995
|
return {
|
|
@@ -1123,6 +1124,7 @@ class ScopedRegistryView {
|
|
|
1123
1124
|
const registry = this.registryFor(id);
|
|
1124
1125
|
return registry ? registry.invalidateStored(id) : Promise.resolve();
|
|
1125
1126
|
}
|
|
1127
|
+
credentialUiAvailable() { return this.root.credentialUiAvailable(); }
|
|
1126
1128
|
async bindOAuthHandoff(id, authorizationUrl) {
|
|
1127
1129
|
const connector = this.getConnector(id);
|
|
1128
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
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { createMcpHandler, isLegacyRequest, McpServer, WebStandardStreamableHTTPServerTransport, } from "@modelcontextprotocol/server";
|
|
2
|
-
import { MCP_APPS_EXTENSION, PROGRAM_UI_MIME_TYPE, PROGRAM_UI_RESOURCE_URI, PROGRAM_UI_SHELL_HTML, } from "../apps-shell.js";
|
|
3
2
|
import { registerExecuteTool } from "../execute.js";
|
|
4
3
|
import { ExecutorAdmissionError, } from "../executor-admission.js";
|
|
5
4
|
import { registerMetaTools } from "../meta-tools.js";
|
|
6
5
|
import { instructionsFor } from "../skills.js";
|
|
7
6
|
import { msg } from "../errors.js";
|
|
8
|
-
import { authorize, } from "./shared.js";
|
|
7
|
+
import { authorize, mayManageConnector, validateAuthPermissions, } from "./shared.js";
|
|
9
8
|
export const MCP_CORS_HEADERS = {
|
|
10
9
|
"Access-Control-Allow-Origin": "*",
|
|
11
10
|
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
@@ -149,56 +148,10 @@ function toolkitRetired(logger) {
|
|
|
149
148
|
},
|
|
150
149
|
});
|
|
151
150
|
}
|
|
152
|
-
|
|
153
|
-
* U5: one static template, served by a handler that answers exactly one URI
|
|
154
|
-
* and fails on every other. Registering it is also what declares the
|
|
155
|
-
* `resources` capability — which is why `resources/list` has to answer, and
|
|
156
|
-
* why it answers with nothing. That is the Apps spec's permitted omission of
|
|
157
|
-
* UI-only resources from listing, taken exactly: the capability stays honest
|
|
158
|
-
* because the method answers, and nothing downstream is ever listed or
|
|
159
|
-
* aggregated. Widening this handler to proxy downstream templates is a
|
|
160
|
-
* decision (see the design record), not a diff.
|
|
161
|
-
*/
|
|
162
|
-
function registerProgramUiResource(server) {
|
|
163
|
-
server.registerResource("connecta-program-ui", PROGRAM_UI_RESOURCE_URI, {
|
|
164
|
-
title: "connecta program view",
|
|
165
|
-
description: "The MCP Apps shell that renders HTML an execute_code program handed connecta.ui.",
|
|
166
|
-
mimeType: PROGRAM_UI_MIME_TYPE,
|
|
167
|
-
}, (uri) => ({
|
|
168
|
-
contents: [
|
|
169
|
-
{
|
|
170
|
-
uri: uri.href,
|
|
171
|
-
mimeType: PROGRAM_UI_MIME_TYPE,
|
|
172
|
-
text: PROGRAM_UI_SHELL_HTML,
|
|
173
|
-
},
|
|
174
|
-
],
|
|
175
|
-
}));
|
|
176
|
-
// The SDK's generated listing would advertise the template it just
|
|
177
|
-
// registered. Replace it rather than accept that: the URI reaches the host
|
|
178
|
-
// through tool metadata, so the listing has nothing to carry.
|
|
179
|
-
server.server.setRequestHandler("resources/list", () => ({ resources: [] }));
|
|
180
|
-
}
|
|
181
|
-
async function serveMcp(request, opts, baseUrl, actor, registry, runtimeContext) {
|
|
151
|
+
async function serveMcp(request, opts, baseUrl, actor, registry, canManageAuth, runtimeContext) {
|
|
182
152
|
const createServer = () => {
|
|
183
153
|
const server = new McpServer(opts.serverInfo, {
|
|
184
154
|
instructions: instructionsFor(),
|
|
185
|
-
// U11: the Apps extension must be explicitly negotiated, and a
|
|
186
|
-
// conforming client acts on an extension only when both sides declare
|
|
187
|
-
// it — without this line no host reads execute_code's _meta.ui, no host
|
|
188
|
-
// fetches the shell, and the whole design is inert. This is the one
|
|
189
|
-
// extension connecta advertises; the versioned extensions framework
|
|
190
|
-
// stays declined as a general surface (https://github.com/zackbart/connecta/blob/main/records/mcp-2026-07-28.md).
|
|
191
|
-
capabilities: {
|
|
192
|
-
extensions: {
|
|
193
|
-
[MCP_APPS_EXTENSION]: { mimeTypes: [PROGRAM_UI_MIME_TYPE] },
|
|
194
|
-
},
|
|
195
|
-
// Registering the shell below declares `resources` on its own, but it
|
|
196
|
-
// would default `listChanged` to true. Connecta serves one build-time
|
|
197
|
-
// template and never sends a list_changed notification, so say so:
|
|
198
|
-
// a client that subscribes on the strength of that flag would wait
|
|
199
|
-
// forever for an event this server has no way to produce.
|
|
200
|
-
resources: { listChanged: false },
|
|
201
|
-
},
|
|
202
155
|
cacheHints: {
|
|
203
156
|
"tools/list": {
|
|
204
157
|
ttlMs: 3_600_000,
|
|
@@ -206,10 +159,10 @@ async function serveMcp(request, opts, baseUrl, actor, registry, runtimeContext)
|
|
|
206
159
|
},
|
|
207
160
|
},
|
|
208
161
|
});
|
|
209
|
-
registerProgramUiResource(server);
|
|
210
162
|
const activity = opts.activity
|
|
211
163
|
? {
|
|
212
164
|
sink: opts.activity,
|
|
165
|
+
recordTool: opts.activityModule?.recordTool,
|
|
213
166
|
actor,
|
|
214
167
|
requestId: crypto.randomUUID(),
|
|
215
168
|
serverInfo: opts.serverInfo,
|
|
@@ -224,6 +177,8 @@ async function serveMcp(request, opts, baseUrl, actor, registry, runtimeContext)
|
|
|
224
177
|
: undefined;
|
|
225
178
|
registerMetaTools(server, registry, {
|
|
226
179
|
baseUrl,
|
|
180
|
+
canManageAuth,
|
|
181
|
+
credentialHandoffUrl: opts.ui?.credentialHandoffUrl(baseUrl),
|
|
227
182
|
...(activity ? { activity } : {}),
|
|
228
183
|
...(opts.defaultToolTimeoutMs !== undefined
|
|
229
184
|
? { defaultToolTimeoutMs: opts.defaultToolTimeoutMs }
|
|
@@ -336,6 +291,7 @@ export function createMcpRoute(opts) {
|
|
|
336
291
|
}
|
|
337
292
|
let scopedRegistry;
|
|
338
293
|
try {
|
|
294
|
+
validateAuthPermissions(authz, opts.registry);
|
|
339
295
|
scopedRegistry = opts.registry.scoped({
|
|
340
296
|
connectorIds: authz.connectorIds,
|
|
341
297
|
...(authz.subjectKey ? { subjectKey: authz.subjectKey } : {}),
|
|
@@ -351,7 +307,7 @@ export function createMcpRoute(opts) {
|
|
|
351
307
|
if (new URL(request.url).searchParams.has("toolkit")) {
|
|
352
308
|
return releaseAdmissionWithResponse(withMcpCors(toolkitRetired(opts.logger)), admission, request.signal);
|
|
353
309
|
}
|
|
354
|
-
return releaseAdmissionWithResponse(withMcpCors(await serveMcp(request, opts, baseUrl, authz.actor, scopedRegistry, runtimeContext)), admission, request.signal);
|
|
310
|
+
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);
|
|
355
311
|
}
|
|
356
312
|
catch (error) {
|
|
357
313
|
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
|
}
|