@decocms/runtime 1.0.0-alpha.5 → 1.0.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/src/index.ts CHANGED
@@ -1,56 +1,49 @@
1
1
  /* oxlint-disable no-explicit-any */
2
- import type { ExecutionContext } from "@cloudflare/workers-types";
3
2
  import { decodeJwt } from "jose";
4
3
  import type { z } from "zod";
5
4
  import {
6
- getReqToken,
7
- handleAuthCallback,
8
- handleLogout,
9
- StateParser,
10
- } from "./auth.ts";
11
- import {
12
- createContractBinding,
13
- createIntegrationBinding,
14
- workspaceClient,
5
+ BindingRegistry,
6
+ initializeBindings,
7
+ ResolvedBindings,
15
8
  } from "./bindings.ts";
16
- import { DeconfigResource } from "./bindings/deconfig/index.ts";
17
- import { DECO_MCP_CLIENT_HEADER } from "./client.ts";
9
+ import { type CORSOptions, handlePreflight, withCORS } from "./cors.ts";
10
+ import { createOAuthHandlers } from "./oauth.ts";
11
+ import { State } from "./state.ts";
18
12
  import {
19
13
  createMCPServer,
20
14
  type CreateMCPServerOptions,
21
15
  MCPServer,
22
- } from "./mastra.ts";
23
- import { MCPClient, type QueryResult } from "./mcp.ts";
24
- import { State } from "./state.ts";
25
- import type { Binding, ContractBinding, MCPBinding } from "./wrangler.ts";
26
- export { proxyConnectionForId } from "./bindings.ts";
16
+ } from "./tools.ts";
17
+ export {
18
+ createPrompt,
19
+ createPublicPrompt,
20
+ type Prompt,
21
+ type PromptArgsRawShape,
22
+ type PromptExecutionContext,
23
+ type CreatedPrompt,
24
+ type GetPromptResult,
25
+ } from "./tools.ts";
26
+ import type { Binding } from "./wrangler.ts";
27
+ export { proxyConnectionForId, BindingOf } from "./bindings.ts";
28
+ export { type CORSOptions, type CORSOrigin } from "./cors.ts";
27
29
  export {
28
30
  createMCPFetchStub,
29
31
  type CreateStubAPIOptions,
30
32
  type ToolBinder,
31
33
  } from "./mcp.ts";
32
- export interface WorkspaceDB {
33
- query: (params: {
34
- sql: string;
35
- params: string[];
36
- }) => Promise<{ result: QueryResult[] }>;
37
- }
38
34
 
39
- export interface DefaultEnv<TSchema extends z.ZodTypeAny = any> {
40
- DECO_REQUEST_CONTEXT: RequestContext<TSchema>;
41
- DECO_APP_NAME: string;
42
- DECO_APP_SLUG: string;
43
- DECO_APP_ENTRYPOINT: string;
44
- DECO_API_URL?: string;
45
- DECO_WORKSPACE: string;
46
- DECO_API_JWT_PUBLIC_KEY: string;
47
- DECO_APP_DEPLOYMENT_ID: string;
48
- DECO_BINDINGS: string;
49
- DECO_API_TOKEN: string;
50
- DECO_WORKSPACE_DB: WorkspaceDB & {
51
- forContext: (ctx: RequestContext) => WorkspaceDB;
52
- };
35
+ export type { BindingRegistry } from "./bindings.ts";
36
+
37
+ export interface DefaultEnv<
38
+ TSchema extends z.ZodTypeAny = any,
39
+ TBindings extends BindingRegistry = BindingRegistry,
40
+ > {
41
+ MESH_REQUEST_CONTEXT: RequestContext<TSchema, TBindings>;
42
+ MESH_APP_DEPLOYMENT_ID: string;
53
43
  IS_LOCAL: boolean;
44
+ MESH_URL?: string;
45
+ MESH_RUNTIME_TOKEN?: string;
46
+ MESH_APP_NAME?: string;
54
47
  [key: string]: unknown;
55
48
  }
56
49
 
@@ -58,7 +51,7 @@ export interface BindingsObject {
58
51
  bindings?: Binding[];
59
52
  }
60
53
 
61
- export const WorkersMCPBindings = {
54
+ export const MCPBindings = {
62
55
  parse: (bindings?: string): Binding[] => {
63
56
  if (!bindings) return [];
64
57
  try {
@@ -75,19 +68,16 @@ export const WorkersMCPBindings = {
75
68
  export interface UserDefaultExport<
76
69
  TUserEnv = Record<string, unknown>,
77
70
  TSchema extends z.ZodTypeAny = never,
78
- TEnv = TUserEnv & DefaultEnv<TSchema>,
79
- > extends CreateMCPServerOptions<TEnv, TSchema> {
80
- fetch?: (
81
- req: Request,
82
- env: TEnv,
83
- ctx: ExecutionContext,
84
- ) => Promise<Response> | Response;
85
- }
86
-
87
- // 1. Map binding type to its interface
88
- interface BindingTypeMap {
89
- mcp: MCPBinding;
90
- contract: ContractBinding;
71
+ TBindings extends BindingRegistry = BindingRegistry,
72
+ TEnv extends TUserEnv & DefaultEnv<TSchema, TBindings> = TUserEnv &
73
+ DefaultEnv<TSchema, TBindings>,
74
+ > extends CreateMCPServerOptions<TEnv, TSchema, TBindings> {
75
+ fetch?: (req: Request, env: TEnv, ctx: any) => Promise<Response> | Response;
76
+ /**
77
+ * CORS configuration options.
78
+ * Set to `false` to disable CORS handling entirely.
79
+ */
80
+ cors?: CORSOptions | false;
91
81
  }
92
82
 
93
83
  export interface User {
@@ -102,55 +92,30 @@ export interface User {
102
92
  };
103
93
  }
104
94
 
105
- export interface RequestContext<TSchema extends z.ZodTypeAny = any> {
106
- state: z.infer<TSchema>;
107
- branch?: string;
95
+ export interface RequestContext<
96
+ TSchema extends z.ZodTypeAny = any,
97
+ TBindings extends BindingRegistry = BindingRegistry,
98
+ > {
99
+ state: ResolvedBindings<z.infer<TSchema>, TBindings>;
108
100
  token: string;
109
- workspace: string;
101
+ meshUrl: string;
102
+ authorization?: string | null;
110
103
  ensureAuthenticated: (options?: {
111
104
  workspaceHint?: string;
112
105
  }) => User | undefined;
113
106
  callerApp?: string;
114
- integrationId?: string;
107
+ connectionId?: string;
115
108
  }
116
109
 
117
- // 2. Map binding type to its creator function
118
- type CreatorByType = {
119
- [K in keyof BindingTypeMap]: (
120
- value: BindingTypeMap[K],
121
- env: DefaultEnv,
122
- ) => unknown;
123
- };
124
-
125
- // 3. Strongly type creatorByType
126
- const creatorByType: CreatorByType = {
127
- mcp: createIntegrationBinding,
128
- contract: createContractBinding,
129
- };
130
-
131
110
  const withDefaultBindings = ({
132
111
  env,
133
112
  server,
134
- ctx,
135
113
  url,
136
114
  }: {
137
115
  env: DefaultEnv;
138
116
  server: MCPServer<any, any>;
139
- ctx: RequestContext;
140
117
  url?: string;
141
118
  }) => {
142
- const client = workspaceClient(ctx, env.DECO_API_URL);
143
- const createWorkspaceDB = (ctx: RequestContext): WorkspaceDB => {
144
- const client = workspaceClient(ctx, env.DECO_API_URL);
145
- return {
146
- query: ({ sql, params }) => {
147
- return client.DATABASES_RUN_SQL({
148
- sql,
149
- params,
150
- });
151
- },
152
- };
153
- };
154
119
  env["SELF"] = new Proxy(
155
120
  {},
156
121
  {
@@ -169,15 +134,6 @@ const withDefaultBindings = ({
169
134
  },
170
135
  );
171
136
 
172
- const workspaceDbBinding = {
173
- ...createWorkspaceDB(ctx),
174
- forContext: createWorkspaceDB,
175
- };
176
-
177
- env["DECO_API"] = MCPClient;
178
- env["DECO_WORKSPACE_API"] = client;
179
- env["DECO_WORKSPACE_DB"] = workspaceDbBinding;
180
-
181
137
  env["IS_LOCAL"] =
182
138
  (url?.startsWith("http://localhost") ||
183
139
  url?.startsWith("http://127.0.0.1")) ??
@@ -194,13 +150,9 @@ export class UnauthorizedError extends Error {
194
150
  }
195
151
  }
196
152
 
197
- const AUTH_CALLBACK_ENDPOINT = "/oauth/callback";
198
- const AUTH_START_ENDPOINT = "/oauth/start";
199
- const AUTH_LOGOUT_ENDPOINT = "/oauth/logout";
200
- const AUTHENTICATED = (user?: unknown, workspace?: string) => () => {
153
+ const AUTHENTICATED = (user?: unknown) => () => {
201
154
  return {
202
155
  ...((user as User) ?? {}),
203
- workspace,
204
156
  } as User;
205
157
  };
206
158
 
@@ -208,107 +160,171 @@ export const withBindings = <TEnv>({
208
160
  env: _env,
209
161
  server,
210
162
  tokenOrContext,
211
- origin,
212
163
  url,
213
- branch,
164
+ authToken,
214
165
  }: {
215
166
  env: TEnv;
216
167
  server: MCPServer<TEnv, any>;
168
+ // token is x-mesh-token
217
169
  tokenOrContext?: string | RequestContext;
218
- origin?: string | null;
170
+ // authToken is the authorization header
171
+ authToken?: string | null;
219
172
  url?: string;
220
- branch?: string | null;
221
173
  }): TEnv => {
222
- branch ??= undefined;
223
174
  const env = _env as DefaultEnv<any>;
175
+ const authorization = authToken ? authToken.split(" ")[1] : undefined;
224
176
 
225
- const apiUrl = env.DECO_API_URL ?? "https://api.decocms.com";
226
177
  let context;
227
178
  if (typeof tokenOrContext === "string") {
228
179
  const decoded = decodeJwt(tokenOrContext);
229
- const workspace = decoded.aud as string;
180
+ // Support both new JWT format (fields directly on payload) and legacy format (nested in metadata)
181
+ const metadata =
182
+ (decoded.metadata as {
183
+ state?: Record<string, unknown>;
184
+ meshUrl?: string;
185
+ connectionId?: string;
186
+ }) ?? {};
230
187
 
231
188
  context = {
232
- state: decoded.state as Record<string, unknown>,
189
+ authorization,
190
+ state: decoded.state ?? metadata.state,
233
191
  token: tokenOrContext,
234
- integrationId: decoded.integrationId as string,
235
- workspace,
236
- ensureAuthenticated: AUTHENTICATED(decoded.user, workspace),
237
- branch,
192
+ meshUrl: (decoded.meshUrl as string) ?? metadata.meshUrl,
193
+ connectionId: (decoded.connectionId as string) ?? metadata.connectionId,
194
+ ensureAuthenticated: AUTHENTICATED(decoded.user ?? decoded.sub),
238
195
  } as RequestContext<any>;
239
196
  } else if (typeof tokenOrContext === "object") {
240
197
  context = tokenOrContext;
241
198
  const decoded = decodeJwt(tokenOrContext.token);
242
- const workspace = decoded.aud as string;
199
+ // Support both new JWT format (fields directly on payload) and legacy format (nested in metadata)
200
+ const metadata =
201
+ (decoded.metadata as {
202
+ state?: Record<string, unknown>;
203
+ meshUrl?: string;
204
+ connectionId?: string;
205
+ }) ?? {};
243
206
  const appName = decoded.appName as string | undefined;
207
+ context.authorization ??= authorization;
244
208
  context.callerApp = appName;
245
- context.integrationId ??= decoded.integrationId as string;
246
- context.ensureAuthenticated = AUTHENTICATED(decoded.user, workspace);
209
+ context.connectionId ??=
210
+ (decoded.connectionId as string) ?? metadata.connectionId;
211
+ context.ensureAuthenticated = AUTHENTICATED(decoded.user ?? decoded.sub);
247
212
  } else {
248
213
  context = {
249
- state: undefined,
250
- token: env.DECO_API_TOKEN,
251
- workspace: env.DECO_WORKSPACE,
252
- branch,
253
- ensureAuthenticated: (options?: { workspaceHint?: string }) => {
254
- const workspaceHint = options?.workspaceHint ?? env.DECO_WORKSPACE;
255
- const authUri = new URL("/apps/oauth", apiUrl);
256
- authUri.searchParams.set("client_id", env.DECO_APP_NAME);
257
- authUri.searchParams.set(
258
- "redirect_uri",
259
- new URL(AUTH_CALLBACK_ENDPOINT, origin ?? env.DECO_APP_ENTRYPOINT)
260
- .href,
261
- );
262
- workspaceHint &&
263
- authUri.searchParams.set("workspace_hint", workspaceHint);
264
- throw new UnauthorizedError("Unauthorized", authUri);
214
+ state: {},
215
+ authorization,
216
+ token: undefined,
217
+ meshUrl: undefined,
218
+ connectionId: undefined,
219
+ ensureAuthenticated: () => {
220
+ throw new Error("Unauthorized");
265
221
  },
266
- };
222
+ } as unknown as RequestContext<any>;
267
223
  }
268
224
 
269
- env.DECO_REQUEST_CONTEXT = context;
270
- const bindings = WorkersMCPBindings.parse(env.DECO_BINDINGS);
271
-
272
- for (const binding of bindings) {
273
- env[binding.name] = creatorByType[binding.type](binding as any, env);
274
- }
225
+ env.MESH_REQUEST_CONTEXT = context;
226
+ context.state = initializeBindings(context);
275
227
 
276
228
  withDefaultBindings({
277
229
  env,
278
230
  server,
279
- ctx: env.DECO_REQUEST_CONTEXT,
280
231
  url,
281
232
  });
282
233
 
283
234
  return env as TEnv;
284
235
  };
285
236
 
286
- export const withRuntime = <TEnv, TSchema extends z.ZodTypeAny = never>(
287
- userFns: UserDefaultExport<TEnv, TSchema>,
288
- ): ExportedHandler<TEnv & DefaultEnv<TSchema>> => {
289
- const server = createMCPServer<TEnv, TSchema>(userFns);
290
- const fetcher = async (
291
- req: Request,
292
- env: TEnv & DefaultEnv<TSchema>,
293
- ctx: ExecutionContext,
294
- ) => {
295
- const url = new URL(req.url);
296
- if (url.pathname === AUTH_CALLBACK_ENDPOINT) {
297
- return handleAuthCallback(req, {
298
- apiUrl: env.DECO_API_URL,
299
- appName: env.DECO_APP_NAME,
300
- });
237
+ const DEFAULT_CORS_OPTIONS = {
238
+ origin: (origin: string) => {
239
+ // Allow localhost and configured origins
240
+ if (origin.includes("localhost") || origin.includes("127.0.0.1")) {
241
+ return origin;
301
242
  }
302
- if (url.pathname === AUTH_START_ENDPOINT) {
303
- env.DECO_REQUEST_CONTEXT.ensureAuthenticated();
304
- const redirectTo = new URL("/", url);
305
- const next = url.searchParams.get("next");
306
- return Response.redirect(next ?? redirectTo, 302);
307
- }
308
- if (url.pathname === AUTH_LOGOUT_ENDPOINT) {
309
- return handleLogout(req);
243
+ // TODO: Configure allowed origins from environment
244
+ return origin;
245
+ },
246
+ credentials: true,
247
+ allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
248
+ allowHeaders: ["Content-Type", "Authorization", "mcp-protocol-version"],
249
+ };
250
+
251
+ export const withRuntime = <
252
+ TUserEnv,
253
+ TSchema extends z.ZodTypeAny = never,
254
+ TBindings extends BindingRegistry = BindingRegistry,
255
+ TEnv extends TUserEnv & DefaultEnv<TSchema, TBindings> = TUserEnv &
256
+ DefaultEnv<TSchema, TBindings>,
257
+ >(
258
+ userFns: UserDefaultExport<TUserEnv, TSchema, TBindings>,
259
+ ) => {
260
+ const server = createMCPServer<TUserEnv, TSchema, TBindings>(userFns);
261
+ const corsOptions = userFns.cors ?? DEFAULT_CORS_OPTIONS;
262
+ const oauth = userFns.oauth;
263
+ const oauthHandlers = oauth ? createOAuthHandlers(oauth) : null;
264
+
265
+ const fetcher = async (req: Request, env: TEnv, ctx: any) => {
266
+ const url = new URL(req.url);
267
+
268
+ // OAuth routes (when configured)
269
+ if (oauthHandlers) {
270
+ // Protected resource metadata (RFC9728) - both paths MUST be supported
271
+ if (
272
+ url.pathname === "/.well-known/oauth-protected-resource" ||
273
+ url.pathname === "/mcp/.well-known/oauth-protected-resource"
274
+ ) {
275
+ return oauthHandlers.handleProtectedResourceMetadata(req);
276
+ }
277
+
278
+ // Authorization server metadata (RFC8414)
279
+ if (url.pathname === "/.well-known/oauth-authorization-server") {
280
+ return oauthHandlers.handleAuthorizationServerMetadata(req);
281
+ }
282
+
283
+ // Authorization endpoint - redirects to external OAuth provider
284
+ if (url.pathname === "/authorize") {
285
+ return oauthHandlers.handleAuthorize(req);
286
+ }
287
+
288
+ // OAuth callback - receives code from external OAuth provider
289
+ if (url.pathname === "/oauth/callback") {
290
+ return oauthHandlers.handleOAuthCallback(req);
291
+ }
292
+
293
+ // Token endpoint - exchanges code for tokens
294
+ if (url.pathname === "/token" && req.method === "POST") {
295
+ return oauthHandlers.handleToken(req);
296
+ }
297
+
298
+ // Dynamic client registration (RFC7591)
299
+ if (
300
+ (url.pathname === "/register" || url.pathname === "/mcp/register") &&
301
+ req.method === "POST"
302
+ ) {
303
+ return oauthHandlers.handleClientRegistration(req);
304
+ }
310
305
  }
306
+
307
+ // MCP endpoint
311
308
  if (url.pathname === "/mcp") {
309
+ if (req.method === "GET") {
310
+ return new Response("Method not allowed", { status: 405 });
311
+ }
312
+ // If OAuth is configured, require authentication
313
+ if (oauthHandlers && !oauthHandlers.hasAuth(req)) {
314
+ // Clone request to check method without consuming the original body
315
+ const clonedReq = req.clone();
316
+ try {
317
+ const body = (await clonedReq.json()) as { method?: string };
318
+ // Allow tools/list to pass without auth
319
+ if (body?.method !== "tools/list") {
320
+ return oauthHandlers.createUnauthorizedResponse(req);
321
+ }
322
+ } catch {
323
+ // If body parsing fails, require auth
324
+ return oauthHandlers.createUnauthorizedResponse(req);
325
+ }
326
+ }
327
+
312
328
  return server.fetch(req, env, ctx);
313
329
  }
314
330
 
@@ -334,55 +350,43 @@ export const withRuntime = <TEnv, TSchema extends z.ZodTypeAny = never>(
334
350
  });
335
351
  }
336
352
 
337
- if (url.pathname.startsWith(DeconfigResource.WatchPathNameBase)) {
338
- return DeconfigResource.watchAPI(req, env);
339
- }
340
353
  return (
341
354
  userFns.fetch?.(req, env, ctx) ||
342
355
  new Response("Not found", { status: 404 })
343
356
  );
344
357
  };
358
+
345
359
  return {
346
- fetch: async (
347
- req: Request,
348
- env: TEnv & DefaultEnv<TSchema>,
349
- ctx: ExecutionContext,
350
- ) => {
351
- const referer = req.headers.get("referer");
352
- const isFetchRequest = req.headers.has(DECO_MCP_CLIENT_HEADER);
353
-
354
- try {
355
- const bindings = withBindings({
356
- env,
357
- server,
358
- branch:
359
- req.headers.get("x-deco-branch") ??
360
- new URL(req.url).searchParams.get("__b"),
361
- tokenOrContext: await getReqToken(req, env),
362
- origin:
363
- referer ?? req.headers.get("origin") ?? new URL(req.url).origin,
364
- url: req.url,
365
- });
366
- return await State.run(
367
- { req, env: bindings, ctx },
368
- async () => await fetcher(req, bindings, ctx),
369
- );
370
- } catch (error) {
371
- if (error instanceof UnauthorizedError) {
372
- if (!isFetchRequest) {
373
- const url = new URL(req.url);
374
- error.redirectTo.searchParams.set(
375
- "state",
376
- StateParser.stringify({
377
- next: url.searchParams.get("next") ?? referer ?? req.url,
378
- }),
379
- );
380
- return Response.redirect(error.redirectTo, 302);
381
- }
382
- return new Response(null, { status: 401 });
383
- }
384
- throw error;
360
+ fetch: async (req: Request, env: TEnv, ctx?: any) => {
361
+ if (new URL(req.url).pathname === "/_healthcheck") {
362
+ return new Response("OK", { status: 200 });
385
363
  }
364
+ // Handle CORS preflight (OPTIONS) requests
365
+ if (corsOptions !== false && req.method === "OPTIONS") {
366
+ const options = corsOptions ?? {};
367
+ return handlePreflight(req, options);
368
+ }
369
+
370
+ const bindings = withBindings({
371
+ authToken: req.headers.get("authorization") ?? null,
372
+ env: { ...process.env, ...env },
373
+ server,
374
+ tokenOrContext: req.headers.get("x-mesh-token") ?? undefined,
375
+ url: req.url,
376
+ });
377
+
378
+ const response = await State.run(
379
+ { req, env: bindings, ctx },
380
+ async () => await fetcher(req, bindings, ctx),
381
+ );
382
+
383
+ // Add CORS headers to response
384
+ if (corsOptions !== false) {
385
+ const options = corsOptions ?? {};
386
+ return withCORS(response, req, options);
387
+ }
388
+
389
+ return response;
386
390
  },
387
391
  };
388
392
  };