dxcomplete 0.2.0 → 0.2.2
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/.env.example +0 -7
- package/README.md +18 -54
- package/dist/cli.js +0 -22
- package/dist/validate.js +10 -26
- package/docs/model.md +3 -3
- package/docs/taxonomy.md +1 -1
- package/package.json +23 -23
- package/templates/process/README.md +1 -1
- package/dist/http/service.d.ts +0 -7
- package/dist/http/service.js +0 -725
- package/dist/mcp/docs.d.ts +0 -114
- package/dist/mcp/docs.js +0 -626
- package/dist/mcp/server.d.ts +0 -20
- package/dist/mcp/server.js +0 -3059
- package/dist/runtime/auth.d.ts +0 -162
- package/dist/runtime/auth.js +0 -394
- package/dist/runtime/check.d.ts +0 -7
- package/dist/runtime/check.js +0 -16
- package/dist/runtime/config.d.ts +0 -17
- package/dist/runtime/config.js +0 -93
- package/dist/runtime/mongo.d.ts +0 -9
- package/dist/runtime/mongo.js +0 -56
- package/dist/runtime/records.d.ts +0 -427
- package/dist/runtime/records.js +0 -2092
- package/scripts/check-env-surface.mjs +0 -136
- package/scripts/check-public-copy.mjs +0 -263
- package/scripts/check-service-boundary.mjs +0 -63
- package/scripts/dogfood-work-order.mjs +0 -506
- package/scripts/smoke-mcp-http.mjs +0 -4026
- package/src/cli.ts +0 -268
- package/src/http/server.ts +0 -314
- package/src/http/service.ts +0 -934
- package/src/init.ts +0 -262
- package/src/install-manifest.ts +0 -144
- package/src/mcp/docs.ts +0 -777
- package/src/mcp/server.ts +0 -4580
- package/src/package-root.ts +0 -31
- package/src/runtime/actor.ts +0 -61
- package/src/runtime/auth.ts +0 -673
- package/src/runtime/check.ts +0 -18
- package/src/runtime/config.ts +0 -128
- package/src/runtime/mongo.ts +0 -89
- package/src/runtime/records.ts +0 -3205
- package/src/runtime/workspace.ts +0 -155
- package/src/upgrade.ts +0 -356
- package/src/validate.ts +0 -141
- package/src/version.ts +0 -16
package/dist/http/service.js
DELETED
|
@@ -1,725 +0,0 @@
|
|
|
1
|
-
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
2
|
-
import { assertWorkspaceMembership, consumeOAuthAuthorizationRequest, createGoogleActor, createOAuthAuthorizationCode, createOAuthAuthorizationRequest, createWorkspaceServiceClient, ensureWorkspaceBootstrap, exchangeGoogleCodeForProfile, exchangeOAuthAuthorizationCode, exchangeOAuthRefreshToken, getOAuthClient, googleAuthorizationUrl, membershipRoles, registerOAuthClient, upsertWorkspaceMembership, verifyWorkspaceServiceClient, verifyMcpAccessToken } from "../runtime/auth.js";
|
|
3
|
-
import { createMcpServer } from "../mcp/server.js";
|
|
4
|
-
import { connectRuntime } from "../runtime/mongo.js";
|
|
5
|
-
import { RUNTIME_ACTOR_ID } from "../runtime/records.js";
|
|
6
|
-
const MCP_PATH = "/api/mcp";
|
|
7
|
-
const GOOGLE_CALLBACK_PATH = "/api/auth/callback/google";
|
|
8
|
-
const MCP_SCOPE = "mcp:tools";
|
|
9
|
-
const AUTH_DIAGNOSTICS_COLLECTION = "auth_diagnostics";
|
|
10
|
-
let runtimePromise;
|
|
11
|
-
export async function closeDxcompleteServiceRuntime() {
|
|
12
|
-
if (runtimePromise) {
|
|
13
|
-
const runtime = await runtimePromise;
|
|
14
|
-
await runtime.close();
|
|
15
|
-
}
|
|
16
|
-
runtimePromise = undefined;
|
|
17
|
-
}
|
|
18
|
-
export async function closeDxcompleteHttpRuntime() {
|
|
19
|
-
await closeDxcompleteServiceRuntime();
|
|
20
|
-
}
|
|
21
|
-
export default async function handleDxcompleteServiceRequest(req, res) {
|
|
22
|
-
try {
|
|
23
|
-
setCorsHeaders(res);
|
|
24
|
-
if (req.method === "OPTIONS") {
|
|
25
|
-
res.writeHead(204).end();
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
const runtime = await getRuntime();
|
|
29
|
-
const ownBaseUrl = getBaseUrl(req);
|
|
30
|
-
const requestUrl = new URL(req.url ?? "/", ownBaseUrl);
|
|
31
|
-
const path = normalizeServicePath(requestUrl.pathname);
|
|
32
|
-
if (path === "/api/dxcomplete/service/provision") {
|
|
33
|
-
await handleWorkspaceProvisioning(req, res, runtime);
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
const workspaceConfig = await authenticateWorkspaceService(runtime, req);
|
|
37
|
-
const baseUrl = forwardedWorkspaceBaseUrl(req) ?? ownBaseUrl;
|
|
38
|
-
if (isProtectedResourceMetadataPath(path)) {
|
|
39
|
-
writeJson(res, 200, protectedResourceMetadata(baseUrl));
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
if (isAuthorizationServerMetadataPath(path)) {
|
|
43
|
-
writeJson(res, 200, authorizationServerMetadata(baseUrl));
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
if (path === "/api/dxcomplete/auth/register") {
|
|
47
|
-
await handleClientRegistration(req, res, runtime);
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
if (path === "/api/dxcomplete/auth/authorize") {
|
|
51
|
-
await handleAuthorize(req, res, runtime, workspaceConfig, baseUrl, requestUrl);
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
if (path === GOOGLE_CALLBACK_PATH) {
|
|
55
|
-
await handleGoogleCallback(res, runtime, workspaceConfig, baseUrl, requestUrl);
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
if (path === "/api/dxcomplete/auth/token") {
|
|
59
|
-
await handleToken(req, res, runtime, baseUrl);
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
if (path === MCP_PATH) {
|
|
63
|
-
await handleMcp(req, res, baseUrl, workspaceConfig);
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
writeJson(res, 404, { error: "not_found" });
|
|
67
|
-
}
|
|
68
|
-
catch (error) {
|
|
69
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
70
|
-
const responseError = responseErrorForMessage(message);
|
|
71
|
-
if (!res.headersSent) {
|
|
72
|
-
writeJson(res, responseError.status, {
|
|
73
|
-
error: responseError.error,
|
|
74
|
-
error_description: message
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
res.end();
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
function responseErrorForMessage(message) {
|
|
83
|
-
if (message.includes("service client") || message.includes("service credentials") || message.includes("Provisioning")) {
|
|
84
|
-
return { status: 401, error: "unauthorized" };
|
|
85
|
-
}
|
|
86
|
-
if (message.includes("Workspace access denied")) {
|
|
87
|
-
return { status: 403, error: "access_denied" };
|
|
88
|
-
}
|
|
89
|
-
if (message.includes("OAuth client was not found")) {
|
|
90
|
-
return { status: 400, error: "invalid_client" };
|
|
91
|
-
}
|
|
92
|
-
if (message.includes("authorization request expired") ||
|
|
93
|
-
message.includes("Authorization code expired") ||
|
|
94
|
-
message.includes("Invalid PKCE verifier") ||
|
|
95
|
-
message.includes("Refresh token") ||
|
|
96
|
-
message.includes("Token expired")) {
|
|
97
|
-
return { status: 400, error: "invalid_grant" };
|
|
98
|
-
}
|
|
99
|
-
if (message.includes("redirect_uri") ||
|
|
100
|
-
message.includes("response_type") ||
|
|
101
|
-
message.includes("code_challenge") ||
|
|
102
|
-
message.includes("grant_type") ||
|
|
103
|
-
message.includes("OAuth resource")) {
|
|
104
|
-
return { status: 400, error: "invalid_request" };
|
|
105
|
-
}
|
|
106
|
-
return { status: 500, error: "server_error" };
|
|
107
|
-
}
|
|
108
|
-
async function authenticateWorkspaceService(runtime, req) {
|
|
109
|
-
const serviceClient = await verifyWorkspaceServiceClient(runtime.db, {
|
|
110
|
-
clientId: firstHeader(req.headers["x-dxc-service-client-id"]),
|
|
111
|
-
secret: firstHeader(req.headers["x-dxc-service-client-secret"]),
|
|
112
|
-
workspaceId: firstHeader(req.headers["x-dxc-workspace-id"])
|
|
113
|
-
});
|
|
114
|
-
const workspace = await runtime.db.collection("workspaces").findOne({ _id: serviceClient.workspaceId });
|
|
115
|
-
return {
|
|
116
|
-
workspaceId: serviceClient.workspaceId,
|
|
117
|
-
name: readRecordName(workspace) ??
|
|
118
|
-
firstHeader(req.headers["x-dxc-workspace-name"]) ??
|
|
119
|
-
serviceClient.workspaceId,
|
|
120
|
-
bootstrapMembers: []
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
async function handleWorkspaceProvisioning(req, res, runtime) {
|
|
124
|
-
if (req.method !== "POST") {
|
|
125
|
-
writeJson(res, 405, { error: "method_not_allowed" });
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
const expectedSecret = runtime.config.serviceProvisioningSecret;
|
|
129
|
-
const suppliedSecret = readProvisioningSecret(req);
|
|
130
|
-
if (!expectedSecret || !suppliedSecret || suppliedSecret !== expectedSecret) {
|
|
131
|
-
writeJson(res, 401, { error: "unauthorized", error_description: "Provisioning authorization failed." });
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
const body = await readJsonBody(req);
|
|
135
|
-
const workspaceId = readRequiredBodyString(body, "workspaceId");
|
|
136
|
-
const name = readRequiredBodyString(body, "name");
|
|
137
|
-
const ownerEmail = readRequiredBodyString(body, "ownerEmail");
|
|
138
|
-
const serviceClientName = readRequiredBodyString(body, "serviceClientName");
|
|
139
|
-
const mode = readOptionalWorkspaceMode(body.mode);
|
|
140
|
-
await ensureWorkspaceBootstrap(runtime.db, {
|
|
141
|
-
workspaceId,
|
|
142
|
-
name,
|
|
143
|
-
...(mode ? { mode } : {}),
|
|
144
|
-
bootstrapMembers: [{ email: ownerEmail, roles: ["owner"] }]
|
|
145
|
-
}, RUNTIME_ACTOR_ID);
|
|
146
|
-
const membership = await upsertWorkspaceMembership(runtime.db, {
|
|
147
|
-
workspaceId,
|
|
148
|
-
email: ownerEmail,
|
|
149
|
-
roles: ["owner"],
|
|
150
|
-
actorId: RUNTIME_ACTOR_ID
|
|
151
|
-
});
|
|
152
|
-
const { record: serviceClient, secret } = await createWorkspaceServiceClient(runtime.db, {
|
|
153
|
-
workspaceId,
|
|
154
|
-
name: serviceClientName,
|
|
155
|
-
actorId: RUNTIME_ACTOR_ID
|
|
156
|
-
});
|
|
157
|
-
const workspace = await runtime.db.collection("workspaces").findOne({ _id: workspaceId });
|
|
158
|
-
writeJson(res, 201, {
|
|
159
|
-
workspace,
|
|
160
|
-
ownerMembership: {
|
|
161
|
-
_id: membership._id,
|
|
162
|
-
workspaceId: membership.workspaceId,
|
|
163
|
-
email: membership.email,
|
|
164
|
-
roles: membershipRoles(membership)
|
|
165
|
-
},
|
|
166
|
-
serviceClient: {
|
|
167
|
-
clientId: serviceClient.clientId,
|
|
168
|
-
name: serviceClient.name,
|
|
169
|
-
workspaceId: serviceClient.workspaceId,
|
|
170
|
-
secret
|
|
171
|
-
}
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
function readRecordName(workspace) {
|
|
175
|
-
if (!workspace) {
|
|
176
|
-
return undefined;
|
|
177
|
-
}
|
|
178
|
-
if (typeof workspace.fields.name === "string" && workspace.fields.name.trim()) {
|
|
179
|
-
return workspace.fields.name.trim();
|
|
180
|
-
}
|
|
181
|
-
return workspace.title;
|
|
182
|
-
}
|
|
183
|
-
function readProvisioningSecret(req) {
|
|
184
|
-
const authorization = firstHeader(req.headers.authorization);
|
|
185
|
-
const bearerMatch = authorization?.match(/^Bearer\s+(.+)$/i);
|
|
186
|
-
return bearerMatch?.[1]?.trim() || firstHeader(req.headers["x-dxc-provisioning-secret"])?.trim();
|
|
187
|
-
}
|
|
188
|
-
function readRequiredBodyString(body, key) {
|
|
189
|
-
const value = body[key];
|
|
190
|
-
if (typeof value !== "string" || !value.trim()) {
|
|
191
|
-
throw new Error(`${key} is required.`);
|
|
192
|
-
}
|
|
193
|
-
return value.trim();
|
|
194
|
-
}
|
|
195
|
-
function readOptionalWorkspaceMode(value) {
|
|
196
|
-
if (value === undefined) {
|
|
197
|
-
return undefined;
|
|
198
|
-
}
|
|
199
|
-
if (value === "transformation" || value === "greenfield" || value === "limited-disclosure") {
|
|
200
|
-
return value;
|
|
201
|
-
}
|
|
202
|
-
throw new Error("mode must be transformation, greenfield, or limited-disclosure when provided.");
|
|
203
|
-
}
|
|
204
|
-
function forwardedWorkspaceBaseUrl(req) {
|
|
205
|
-
const value = firstHeader(req.headers["x-dxc-forwarded-base-url"]);
|
|
206
|
-
if (!value) {
|
|
207
|
-
return undefined;
|
|
208
|
-
}
|
|
209
|
-
return new URL(value).origin;
|
|
210
|
-
}
|
|
211
|
-
async function handleMcp(req, res, baseUrl, workspaceConfig) {
|
|
212
|
-
const resource = mcpResourceUrl(baseUrl);
|
|
213
|
-
const token = readBearerToken(req);
|
|
214
|
-
if (!token) {
|
|
215
|
-
await drainRequestBody(req);
|
|
216
|
-
writeOAuthChallenge(res, baseUrl);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
const runtime = await getRuntime();
|
|
220
|
-
let tokenRecord;
|
|
221
|
-
let membership;
|
|
222
|
-
try {
|
|
223
|
-
tokenRecord = await verifyMcpAccessToken(runtime.db, {
|
|
224
|
-
token,
|
|
225
|
-
workspaceId: workspaceConfig.workspaceId,
|
|
226
|
-
resource
|
|
227
|
-
});
|
|
228
|
-
membership = await assertWorkspaceMembership(runtime.db, workspaceConfig.workspaceId, tokenRecord.actor);
|
|
229
|
-
}
|
|
230
|
-
catch (error) {
|
|
231
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
232
|
-
await recordAuthDiagnostic(runtime.db, "mcp.token_rejected", req, {
|
|
233
|
-
workspaceId: workspaceConfig.workspaceId,
|
|
234
|
-
resource,
|
|
235
|
-
error: message,
|
|
236
|
-
status: message.includes("Workspace access denied") ? 403 : 401
|
|
237
|
-
});
|
|
238
|
-
if (message.includes("Workspace access denied")) {
|
|
239
|
-
await drainRequestBody(req);
|
|
240
|
-
writeJson(res, 403, { error: "access_denied", error_description: "Workspace access denied." });
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
await drainRequestBody(req);
|
|
244
|
-
writeOAuthChallenge(res, baseUrl);
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
const server = createMcpServer(runtime, {
|
|
248
|
-
actor: tokenRecord.actor,
|
|
249
|
-
recordActorId: tokenRecord.actor.actorId,
|
|
250
|
-
hostedWorkspace: {
|
|
251
|
-
workspaceId: workspaceConfig.workspaceId,
|
|
252
|
-
name: workspaceConfig.name
|
|
253
|
-
},
|
|
254
|
-
workspaceRoles: membershipRoles(membership),
|
|
255
|
-
hostedHttp: {
|
|
256
|
-
canonicalMcpPath: MCP_PATH,
|
|
257
|
-
canonicalMcpUrl: resource,
|
|
258
|
-
protectedResourceMetadataUrl: protectedResourceMetadataUrl(baseUrl),
|
|
259
|
-
googleCallbackUrl: googleCallbackUrl(baseUrl)
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
const transport = new StreamableHTTPServerTransport({
|
|
263
|
-
sessionIdGenerator: undefined,
|
|
264
|
-
enableJsonResponse: true
|
|
265
|
-
});
|
|
266
|
-
req.auth = {
|
|
267
|
-
token,
|
|
268
|
-
clientId: tokenRecord.clientId,
|
|
269
|
-
scopes: tokenRecord.scope.split(/\s+/).filter(Boolean),
|
|
270
|
-
expiresAt: Math.floor(new Date(tokenRecord.expiresAt).getTime() / 1000),
|
|
271
|
-
resource: new URL(resource),
|
|
272
|
-
extra: {
|
|
273
|
-
actor: tokenRecord.actor,
|
|
274
|
-
workspaceId: tokenRecord.workspaceId
|
|
275
|
-
}
|
|
276
|
-
};
|
|
277
|
-
await recordAuthDiagnostic(runtime.db, "mcp.token_accepted", req, {
|
|
278
|
-
clientId: tokenRecord.clientId,
|
|
279
|
-
workspaceId: tokenRecord.workspaceId,
|
|
280
|
-
resource: tokenRecord.resource,
|
|
281
|
-
actorEmail: tokenRecord.actor.email,
|
|
282
|
-
status: 200
|
|
283
|
-
});
|
|
284
|
-
ensureStreamableHttpAcceptHeader(req);
|
|
285
|
-
await server.connect(transport);
|
|
286
|
-
const parsedBody = req.method === "POST" ? await readJsonBody(req) : undefined;
|
|
287
|
-
try {
|
|
288
|
-
await transport.handleRequest(req, res, parsedBody);
|
|
289
|
-
await recordAuthDiagnostic(runtime.db, "mcp.transport_completed", req, {
|
|
290
|
-
clientId: tokenRecord.clientId,
|
|
291
|
-
workspaceId: tokenRecord.workspaceId,
|
|
292
|
-
resource: tokenRecord.resource,
|
|
293
|
-
actorEmail: tokenRecord.actor.email,
|
|
294
|
-
status: res.statusCode,
|
|
295
|
-
responseContentType: firstHeader(res.getHeader("content-type"))
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
catch (error) {
|
|
299
|
-
await recordAuthDiagnostic(runtime.db, "mcp.transport_failed", req, {
|
|
300
|
-
clientId: tokenRecord.clientId,
|
|
301
|
-
workspaceId: tokenRecord.workspaceId,
|
|
302
|
-
resource: tokenRecord.resource,
|
|
303
|
-
actorEmail: tokenRecord.actor.email,
|
|
304
|
-
error: error instanceof Error ? error.message : String(error),
|
|
305
|
-
status: res.statusCode
|
|
306
|
-
});
|
|
307
|
-
throw error;
|
|
308
|
-
}
|
|
309
|
-
res.on("close", () => {
|
|
310
|
-
void transport.close().catch(() => undefined);
|
|
311
|
-
void server.close().catch(() => undefined);
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
async function handleClientRegistration(req, res, runtime) {
|
|
315
|
-
if (req.method !== "POST") {
|
|
316
|
-
writeJson(res, 405, { error: "method_not_allowed" });
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
const body = await readJsonBody(req);
|
|
320
|
-
const redirectUris = readStringArray(body.redirect_uris);
|
|
321
|
-
const client = await registerOAuthClient(runtime.db, {
|
|
322
|
-
clientName: typeof body.client_name === "string" ? body.client_name : undefined,
|
|
323
|
-
redirectUris,
|
|
324
|
-
grantTypes: readStringArray(body.grant_types, ["authorization_code", "refresh_token"]),
|
|
325
|
-
responseTypes: readStringArray(body.response_types, ["code"])
|
|
326
|
-
});
|
|
327
|
-
await recordAuthDiagnostic(runtime.db, "oauth.client_registered", req, {
|
|
328
|
-
clientId: client.clientId,
|
|
329
|
-
redirectUris: client.redirectUris,
|
|
330
|
-
status: 201
|
|
331
|
-
});
|
|
332
|
-
writeJson(res, 201, {
|
|
333
|
-
client_id: client.clientId,
|
|
334
|
-
client_id_issued_at: Math.floor(new Date(client.createdAt).getTime() / 1000),
|
|
335
|
-
redirect_uris: client.redirectUris,
|
|
336
|
-
grant_types: client.grantTypes,
|
|
337
|
-
response_types: client.responseTypes,
|
|
338
|
-
token_endpoint_auth_method: "none"
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
async function handleAuthorize(req, res, runtime, workspaceConfig, baseUrl, requestUrl) {
|
|
342
|
-
if (req.method !== "GET") {
|
|
343
|
-
writeJson(res, 405, { error: "method_not_allowed" });
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
const clientId = requiredParam(requestUrl, "client_id");
|
|
347
|
-
const redirectUri = requiredParam(requestUrl, "redirect_uri");
|
|
348
|
-
const responseType = requiredParam(requestUrl, "response_type");
|
|
349
|
-
const codeChallenge = requiredParam(requestUrl, "code_challenge");
|
|
350
|
-
const codeChallengeMethod = requiredParam(requestUrl, "code_challenge_method");
|
|
351
|
-
const resource = requestUrl.searchParams.get("resource") || mcpResourceUrl(baseUrl);
|
|
352
|
-
if (responseType !== "code") {
|
|
353
|
-
throw new Error("Only authorization code response_type is supported.");
|
|
354
|
-
}
|
|
355
|
-
if (codeChallengeMethod !== "S256") {
|
|
356
|
-
throw new Error("Only S256 PKCE is supported.");
|
|
357
|
-
}
|
|
358
|
-
const client = await getOAuthClient(runtime.db, clientId);
|
|
359
|
-
if (!client) {
|
|
360
|
-
throw new Error("OAuth client was not found.");
|
|
361
|
-
}
|
|
362
|
-
if (!client.redirectUris.includes(redirectUri)) {
|
|
363
|
-
throw new Error("redirect_uri was not registered for this OAuth client.");
|
|
364
|
-
}
|
|
365
|
-
if (resource !== mcpResourceUrl(baseUrl)) {
|
|
366
|
-
throw new Error("OAuth resource must match this MCP endpoint.");
|
|
367
|
-
}
|
|
368
|
-
const authRequest = await createOAuthAuthorizationRequest(runtime.db, {
|
|
369
|
-
clientId,
|
|
370
|
-
redirectUri,
|
|
371
|
-
codeChallenge,
|
|
372
|
-
codeChallengeMethod: "S256",
|
|
373
|
-
state: requestUrl.searchParams.get("state") ?? undefined,
|
|
374
|
-
scope: requestUrl.searchParams.get("scope") || MCP_SCOPE,
|
|
375
|
-
resource,
|
|
376
|
-
workspaceId: workspaceConfig.workspaceId
|
|
377
|
-
});
|
|
378
|
-
const googleRedirectUri = googleCallbackUrl(baseUrl);
|
|
379
|
-
await recordAuthDiagnostic(runtime.db, "oauth.authorize_redirect", req, {
|
|
380
|
-
clientId,
|
|
381
|
-
workspaceId: workspaceConfig.workspaceId,
|
|
382
|
-
resource,
|
|
383
|
-
redirectUri,
|
|
384
|
-
googleRedirectUri,
|
|
385
|
-
status: 302
|
|
386
|
-
});
|
|
387
|
-
redirect(res, googleAuthorizationUrl(runtime.config, {
|
|
388
|
-
redirectUri: googleRedirectUri,
|
|
389
|
-
state: authRequest._id
|
|
390
|
-
}));
|
|
391
|
-
}
|
|
392
|
-
async function handleGoogleCallback(res, runtime, workspaceConfig, baseUrl, requestUrl) {
|
|
393
|
-
const state = requiredParam(requestUrl, "state");
|
|
394
|
-
const googleCode = requiredParam(requestUrl, "code");
|
|
395
|
-
const authRequest = await consumeOAuthAuthorizationRequest(runtime.db, state);
|
|
396
|
-
const profile = await exchangeGoogleCodeForProfile(runtime.config, {
|
|
397
|
-
code: googleCode,
|
|
398
|
-
redirectUri: googleCallbackUrl(baseUrl)
|
|
399
|
-
});
|
|
400
|
-
const actor = createGoogleActor(profile);
|
|
401
|
-
await assertWorkspaceMembership(runtime.db, workspaceConfig.workspaceId, actor);
|
|
402
|
-
const code = await createOAuthAuthorizationCode(runtime.db, authRequest, actor);
|
|
403
|
-
await recordAuthDiagnostic(runtime.db, "oauth.callback_authorized", undefined, {
|
|
404
|
-
clientId: authRequest.clientId,
|
|
405
|
-
workspaceId: workspaceConfig.workspaceId,
|
|
406
|
-
resource: authRequest.resource,
|
|
407
|
-
redirectUri: authRequest.redirectUri,
|
|
408
|
-
actorEmail: actor.email,
|
|
409
|
-
status: 302
|
|
410
|
-
});
|
|
411
|
-
const callback = new URL(authRequest.redirectUri);
|
|
412
|
-
callback.searchParams.set("code", code);
|
|
413
|
-
if (authRequest.state) {
|
|
414
|
-
callback.searchParams.set("state", authRequest.state);
|
|
415
|
-
}
|
|
416
|
-
redirect(res, callback.href);
|
|
417
|
-
}
|
|
418
|
-
async function handleToken(req, res, runtime, baseUrl) {
|
|
419
|
-
if (req.method !== "POST") {
|
|
420
|
-
writeJson(res, 405, { error: "method_not_allowed" });
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
const body = await readFormBody(req);
|
|
424
|
-
const grantType = requiredFormValue(body, "grant_type");
|
|
425
|
-
const clientId = readClientId(req, body);
|
|
426
|
-
const client = await getOAuthClient(runtime.db, clientId);
|
|
427
|
-
const resource = body.get("resource") || mcpResourceUrl(baseUrl);
|
|
428
|
-
if (!client) {
|
|
429
|
-
throw new Error("OAuth client was not found.");
|
|
430
|
-
}
|
|
431
|
-
if (resource !== mcpResourceUrl(baseUrl)) {
|
|
432
|
-
throw new Error("OAuth resource must match this MCP endpoint.");
|
|
433
|
-
}
|
|
434
|
-
if (grantType === "authorization_code") {
|
|
435
|
-
const redirectUri = requiredFormValue(body, "redirect_uri");
|
|
436
|
-
const tokenPair = await exchangeOAuthAuthorizationCode(runtime.db, {
|
|
437
|
-
code: requiredFormValue(body, "code"),
|
|
438
|
-
clientId,
|
|
439
|
-
redirectUri,
|
|
440
|
-
codeVerifier: requiredFormValue(body, "code_verifier"),
|
|
441
|
-
resource
|
|
442
|
-
});
|
|
443
|
-
await recordAuthDiagnostic(runtime.db, "oauth.token_issued", req, {
|
|
444
|
-
clientId,
|
|
445
|
-
resource,
|
|
446
|
-
redirectUri,
|
|
447
|
-
grantType,
|
|
448
|
-
status: 200
|
|
449
|
-
});
|
|
450
|
-
writeTokenResponse(res, tokenPair);
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
if (grantType === "refresh_token") {
|
|
454
|
-
const tokenPair = await exchangeOAuthRefreshToken(runtime.db, {
|
|
455
|
-
refreshToken: requiredFormValue(body, "refresh_token"),
|
|
456
|
-
clientId,
|
|
457
|
-
resource
|
|
458
|
-
});
|
|
459
|
-
await recordAuthDiagnostic(runtime.db, "oauth.token_refreshed", req, {
|
|
460
|
-
clientId,
|
|
461
|
-
resource,
|
|
462
|
-
grantType,
|
|
463
|
-
status: 200
|
|
464
|
-
});
|
|
465
|
-
writeTokenResponse(res, tokenPair);
|
|
466
|
-
return;
|
|
467
|
-
}
|
|
468
|
-
writeJson(res, 400, { error: "unsupported_grant_type" });
|
|
469
|
-
}
|
|
470
|
-
function protectedResourceMetadata(baseUrl) {
|
|
471
|
-
return {
|
|
472
|
-
resource: mcpResourceUrl(baseUrl),
|
|
473
|
-
authorization_servers: [baseUrl],
|
|
474
|
-
scopes_supported: [MCP_SCOPE],
|
|
475
|
-
resource_name: "DX Complete"
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
function authorizationServerMetadata(baseUrl) {
|
|
479
|
-
return {
|
|
480
|
-
issuer: baseUrl,
|
|
481
|
-
authorization_endpoint: `${baseUrl}/api/dxcomplete/auth/authorize`,
|
|
482
|
-
token_endpoint: `${baseUrl}/api/dxcomplete/auth/token`,
|
|
483
|
-
registration_endpoint: `${baseUrl}/api/dxcomplete/auth/register`,
|
|
484
|
-
response_types_supported: ["code"],
|
|
485
|
-
code_challenge_methods_supported: ["S256"],
|
|
486
|
-
token_endpoint_auth_methods_supported: ["none"],
|
|
487
|
-
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
488
|
-
scopes_supported: [MCP_SCOPE]
|
|
489
|
-
};
|
|
490
|
-
}
|
|
491
|
-
function writeTokenResponse(res, tokenPair) {
|
|
492
|
-
res.setHeader("cache-control", "no-store");
|
|
493
|
-
res.setHeader("pragma", "no-cache");
|
|
494
|
-
writeJson(res, 200, {
|
|
495
|
-
access_token: tokenPair.accessToken,
|
|
496
|
-
refresh_token: tokenPair.refreshToken,
|
|
497
|
-
token_type: "Bearer",
|
|
498
|
-
expires_in: tokenPair.expiresIn,
|
|
499
|
-
scope: tokenPair.scope
|
|
500
|
-
});
|
|
501
|
-
}
|
|
502
|
-
function writeOAuthChallenge(res, baseUrl) {
|
|
503
|
-
res.setHeader("www-authenticate", `Bearer resource_metadata="${protectedResourceMetadataUrl(baseUrl)}", scope="${MCP_SCOPE}"`);
|
|
504
|
-
writeJson(res, 401, { error: "unauthorized" });
|
|
505
|
-
}
|
|
506
|
-
function ensureStreamableHttpAcceptHeader(req) {
|
|
507
|
-
const accept = firstHeader(req.headers.accept)?.toLowerCase() ?? "";
|
|
508
|
-
if (req.method === "POST" && (!accept.includes("application/json") || !accept.includes("text/event-stream"))) {
|
|
509
|
-
setIncomingHeader(req, "accept", "application/json, text/event-stream");
|
|
510
|
-
}
|
|
511
|
-
if (req.method === "GET" && !accept.includes("text/event-stream")) {
|
|
512
|
-
setIncomingHeader(req, "accept", "text/event-stream");
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
function setIncomingHeader(req, key, value) {
|
|
516
|
-
const lowerKey = key.toLowerCase();
|
|
517
|
-
req.headers[lowerKey] = value;
|
|
518
|
-
const rawIndex = req.rawHeaders.findIndex((entry) => entry.toLowerCase() === lowerKey);
|
|
519
|
-
if (rawIndex >= 0) {
|
|
520
|
-
req.rawHeaders[rawIndex + 1] = value;
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
|
-
req.rawHeaders.push(key, value);
|
|
524
|
-
}
|
|
525
|
-
async function recordAuthDiagnostic(db, event, req, fields) {
|
|
526
|
-
try {
|
|
527
|
-
await db.collection(AUTH_DIAGNOSTICS_COLLECTION).insertOne({
|
|
528
|
-
event,
|
|
529
|
-
createdAt: new Date().toISOString(),
|
|
530
|
-
method: req?.method,
|
|
531
|
-
path: req ? normalizePath(new URL(req.url ?? "/", "http://localhost").pathname) : undefined,
|
|
532
|
-
headers: req ? diagnosticHeaders(req) : undefined,
|
|
533
|
-
...sanitizeDiagnosticFields(fields)
|
|
534
|
-
});
|
|
535
|
-
}
|
|
536
|
-
catch {
|
|
537
|
-
// Diagnostics must never affect the OAuth or MCP response path.
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
function diagnosticHeaders(req) {
|
|
541
|
-
return {
|
|
542
|
-
accept: firstHeader(req.headers.accept),
|
|
543
|
-
contentType: firstHeader(req.headers["content-type"]),
|
|
544
|
-
mcpProtocolVersion: firstHeader(req.headers["mcp-protocol-version"]),
|
|
545
|
-
origin: firstHeader(req.headers.origin),
|
|
546
|
-
userAgent: firstHeader(req.headers["user-agent"])
|
|
547
|
-
};
|
|
548
|
-
}
|
|
549
|
-
function sanitizeDiagnosticFields(fields) {
|
|
550
|
-
const sanitized = {};
|
|
551
|
-
for (const [key, value] of Object.entries(fields)) {
|
|
552
|
-
if (value === undefined) {
|
|
553
|
-
continue;
|
|
554
|
-
}
|
|
555
|
-
if (key.toLowerCase().includes("token") || key.toLowerCase().includes("code")) {
|
|
556
|
-
continue;
|
|
557
|
-
}
|
|
558
|
-
sanitized[key] = typeof value === "string" ? truncateDiagnosticValue(value) : value;
|
|
559
|
-
}
|
|
560
|
-
return sanitized;
|
|
561
|
-
}
|
|
562
|
-
function truncateDiagnosticValue(value) {
|
|
563
|
-
return value.length > 500 ? `${value.slice(0, 500)}...` : value;
|
|
564
|
-
}
|
|
565
|
-
function setCorsHeaders(res) {
|
|
566
|
-
res.setHeader("access-control-allow-origin", "*");
|
|
567
|
-
res.setHeader("access-control-allow-headers", "authorization,content-type,mcp-protocol-version,mcp-session-id,last-event-id,x-dxc-service-client-id,x-dxc-service-client-secret,x-dxc-workspace-id,x-dxc-workspace-name,x-dxc-forwarded-base-url,x-dxc-provisioning-secret");
|
|
568
|
-
res.setHeader("access-control-allow-methods", "GET,POST,DELETE,OPTIONS");
|
|
569
|
-
res.setHeader("access-control-expose-headers", "mcp-session-id,www-authenticate");
|
|
570
|
-
}
|
|
571
|
-
function writeJson(res, status, value) {
|
|
572
|
-
const body = JSON.stringify(value);
|
|
573
|
-
if (!res.headersSent) {
|
|
574
|
-
res.writeHead(status, {
|
|
575
|
-
"content-type": "application/json",
|
|
576
|
-
"content-length": String(Buffer.byteLength(body))
|
|
577
|
-
});
|
|
578
|
-
}
|
|
579
|
-
res.end(body);
|
|
580
|
-
}
|
|
581
|
-
function redirect(res, location) {
|
|
582
|
-
res.writeHead(302, { location });
|
|
583
|
-
res.end();
|
|
584
|
-
}
|
|
585
|
-
async function getRuntime() {
|
|
586
|
-
runtimePromise ??= connectRuntime();
|
|
587
|
-
return runtimePromise;
|
|
588
|
-
}
|
|
589
|
-
function getBaseUrl(req) {
|
|
590
|
-
const forwardedProto = firstHeader(req.headers["x-forwarded-proto"]);
|
|
591
|
-
const forwardedHost = firstHeader(req.headers["x-forwarded-host"]);
|
|
592
|
-
const host = forwardedHost || firstHeader(req.headers.host);
|
|
593
|
-
const protocol = forwardedProto || "http";
|
|
594
|
-
if (!host) {
|
|
595
|
-
throw new Error("Host header is required.");
|
|
596
|
-
}
|
|
597
|
-
return `${protocol}://${host}`;
|
|
598
|
-
}
|
|
599
|
-
function firstHeader(value) {
|
|
600
|
-
return Array.isArray(value) ? value[0] : value;
|
|
601
|
-
}
|
|
602
|
-
function normalizePath(pathname) {
|
|
603
|
-
if (pathname === "/api/mcp" || pathname === "/api/dxcomplete" || pathname === "/api/dxcomplete/mcp") {
|
|
604
|
-
return MCP_PATH;
|
|
605
|
-
}
|
|
606
|
-
return pathname.endsWith("/") && pathname.length > 1 ? pathname.slice(0, -1) : pathname;
|
|
607
|
-
}
|
|
608
|
-
function normalizeServicePath(pathname) {
|
|
609
|
-
const path = pathname.endsWith("/") && pathname.length > 1 ? pathname.slice(0, -1) : pathname;
|
|
610
|
-
switch (path) {
|
|
611
|
-
case "/api/dxcomplete/service/mcp":
|
|
612
|
-
return MCP_PATH;
|
|
613
|
-
case "/api/dxcomplete/service/auth/register":
|
|
614
|
-
return "/api/dxcomplete/auth/register";
|
|
615
|
-
case "/api/dxcomplete/service/auth/authorize":
|
|
616
|
-
return "/api/dxcomplete/auth/authorize";
|
|
617
|
-
case "/api/dxcomplete/service/auth/google/callback":
|
|
618
|
-
return GOOGLE_CALLBACK_PATH;
|
|
619
|
-
case "/api/dxcomplete/service/auth/token":
|
|
620
|
-
return "/api/dxcomplete/auth/token";
|
|
621
|
-
default:
|
|
622
|
-
return path;
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
function isProtectedResourceMetadataPath(pathname) {
|
|
626
|
-
return (pathname === protectedResourceMetadataPath() ||
|
|
627
|
-
pathname === "/.well-known/oauth-protected-resource/api/dxcomplete/mcp" ||
|
|
628
|
-
pathname === `/api/dxcomplete${protectedResourceMetadataPath()}`);
|
|
629
|
-
}
|
|
630
|
-
function isAuthorizationServerMetadataPath(pathname) {
|
|
631
|
-
return pathname === "/.well-known/oauth-authorization-server" || pathname === "/api/dxcomplete/.well-known/oauth-authorization-server";
|
|
632
|
-
}
|
|
633
|
-
function protectedResourceMetadataPath() {
|
|
634
|
-
return `/.well-known/oauth-protected-resource${MCP_PATH}`;
|
|
635
|
-
}
|
|
636
|
-
function protectedResourceMetadataUrl(baseUrl) {
|
|
637
|
-
return `${baseUrl}${protectedResourceMetadataPath()}`;
|
|
638
|
-
}
|
|
639
|
-
function mcpResourceUrl(baseUrl) {
|
|
640
|
-
return `${baseUrl}${MCP_PATH}`;
|
|
641
|
-
}
|
|
642
|
-
function googleCallbackUrl(baseUrl) {
|
|
643
|
-
return `${baseUrl}${GOOGLE_CALLBACK_PATH}`;
|
|
644
|
-
}
|
|
645
|
-
function readBearerToken(req) {
|
|
646
|
-
const authorization = firstHeader(req.headers.authorization);
|
|
647
|
-
const match = authorization?.match(/^Bearer\s+(.+)$/i);
|
|
648
|
-
return match?.[1]?.trim();
|
|
649
|
-
}
|
|
650
|
-
async function readJsonBody(req) {
|
|
651
|
-
if (req.body && typeof req.body === "object" && !Buffer.isBuffer(req.body)) {
|
|
652
|
-
return req.body;
|
|
653
|
-
}
|
|
654
|
-
const text = typeof req.body === "string" ? req.body : await readRawBody(req);
|
|
655
|
-
return text.trim() ? JSON.parse(text) : {};
|
|
656
|
-
}
|
|
657
|
-
async function readFormBody(req) {
|
|
658
|
-
if (req.body && typeof req.body === "object" && !Buffer.isBuffer(req.body)) {
|
|
659
|
-
const params = new URLSearchParams();
|
|
660
|
-
for (const [key, value] of Object.entries(req.body)) {
|
|
661
|
-
if (typeof value === "string") {
|
|
662
|
-
params.set(key, value);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
return params;
|
|
666
|
-
}
|
|
667
|
-
const text = typeof req.body === "string" ? req.body : await readRawBody(req);
|
|
668
|
-
return new URLSearchParams(text);
|
|
669
|
-
}
|
|
670
|
-
function readRawBody(req) {
|
|
671
|
-
return new Promise((resolve, reject) => {
|
|
672
|
-
let body = "";
|
|
673
|
-
req.setEncoding("utf8");
|
|
674
|
-
req.on("data", (chunk) => {
|
|
675
|
-
body += chunk;
|
|
676
|
-
});
|
|
677
|
-
req.on("end", () => resolve(body));
|
|
678
|
-
req.on("error", reject);
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
async function drainRequestBody(req) {
|
|
682
|
-
if (req.method !== "POST" || req.complete || req.destroyed) {
|
|
683
|
-
return;
|
|
684
|
-
}
|
|
685
|
-
await readRawBody(req).catch(() => undefined);
|
|
686
|
-
}
|
|
687
|
-
function readStringArray(value, fallback) {
|
|
688
|
-
if (value === undefined && fallback) {
|
|
689
|
-
return fallback;
|
|
690
|
-
}
|
|
691
|
-
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string" && entry.trim())) {
|
|
692
|
-
throw new Error("Expected a non-empty string array.");
|
|
693
|
-
}
|
|
694
|
-
return value.map((entry) => entry.trim());
|
|
695
|
-
}
|
|
696
|
-
function requiredParam(url, key) {
|
|
697
|
-
const value = url.searchParams.get(key)?.trim();
|
|
698
|
-
if (!value) {
|
|
699
|
-
throw new Error(`${key} is required.`);
|
|
700
|
-
}
|
|
701
|
-
return value;
|
|
702
|
-
}
|
|
703
|
-
function requiredFormValue(body, key) {
|
|
704
|
-
const value = body.get(key)?.trim();
|
|
705
|
-
if (!value) {
|
|
706
|
-
throw new Error(`${key} is required.`);
|
|
707
|
-
}
|
|
708
|
-
return value;
|
|
709
|
-
}
|
|
710
|
-
function readClientId(req, body) {
|
|
711
|
-
const bodyClientId = body.get("client_id")?.trim();
|
|
712
|
-
if (bodyClientId) {
|
|
713
|
-
return bodyClientId;
|
|
714
|
-
}
|
|
715
|
-
const authorization = firstHeader(req.headers.authorization);
|
|
716
|
-
const match = authorization?.match(/^Basic\s+(.+)$/i);
|
|
717
|
-
if (match?.[1]) {
|
|
718
|
-
const decoded = Buffer.from(match[1], "base64").toString("utf8");
|
|
719
|
-
const [clientId] = decoded.split(":");
|
|
720
|
-
if (clientId) {
|
|
721
|
-
return clientId;
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
throw new Error("client_id is required.");
|
|
725
|
-
}
|