@portel/photon 1.28.2 → 1.31.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.
Files changed (183) hide show
  1. package/README.md +42 -11
  2. package/dist/asset-resolver.d.ts +44 -0
  3. package/dist/asset-resolver.d.ts.map +1 -0
  4. package/dist/asset-resolver.js +105 -0
  5. package/dist/asset-resolver.js.map +1 -0
  6. package/dist/auto-ui/beam/external-mcp-manager.d.ts +73 -0
  7. package/dist/auto-ui/beam/external-mcp-manager.d.ts.map +1 -0
  8. package/dist/auto-ui/beam/external-mcp-manager.js +65 -0
  9. package/dist/auto-ui/beam/external-mcp-manager.js.map +1 -0
  10. package/dist/auto-ui/beam/external-mcp.d.ts.map +1 -1
  11. package/dist/auto-ui/beam/external-mcp.js +25 -1
  12. package/dist/auto-ui/beam/external-mcp.js.map +1 -1
  13. package/dist/auto-ui/beam/photon-management.d.ts.map +1 -1
  14. package/dist/auto-ui/beam/photon-management.js +11 -8
  15. package/dist/auto-ui/beam/photon-management.js.map +1 -1
  16. package/dist/auto-ui/beam/routes/api-browse.d.ts.map +1 -1
  17. package/dist/auto-ui/beam/routes/api-browse.js +7 -4
  18. package/dist/auto-ui/beam/routes/api-browse.js.map +1 -1
  19. package/dist/auto-ui/beam/routes/api-config.d.ts.map +1 -1
  20. package/dist/auto-ui/beam/routes/api-config.js +3 -2
  21. package/dist/auto-ui/beam/routes/api-config.js.map +1 -1
  22. package/dist/auto-ui/beam/routes/api-marketplace.d.ts.map +1 -1
  23. package/dist/auto-ui/beam/routes/api-marketplace.js +6 -2
  24. package/dist/auto-ui/beam/routes/api-marketplace.js.map +1 -1
  25. package/dist/auto-ui/beam/startup.js.map +1 -1
  26. package/dist/auto-ui/beam/types.d.ts +5 -2
  27. package/dist/auto-ui/beam/types.d.ts.map +1 -1
  28. package/dist/auto-ui/beam.d.ts.map +1 -1
  29. package/dist/auto-ui/beam.js +239 -88
  30. package/dist/auto-ui/beam.js.map +1 -1
  31. package/dist/auto-ui/bridge/index.d.ts.map +1 -1
  32. package/dist/auto-ui/bridge/index.js +11 -0
  33. package/dist/auto-ui/bridge/index.js.map +1 -1
  34. package/dist/auto-ui/bridge/types.d.ts +2 -0
  35. package/dist/auto-ui/bridge/types.d.ts.map +1 -1
  36. package/dist/auto-ui/openapi-generator.js +1 -4
  37. package/dist/auto-ui/openapi-generator.js.map +1 -1
  38. package/dist/auto-ui/photon-bridge.d.ts +4 -0
  39. package/dist/auto-ui/photon-bridge.d.ts.map +1 -1
  40. package/dist/auto-ui/photon-bridge.js.map +1 -1
  41. package/dist/auto-ui/photon-host.js.map +1 -1
  42. package/dist/auto-ui/streamable-http-transport.d.ts +7 -0
  43. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  44. package/dist/auto-ui/streamable-http-transport.js +252 -43
  45. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  46. package/dist/auto-ui/types.d.ts +24 -2
  47. package/dist/auto-ui/types.d.ts.map +1 -1
  48. package/dist/auto-ui/types.js.map +1 -1
  49. package/dist/beam.bundle.js +202 -24
  50. package/dist/beam.bundle.js.map +3 -3
  51. package/dist/capability-negotiator.d.ts +39 -1
  52. package/dist/capability-negotiator.d.ts.map +1 -1
  53. package/dist/capability-negotiator.js +5 -0
  54. package/dist/capability-negotiator.js.map +1 -1
  55. package/dist/cf-bindings-parser.d.ts +15 -0
  56. package/dist/cf-bindings-parser.d.ts.map +1 -0
  57. package/dist/cf-bindings-parser.js +98 -0
  58. package/dist/cf-bindings-parser.js.map +1 -0
  59. package/dist/cf-usage-scanner.d.ts +76 -0
  60. package/dist/cf-usage-scanner.d.ts.map +1 -0
  61. package/dist/cf-usage-scanner.js +179 -0
  62. package/dist/cf-usage-scanner.js.map +1 -0
  63. package/dist/cli/commands/build.d.ts.map +1 -1
  64. package/dist/cli/commands/build.js +124 -16
  65. package/dist/cli/commands/build.js.map +1 -1
  66. package/dist/cli/commands/cf.d.ts +18 -0
  67. package/dist/cli/commands/cf.d.ts.map +1 -0
  68. package/dist/cli/commands/cf.js +207 -0
  69. package/dist/cli/commands/cf.js.map +1 -0
  70. package/dist/cli/commands/info.js +1 -1
  71. package/dist/cli/commands/info.js.map +1 -1
  72. package/dist/cli/commands/init.d.ts.map +1 -1
  73. package/dist/cli/commands/init.js +59 -46
  74. package/dist/cli/commands/init.js.map +1 -1
  75. package/dist/cli/commands/run.d.ts.map +1 -1
  76. package/dist/cli/commands/run.js +3 -0
  77. package/dist/cli/commands/run.js.map +1 -1
  78. package/dist/cli/index.d.ts.map +1 -1
  79. package/dist/cli/index.js +43 -6
  80. package/dist/cli/index.js.map +1 -1
  81. package/dist/daemon/client.d.ts.map +1 -1
  82. package/dist/daemon/client.js +40 -33
  83. package/dist/daemon/client.js.map +1 -1
  84. package/dist/daemon/manager.d.ts +6 -2
  85. package/dist/daemon/manager.d.ts.map +1 -1
  86. package/dist/daemon/manager.js +75 -20
  87. package/dist/daemon/manager.js.map +1 -1
  88. package/dist/daemon/server.js +69 -11
  89. package/dist/daemon/server.js.map +1 -1
  90. package/dist/daemon/worker-host.js.map +1 -1
  91. package/dist/deploy/cloudflare.d.ts +27 -0
  92. package/dist/deploy/cloudflare.d.ts.map +1 -1
  93. package/dist/deploy/cloudflare.js +210 -3
  94. package/dist/deploy/cloudflare.js.map +1 -1
  95. package/dist/editor-support/docblock-tag-catalog.d.ts.map +1 -1
  96. package/dist/editor-support/docblock-tag-catalog.js +32 -2
  97. package/dist/editor-support/docblock-tag-catalog.js.map +1 -1
  98. package/dist/embedded-runtime.js.map +1 -1
  99. package/dist/format/registry.d.ts +83 -0
  100. package/dist/format/registry.d.ts.map +1 -0
  101. package/dist/format/registry.js +139 -0
  102. package/dist/format/registry.js.map +1 -0
  103. package/dist/format/seed.d.ts +18 -0
  104. package/dist/format/seed.d.ts.map +1 -0
  105. package/dist/format/seed.js +246 -0
  106. package/dist/format/seed.js.map +1 -0
  107. package/dist/loader.d.ts +61 -66
  108. package/dist/loader.d.ts.map +1 -1
  109. package/dist/loader.js +315 -327
  110. package/dist/loader.js.map +1 -1
  111. package/dist/photon-cli-runner.d.ts.map +1 -1
  112. package/dist/photon-cli-runner.js +20 -11
  113. package/dist/photon-cli-runner.js.map +1 -1
  114. package/dist/photons/maker.photon.d.ts +2 -2
  115. package/dist/photons/maker.photon.d.ts.map +1 -1
  116. package/dist/photons/maker.photon.js +5 -6
  117. package/dist/photons/maker.photon.js.map +1 -1
  118. package/dist/photons/maker.photon.ts +5 -6
  119. package/dist/resource-server.d.ts +55 -15
  120. package/dist/resource-server.d.ts.map +1 -1
  121. package/dist/resource-server.js +205 -50
  122. package/dist/resource-server.js.map +1 -1
  123. package/dist/runtime/cf-local.d.ts +157 -0
  124. package/dist/runtime/cf-local.d.ts.map +1 -0
  125. package/dist/runtime/cf-local.js +406 -0
  126. package/dist/runtime/cf-local.js.map +1 -0
  127. package/dist/server.d.ts +117 -2
  128. package/dist/server.d.ts.map +1 -1
  129. package/dist/server.js +681 -67
  130. package/dist/server.js.map +1 -1
  131. package/dist/settings-persistence.d.ts +50 -0
  132. package/dist/settings-persistence.d.ts.map +1 -0
  133. package/dist/settings-persistence.js +188 -0
  134. package/dist/settings-persistence.js.map +1 -0
  135. package/dist/shared/asset-encoding.d.ts +30 -0
  136. package/dist/shared/asset-encoding.d.ts.map +1 -0
  137. package/dist/shared/asset-encoding.js +0 -0
  138. package/dist/shared/asset-encoding.js.map +1 -0
  139. package/dist/shared/audit-sqlite.d.ts.map +1 -1
  140. package/dist/shared/audit-sqlite.js +0 -1
  141. package/dist/shared/audit-sqlite.js.map +1 -1
  142. package/dist/shared/cross-origin-headers.d.ts +47 -0
  143. package/dist/shared/cross-origin-headers.d.ts.map +1 -0
  144. package/dist/shared/cross-origin-headers.js +61 -0
  145. package/dist/shared/cross-origin-headers.js.map +1 -0
  146. package/dist/shared/error-handler.d.ts.map +1 -1
  147. package/dist/shared/error-handler.js +3 -1
  148. package/dist/shared/error-handler.js.map +1 -1
  149. package/dist/shared/expose-route-extractor.d.ts +36 -0
  150. package/dist/shared/expose-route-extractor.d.ts.map +1 -0
  151. package/dist/shared/expose-route-extractor.js +64 -0
  152. package/dist/shared/expose-route-extractor.js.map +1 -0
  153. package/dist/shared/extract-claims.d.ts +33 -0
  154. package/dist/shared/extract-claims.d.ts.map +1 -0
  155. package/dist/shared/extract-claims.js +60 -0
  156. package/dist/shared/extract-claims.js.map +1 -0
  157. package/dist/shared/http-route-extractor.d.ts +6 -0
  158. package/dist/shared/http-route-extractor.d.ts.map +1 -1
  159. package/dist/shared/http-route-extractor.js +29 -5
  160. package/dist/shared/http-route-extractor.js.map +1 -1
  161. package/dist/shared/instance-binding.d.ts +53 -0
  162. package/dist/shared/instance-binding.d.ts.map +1 -0
  163. package/dist/shared/instance-binding.js +85 -0
  164. package/dist/shared/instance-binding.js.map +1 -0
  165. package/dist/shared/io.d.ts.map +1 -1
  166. package/dist/shared/io.js +5 -2
  167. package/dist/shared/io.js.map +1 -1
  168. package/dist/shared/logger.js.map +1 -1
  169. package/dist/shared/sqlite-runtime.d.ts.map +1 -1
  170. package/dist/shared/sqlite-runtime.js +0 -1
  171. package/dist/shared/sqlite-runtime.js.map +1 -1
  172. package/dist/task-executor.js.map +1 -1
  173. package/dist/telemetry/sdk.d.ts.map +1 -1
  174. package/dist/telemetry/sdk.js +0 -1
  175. package/dist/telemetry/sdk.js.map +1 -1
  176. package/dist/test-runner.d.ts.map +1 -1
  177. package/dist/test-runner.js.map +1 -1
  178. package/dist/types/server-types.d.ts +16 -7
  179. package/dist/types/server-types.d.ts.map +1 -1
  180. package/package.json +14 -4
  181. package/templates/cloudflare/worker.ts.template +428 -14
  182. package/templates/cloudflare/wrangler.toml.template +2 -7
  183. package/templates/photon.template.ts +13 -0
@@ -298,6 +298,54 @@ function createCallProvider(env: Env, callerName: string) {
298
298
  };
299
299
  }
300
300
 
301
+ /**
302
+ * Build a `Cloudflare` adapter for the deployed Worker. Each scoped
303
+ * category resolves to `env[bindingNameFor(photon, category, qualifier)]`
304
+ * — the same auto-naming convention the local miniflare sandbox seeds,
305
+ * so photon source is unchanged across runtimes. Shared categories
306
+ * (ai/images/browser) read fixed canonical env keys.
307
+ *
308
+ * Mirrors photon-core's `createCloudflareFromEnv` line-for-line. We
309
+ * inline it rather than import to keep the template self-contained
310
+ * (the bundled Worker shouldn't depend on `@portel/photon-core` at
311
+ * runtime — too much surface for what's needed here).
312
+ */
313
+ function bindingNameFor(photon: string, category: string, qualifier?: string): string {
314
+ const safePhoton = photon.toLowerCase().replace(/-/g, '_');
315
+ if (qualifier && qualifier.length > 0) {
316
+ const safeQualifier = qualifier.toLowerCase().replace(/-/g, '_');
317
+ return `${safePhoton}_${safeQualifier}_${category}`;
318
+ }
319
+ return `${safePhoton}_${category}`;
320
+ }
321
+
322
+ function createCloudflareFromEnv(env: Env, photonName: string): any {
323
+ const scoped = (category: string) => (qualifier?: string) => {
324
+ const name = bindingNameFor(photonName, category, qualifier);
325
+ const binding = (env as any)[name];
326
+ if (!binding) {
327
+ throw new Error(
328
+ `cf.${category}(${qualifier ? JSON.stringify(qualifier) : ''}) requires ` +
329
+ `binding "${name}" on the Worker env, but it is not defined. Add it to ` +
330
+ `wrangler.toml (or run \`photon host deploy cloudflare\` to regenerate ` +
331
+ `bindings from the photon's source).`
332
+ );
333
+ }
334
+ return binding;
335
+ };
336
+ return {
337
+ kv: scoped('kv'),
338
+ r2: scoped('r2'),
339
+ d1: scoped('d1'),
340
+ queue: scoped('queue'),
341
+ vectorize: scoped('vectorize'),
342
+ ai: (env as any).AI,
343
+ images: (env as any).IMAGES,
344
+ browser: (env as any).BROWSER,
345
+ fetch: (input: any, init?: any) => fetch(input, init),
346
+ };
347
+ }
348
+
301
349
  // ════════════════════════════════════════════════════════════════════════════
302
350
  // Capability shim — wires this.* on the photon instance
303
351
  // ════════════════════════════════════════════════════════════════════════════
@@ -316,15 +364,76 @@ function withCfCapabilities(
316
364
  });
317
365
 
318
366
  Object.defineProperty(instance, 'emit', {
319
- value: (event: { channel?: string; [k: string]: unknown }) => {
320
- const channel = (event && typeof event === 'object' && event.channel) || 'default';
321
- const payload = JSON.stringify(event);
322
- for (const ws of ctx.getWebSockets(channel)) {
323
- try {
324
- ws.send(payload);
325
- } catch {
326
- // Socket closing; webSocketClose hook will reap.
327
- }
367
+ value: (event: { emit?: string; [k: string]: unknown }) => {
368
+ // SSE forwarding for in-flight tool calls. When an SSE
369
+ // request context is active (i.e. the caller used
370
+ // `Accept: text/event-stream`), `this.emit({ emit: 'progress' })`,
371
+ // `this.emit({ emit: 'status' })`, etc. become real
372
+ // `notifications/progress` / `notifications/message` JSON-RPC
373
+ // notifications written to the open stream — the client sees
374
+ // them arrive as the tool runs, not in a single batch at the
375
+ // end. Mirrors `src/server.ts:1340-1397` for runtime parity
376
+ // between local and deployed transports.
377
+ const reqCtx = requestContext.getStore();
378
+ if (!reqCtx) return;
379
+ const kind = (event && typeof event === 'object' ? event.emit : undefined) as string | undefined;
380
+ if (!kind) return;
381
+ const progressToken = reqCtx.progressToken;
382
+ if (kind === 'progress') {
383
+ const rawValue = typeof event.value === 'number' ? (event.value as number) : 0;
384
+ const progress = rawValue <= 1 ? rawValue * 100 : rawValue;
385
+ void reqCtx.send({
386
+ jsonrpc: '2.0',
387
+ method: 'notifications/progress',
388
+ params: {
389
+ ...(progressToken !== undefined ? { progressToken } : {}),
390
+ progress,
391
+ total: 100,
392
+ ...(typeof event.message === 'string' ? { message: event.message } : {}),
393
+ },
394
+ });
395
+ } else if (kind === 'status') {
396
+ void reqCtx.send({
397
+ jsonrpc: '2.0',
398
+ method: 'notifications/progress',
399
+ params: {
400
+ ...(progressToken !== undefined ? { progressToken } : {}),
401
+ progress: 0,
402
+ total: 100,
403
+ message: typeof event.message === 'string' ? event.message : '',
404
+ },
405
+ });
406
+ } else if (kind === 'log') {
407
+ void reqCtx.send({
408
+ jsonrpc: '2.0',
409
+ method: 'notifications/message',
410
+ params: {
411
+ level: typeof event.level === 'string' ? event.level : 'info',
412
+ data: typeof event.message === 'string' ? event.message : '',
413
+ },
414
+ });
415
+ } else if (kind === 'render') {
416
+ void reqCtx.send({
417
+ jsonrpc: '2.0',
418
+ method: 'notifications/message',
419
+ params: {
420
+ level: 'info',
421
+ data: JSON.stringify({
422
+ _render: true,
423
+ format: event.format,
424
+ value: event.value,
425
+ }),
426
+ },
427
+ });
428
+ } else if (kind === 'render:clear') {
429
+ void reqCtx.send({
430
+ jsonrpc: '2.0',
431
+ method: 'notifications/message',
432
+ params: {
433
+ level: 'info',
434
+ data: JSON.stringify({ _render: true, clear: true }),
435
+ },
436
+ });
328
437
  }
329
438
  },
330
439
  writable: false,
@@ -332,6 +441,80 @@ function withCfCapabilities(
332
441
  configurable: false,
333
442
  });
334
443
 
444
+ // Emit-based convenience helpers — `this.status(...)`, `this.log(...)`,
445
+ // `this.progress(...)`, `this.toast(...)`, `this.thinking(...)`,
446
+ // `this.render(...)`. The classic loader injects these on every
447
+ // plain-class instance (`src/loader.ts:injectEmitHelpers`); the
448
+ // deployed Worker must do the same so a photon written against the
449
+ // local runtime behaves identically when served from CF. Without
450
+ // these, calls like `this.status?.('one')` short-circuit silently
451
+ // on the Worker and the whole chain of progress notifications is
452
+ // lost — which is exactly the symptom that prompted this wiring.
453
+ // Each helper guards on `in instance` so user-declared methods on
454
+ // the photon class always win.
455
+ const emitFn = (data: { [k: string]: unknown }) =>
456
+ (instance as { emit: (d: unknown) => void }).emit(data);
457
+ if (!('render' in instance)) {
458
+ (instance as { render?: (format?: string, value?: unknown) => void }).render = (
459
+ format?: string,
460
+ value?: unknown,
461
+ ) => {
462
+ if (format === undefined) return emitFn({ emit: 'render:clear' });
463
+ if (format === 'status') {
464
+ return emitFn(
465
+ typeof value === 'string'
466
+ ? { emit: 'status', message: value }
467
+ : { emit: 'status', ...(value as object) }
468
+ );
469
+ }
470
+ if (format === 'progress') {
471
+ return emitFn(
472
+ typeof value === 'number'
473
+ ? { emit: 'progress', value }
474
+ : { emit: 'progress', ...(value as object) }
475
+ );
476
+ }
477
+ if (format === 'toast') {
478
+ return emitFn(
479
+ typeof value === 'string'
480
+ ? { emit: 'toast', message: value }
481
+ : { emit: 'toast', ...(value as object) }
482
+ );
483
+ }
484
+ emitFn({ emit: 'render', format, value });
485
+ };
486
+ }
487
+ if (!('toast' in instance)) {
488
+ (instance as { toast?: (m: string, o?: { type?: string; duration?: number }) => void }).toast = (
489
+ message: string,
490
+ opts: { type?: string; duration?: number } = {},
491
+ ) => emitFn({ emit: 'toast', message, ...opts });
492
+ }
493
+ if (!('log' in instance)) {
494
+ (instance as { log?: (m: string, o?: { level?: string; data?: unknown }) => void }).log = (
495
+ message: string,
496
+ opts: { level?: string; data?: unknown } = {},
497
+ ) => emitFn({ emit: 'log', message, level: opts.level ?? 'info', data: opts.data });
498
+ }
499
+ if (!('status' in instance)) {
500
+ // Signature mirrors `src/loader.ts:288` exactly. Photons that pass a
501
+ // second arg (e.g. `this.status('one', { n: 1 })`) keep working —
502
+ // the extra arg is dropped on both transports for parity. If we
503
+ // ever decide to surface it, change both call sites at once.
504
+ (instance as { status?: (m: string) => void }).status = (message: string) =>
505
+ emitFn({ emit: 'status', message });
506
+ }
507
+ if (!('progress' in instance)) {
508
+ (instance as { progress?: (v: number, m?: string) => void }).progress = (
509
+ value: number,
510
+ message?: string,
511
+ ) => emitFn({ emit: 'progress', value, message });
512
+ }
513
+ if (!('thinking' in instance)) {
514
+ (instance as { thinking?: (a?: boolean) => void }).thinking = (active = true) =>
515
+ emitFn({ emit: 'thinking', active });
516
+ }
517
+
335
518
  Object.defineProperty(instance, 'schedule', {
336
519
  value: createScheduleProvider(ctx, photonName),
337
520
  writable: false,
@@ -366,6 +549,33 @@ function withCfCapabilities(
366
549
  configurable: false,
367
550
  });
368
551
 
552
+ // `this.mcpAuthed` — true when the active /mcp request passed the
553
+ // PHOTON_MCP_BEARER check, false when no secret is configured or the
554
+ // request hit a path outside MCP dispatch (e.g. /api/* or /__call).
555
+ // User code can guard sensitive methods with:
556
+ // if (!this.mcpAuthed) throw new Error('unauthorized');
557
+ // The flag is per-tool-call (AsyncLocalStorage scoped); it doesn't
558
+ // persist across awaits that escape the dispatch context.
559
+ Object.defineProperty(instance, 'mcpAuthed', {
560
+ get() {
561
+ return mcpAuthContext.getStore()?.authed === true;
562
+ },
563
+ enumerable: false,
564
+ configurable: false,
565
+ });
566
+
567
+ // `this.cf` — the wrapped Cloudflare surface. Auto-naming
568
+ // (`bindingNameFor`) resolves `cf.kv()` to `<photon>_kv`,
569
+ // `cf.kv('cache')` to `<photon>_cache_kv`, etc. The deploy
570
+ // pipeline emits matching wrangler.toml entries from the same
571
+ // convention, so bindings always line up across runtimes.
572
+ Object.defineProperty(instance, 'cf', {
573
+ value: createCloudflareFromEnv(env, photonName),
574
+ writable: false,
575
+ enumerable: false,
576
+ configurable: false,
577
+ });
578
+
369
579
  // Human/LLM-in-the-loop primitives. Each one wraps an MCP server-initiated
370
580
  // request (sampling/createMessage, elicitation/create), pushed over the
371
581
  // active tool call's SSE response stream and awaited on a Promise keyed by
@@ -407,6 +617,14 @@ interface RequestContext {
407
617
  send: (msg: unknown) => Promise<void>;
408
618
  /** Shared pending map; the DO's POST /mcp handler resolves entries here. */
409
619
  pendingRequests: Map<string, PendingRequest>;
620
+ /**
621
+ * Client-supplied progress token from the originating request's
622
+ * `_meta.progressToken`. Echoed back in every `notifications/progress`
623
+ * event we send during the call so the client can correlate progress
624
+ * with its outstanding request. Falls back to a synthesized token if
625
+ * the client didn't supply one.
626
+ */
627
+ progressToken?: string | number;
410
628
  }
411
629
 
412
630
  /**
@@ -418,6 +636,73 @@ interface RequestContext {
418
636
  */
419
637
  const requestContext = new AsyncLocalStorage<RequestContext>();
420
638
 
639
+ /**
640
+ * Per-tool-call MCP auth context. Set when the bearer check on `/mcp`
641
+ * has passed (or skipped because no `PHOTON_MCP_BEARER` secret is
642
+ * configured) so user code reading `this.mcpAuthed` sees the right
643
+ * value for the dispatched method. The flag is scoped to the single
644
+ * `tools/call` invocation, not the DO lifetime.
645
+ */
646
+ const mcpAuthContext = new AsyncLocalStorage<{ authed: boolean }>();
647
+
648
+ /**
649
+ * Constant-time string comparison to avoid leaking the bearer through
650
+ * timing differences. Falls back to a manual byte loop because Workers
651
+ * doesn't always expose `crypto.timingSafeEqual`.
652
+ */
653
+ function timingSafeEqualString(a: string, b: string): boolean {
654
+ if (a.length !== b.length) return false;
655
+ let mismatch = 0;
656
+ for (let i = 0; i < a.length; i++) {
657
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
658
+ }
659
+ return mismatch === 0;
660
+ }
661
+
662
+ /**
663
+ * Validate the Authorization header against `env.PHOTON_MCP_BEARER`.
664
+ * Returns:
665
+ * - `{ enforced: false }` when no secret is configured (back-compat).
666
+ * - `{ enforced: true, ok: true }` when the bearer matches.
667
+ * - `{ enforced: true, ok: false, reason }` when missing or wrong.
668
+ *
669
+ * Methods that don't dispatch user code (`tools/list`, `initialize`,
670
+ * `notifications/*`, `ping`) bypass this check at the call site.
671
+ */
672
+ function checkMcpBearer(
673
+ request: Request,
674
+ env: Env
675
+ ): { enforced: false } | { enforced: true; ok: boolean; reason?: string } {
676
+ const expected = (env as Record<string, unknown>).PHOTON_MCP_BEARER;
677
+ if (typeof expected !== 'string' || expected.length === 0) {
678
+ return { enforced: false };
679
+ }
680
+ const header = request.headers.get('Authorization') ?? '';
681
+ const match = header.match(/^Bearer\s+(.+)$/i);
682
+ if (!match) {
683
+ return { enforced: true, ok: false, reason: 'Authorization: Bearer <token> header missing' };
684
+ }
685
+ const presented = match[1].trim();
686
+ if (!timingSafeEqualString(presented, expected)) {
687
+ return { enforced: true, ok: false, reason: 'bearer token does not match PHOTON_MCP_BEARER' };
688
+ }
689
+ return { enforced: true, ok: true };
690
+ }
691
+
692
+ /**
693
+ * Methods that may pass through `/mcp` without a bearer because they
694
+ * don't dispatch into user code. `tools/list` advertises the catalog,
695
+ * which is generally safe to expose; if a deployment wants to gate it
696
+ * the photon owner can set CF Access on the route.
697
+ */
698
+ const MCP_METHODS_BYPASSING_BEARER = new Set([
699
+ 'initialize',
700
+ 'notifications/initialized',
701
+ 'notifications/cancelled',
702
+ 'ping',
703
+ 'tools/list',
704
+ ]);
705
+
421
706
  function requireRequestContext(which: string): RequestContext {
422
707
  const ctx = requestContext.getStore();
423
708
  if (!ctx) {
@@ -570,6 +855,19 @@ function matchHttpRoute(
570
855
  return null;
571
856
  }
572
857
 
858
+ /**
859
+ * camelCase → kebab-case for `/api/<kebab>` route segments.
860
+ * Mirrors `src/shared/expose-route-extractor.ts#methodToKebab`. Keep both
861
+ * implementations in lock-step so an `@expose`'d method binds to the
862
+ * same path on the local server and on Cloudflare.
863
+ */
864
+ function methodToKebab(name: string): string {
865
+ return name
866
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
867
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
868
+ .toLowerCase();
869
+ }
870
+
573
871
  function matchPathPattern(
574
872
  pattern: string,
575
873
  pathname: string
@@ -698,6 +996,14 @@ abstract class BasePhotonDO extends DurableObject<Env> {
698
996
  protected abstract readonly photonName: string;
699
997
  protected abstract readonly toolDefinitions: any[];
700
998
  protected readonly httpRoutes: { method: string; path: string; handler: string }[] = [];
999
+ /**
1000
+ * Methods declared with `@expose` (Track C). Bound to `POST /api/<kebab>`
1001
+ * with a SameSite-style visibility check. `private` requires a browser-set
1002
+ * `Sec-Fetch-Site: same-origin` (or `same-site`) header — anything else,
1003
+ * or no header, is denied. `public` skips the check (anonymous third-party
1004
+ * callers like RSS readers).
1005
+ */
1006
+ protected readonly exposes: { handler: string; visibility: 'private' | 'public' }[] = [];
701
1007
  protected abstract createPhoton(): any;
702
1008
 
703
1009
  protected photon: any;
@@ -856,6 +1162,67 @@ abstract class BasePhotonDO extends DurableObject<Env> {
856
1162
  }
857
1163
  }
858
1164
 
1165
+ // Track C: @expose auto-RPC. POST /api/<kebab> dispatches to an
1166
+ // `@expose`'d method. `@get`/`@post` already won the matchedRoute
1167
+ // check above, so this only fires when no explicit HTTP route
1168
+ // matched. Visibility gate: `private` requires a browser-set
1169
+ // `Sec-Fetch-Site: same-origin` (or `same-site`) header. The Worker
1170
+ // is on the public internet — there is no localhost analog — so an
1171
+ // absent header means deny.
1172
+ if (
1173
+ this.exposes.length > 0 &&
1174
+ request.method === 'POST' &&
1175
+ url.pathname.startsWith('/api/')
1176
+ ) {
1177
+ const segment = url.pathname.slice('/api/'.length);
1178
+ const exposed = this.exposes.find((e) => methodToKebab(e.handler) === segment);
1179
+ if (exposed) {
1180
+ if (exposed.visibility === 'private') {
1181
+ const sfs = request.headers.get('sec-fetch-site')?.toLowerCase() ?? '';
1182
+ if (sfs !== 'same-origin' && sfs !== 'same-site') {
1183
+ return new Response('Forbidden: cross-site @expose call', {
1184
+ status: 403,
1185
+ headers: CORS_HEADERS,
1186
+ });
1187
+ }
1188
+ }
1189
+ const fn = (this.photon as any)[exposed.handler];
1190
+ if (typeof fn !== 'function') {
1191
+ return new Response(`Unknown handler: ${exposed.handler}`, {
1192
+ status: 500,
1193
+ headers: CORS_HEADERS,
1194
+ });
1195
+ }
1196
+ let parsed: unknown = {};
1197
+ try {
1198
+ const text = await request.text();
1199
+ if (text.length > 0) parsed = JSON.parse(text);
1200
+ } catch {
1201
+ return new Response('Invalid JSON body', { status: 400, headers: CORS_HEADERS });
1202
+ }
1203
+ try {
1204
+ const result = await fn.call(this.photon, parsed);
1205
+ if (result instanceof Response) return result;
1206
+ return Response.json(result, { headers: CORS_HEADERS });
1207
+ } catch (error: any) {
1208
+ return new Response(error?.message ?? 'Internal Server Error', {
1209
+ status: 500,
1210
+ headers: CORS_HEADERS,
1211
+ });
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ // Fall through to the [assets] binding (Track E). The wrangler.toml
1217
+ // emits this binding only when the host photon has an `assets/`
1218
+ // companion folder, so the typeof guard keeps non-asset deploys
1219
+ // quiet (no extra fetch round-trip when the binding is absent).
1220
+ const assets = (this.env as any).ASSETS as { fetch: (req: Request) => Promise<Response> } | undefined;
1221
+ if (assets && request.method === 'GET') {
1222
+ const assetResponse = await assets.fetch(request);
1223
+ if (assetResponse.status !== 404) return assetResponse;
1224
+ }
1225
+
859
1226
  return new Response('Not Found', { status: 404, headers: CORS_HEADERS });
860
1227
  }
861
1228
 
@@ -901,6 +1268,40 @@ abstract class BasePhotonDO extends DurableObject<Env> {
901
1268
  return new Response(null, { status: 204, headers: CORS_HEADERS });
902
1269
  }
903
1270
 
1271
+ // Bearer auth gate. When `PHOTON_MCP_BEARER` is set on the deployed
1272
+ // Worker (e.g. via `wrangler secret put PHOTON_MCP_BEARER`), every
1273
+ // method that dispatches into user code requires a matching
1274
+ // `Authorization: Bearer <token>` header. Discovery + handshake
1275
+ // methods (tools/list, initialize, ping, notifications/*) pass
1276
+ // through unauthed so MCP clients can complete capability
1277
+ // negotiation before authenticating. When the secret is unset the
1278
+ // gate is a no-op so existing deployments keep working.
1279
+ const method = typeof body?.method === 'string' ? body.method : '';
1280
+ const authResult = checkMcpBearer(request, (this as any).env);
1281
+ const requiresAuth = method !== '' && !MCP_METHODS_BYPASSING_BEARER.has(method);
1282
+ if (authResult.enforced && requiresAuth && !authResult.ok) {
1283
+ return new Response(
1284
+ JSON.stringify({
1285
+ jsonrpc: '2.0',
1286
+ id: body?.id ?? null,
1287
+ error: {
1288
+ code: -32001,
1289
+ message: 'Unauthorized',
1290
+ data: { reason: (authResult as { reason: string }).reason },
1291
+ },
1292
+ }),
1293
+ {
1294
+ status: 401,
1295
+ headers: {
1296
+ 'Content-Type': 'application/json',
1297
+ 'WWW-Authenticate': 'Bearer realm="photon"',
1298
+ ...CORS_HEADERS,
1299
+ },
1300
+ }
1301
+ );
1302
+ }
1303
+ const authed = authResult.enforced ? authResult.ok === true : false;
1304
+
904
1305
  // Tool calls from clients that signal SSE support get a streamed
905
1306
  // response so this.sample / this.confirm / this.elicit can push
906
1307
  // server-initiated requests inline. All other JSON-RPC methods (and
@@ -908,10 +1309,12 @@ abstract class BasePhotonDO extends DurableObject<Env> {
908
1309
  const accept = request.headers.get('Accept') ?? '';
909
1310
  const wantsSse = accept.includes('text/event-stream');
910
1311
  if (body?.method === 'tools/call' && wantsSse) {
911
- return this._streamToolCall(body);
1312
+ return mcpAuthContext.run({ authed }, () => this._streamToolCall(body));
912
1313
  }
913
1314
 
914
- const result = await handleMCPRequest(body, this.photon, this.photonName, this.toolDefinitions);
1315
+ const result = await mcpAuthContext.run({ authed }, () =>
1316
+ handleMCPRequest(body, this.photon, this.photonName, this.toolDefinitions)
1317
+ );
915
1318
  return Response.json(result, { headers: CORS_HEADERS });
916
1319
  }
917
1320
 
@@ -929,9 +1332,15 @@ abstract class BasePhotonDO extends DurableObject<Env> {
929
1332
  await writer.write(encoder.encode(`data: ${JSON.stringify(msg)}\n\n`));
930
1333
  };
931
1334
 
1335
+ // Per MCP spec, echo the client-supplied progressToken so any
1336
+ // streamed progress notifications correlate with this request.
1337
+ const clientProgressToken = (rpcRequest?.params as { _meta?: { progressToken?: string | number } })
1338
+ ?._meta?.progressToken;
1339
+ const toolName = (rpcRequest?.params as { name?: string })?.name ?? 'tool';
932
1340
  const ctx: RequestContext = {
933
1341
  send,
934
1342
  pendingRequests: this.pendingRequests,
1343
+ progressToken: clientProgressToken ?? `progress_${toolName}`,
935
1344
  };
936
1345
 
937
1346
  // Fire-and-forget: the response stream stays open as long as the writer
@@ -1043,13 +1452,18 @@ const CF_ACCESS_ENABLED = __CF_ACCESS_ENABLED__;
1043
1452
  */
1044
1453
  function extractInstance(request: Request): string {
1045
1454
  if (CF_ACCESS_ENABLED) {
1455
+ const headerEmail = request.headers.get('Cf-Access-Authenticated-User-Email');
1456
+ if (headerEmail) return headerEmail;
1046
1457
  const jwt = request.headers.get('Cf-Access-Jwt-Assertion');
1047
1458
  if (jwt) {
1048
1459
  try {
1049
- const payload = JSON.parse(atob(jwt.split('.')[1]));
1460
+ const part = jwt.split('.')[1];
1461
+ const b64 = part.replace(/-/g, '+').replace(/_/g, '/');
1462
+ const padded = b64 + '==='.slice((b64.length + 3) % 4);
1463
+ const payload = JSON.parse(atob(padded));
1050
1464
  if (payload?.email) return payload.email as string;
1051
- } catch {
1052
- // malformed JWT fall through to default resolution
1465
+ } catch (err) {
1466
+ console.warn('extractInstance: JWT parse failed', err);
1053
1467
  }
1054
1468
  }
1055
1469
  }
@@ -7,12 +7,7 @@ main = "src/worker.ts"
7
7
  compatibility_date = "2024-09-23"
8
8
  compatibility_flags = ["nodejs_compat"]
9
9
  __OBSERVABILITY__
10
- # Workers AI binding — gives every photon access to CF's edge inference
11
- # (embeddings, classification, small LLMs) via `(this as any).env.AI.run(...)`.
12
- # Free tier covers thousands of calls/day; charges only kick in at scale.
13
- # Photons that don't use it pay nothing for having the binding declared.
14
- [ai]
15
- binding = "AI"
10
+ __CF_BINDINGS__
16
11
 
17
12
  # Durable Objects backing every photon class bundled into this Worker. The
18
13
  # host photon is always bound to `PHOTON`; each `@photons` sibling gets
@@ -27,7 +22,7 @@ __DURABLE_OBJECT_BINDINGS__
27
22
  [[migrations]]
28
23
  tag = "v1"
29
24
  new_sqlite_classes = __SQLITE_CLASSES__
30
-
25
+ __ASSETS_BLOCK__
31
26
  # Uncomment to add environment variables
32
27
  # [vars]
33
28
  # API_KEY = "your-api-key"
@@ -7,6 +7,19 @@
7
7
  */
8
8
 
9
9
  export default class TemplateName {
10
+ // User-configurable knobs. Photon auto-generates a `settings` MCP tool
11
+ // from this object and persists changes to
12
+ // ~/.photon/state/<photon>/<instance>-settings.json.
13
+ //
14
+ // Reach for this whenever a value should be runtime-configurable. Use a
15
+ // constructor parameter only for primitive secrets (API keys, tokens)
16
+ // that should live in .env. Uncomment and edit:
17
+ //
18
+ // protected settings = {
19
+ // /** Friendly description shown in the settings tool */
20
+ // greeting: 'Hello',
21
+ // };
22
+
10
23
  /**
11
24
  * Optional initialization hook
12
25
  * Called once when the MCP is loaded