@portel/photon 1.6.1 → 1.8.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 (113) hide show
  1. package/README.md +111 -160
  2. package/dist/auto-ui/beam.d.ts.map +1 -1
  3. package/dist/auto-ui/beam.js +218 -106
  4. package/dist/auto-ui/beam.js.map +1 -1
  5. package/dist/auto-ui/design-system/tokens.d.ts +1 -1
  6. package/dist/auto-ui/design-system/tokens.d.ts.map +1 -1
  7. package/dist/auto-ui/design-system/tokens.js +2 -2
  8. package/dist/auto-ui/design-system/tokens.js.map +1 -1
  9. package/dist/auto-ui/frontend/index.html +1 -1
  10. package/dist/auto-ui/platform-compat.d.ts.map +1 -1
  11. package/dist/auto-ui/platform-compat.js +12 -2
  12. package/dist/auto-ui/platform-compat.js.map +1 -1
  13. package/dist/auto-ui/playground-html.js +5 -5
  14. package/dist/auto-ui/rendering/components.d.ts.map +1 -1
  15. package/dist/auto-ui/rendering/components.js +568 -0
  16. package/dist/auto-ui/rendering/components.js.map +1 -1
  17. package/dist/auto-ui/rendering/field-analyzer.d.ts +56 -0
  18. package/dist/auto-ui/rendering/field-analyzer.d.ts.map +1 -1
  19. package/dist/auto-ui/rendering/field-analyzer.js +177 -0
  20. package/dist/auto-ui/rendering/field-analyzer.js.map +1 -1
  21. package/dist/auto-ui/rendering/layout-selector.d.ts +14 -2
  22. package/dist/auto-ui/rendering/layout-selector.d.ts.map +1 -1
  23. package/dist/auto-ui/rendering/layout-selector.js +125 -1
  24. package/dist/auto-ui/rendering/layout-selector.js.map +1 -1
  25. package/dist/auto-ui/streamable-http-transport.d.ts +1 -1
  26. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  27. package/dist/auto-ui/streamable-http-transport.js +370 -26
  28. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  29. package/dist/auto-ui/types.d.ts +7 -1
  30. package/dist/auto-ui/types.d.ts.map +1 -1
  31. package/dist/auto-ui/types.js.map +1 -1
  32. package/dist/beam.bundle.js +21932 -3307
  33. package/dist/beam.bundle.js.map +4 -4
  34. package/dist/cli/commands/info.d.ts.map +1 -1
  35. package/dist/cli/commands/info.js +37 -0
  36. package/dist/cli/commands/info.js.map +1 -1
  37. package/dist/cli/commands/package.d.ts.map +1 -1
  38. package/dist/cli/commands/package.js +16 -0
  39. package/dist/cli/commands/package.js.map +1 -1
  40. package/dist/cli.d.ts.map +1 -1
  41. package/dist/cli.js +640 -17
  42. package/dist/cli.js.map +1 -1
  43. package/dist/context-store.d.ts +79 -0
  44. package/dist/context-store.d.ts.map +1 -0
  45. package/dist/context-store.js +210 -0
  46. package/dist/context-store.js.map +1 -0
  47. package/dist/daemon/client.d.ts +13 -4
  48. package/dist/daemon/client.d.ts.map +1 -1
  49. package/dist/daemon/client.js +138 -77
  50. package/dist/daemon/client.js.map +1 -1
  51. package/dist/daemon/manager.d.ts +0 -25
  52. package/dist/daemon/manager.d.ts.map +1 -1
  53. package/dist/daemon/manager.js +10 -38
  54. package/dist/daemon/manager.js.map +1 -1
  55. package/dist/daemon/protocol.d.ts +7 -2
  56. package/dist/daemon/protocol.d.ts.map +1 -1
  57. package/dist/daemon/protocol.js.map +1 -1
  58. package/dist/daemon/server.js +317 -83
  59. package/dist/daemon/server.js.map +1 -1
  60. package/dist/daemon/session-manager.d.ts +24 -4
  61. package/dist/daemon/session-manager.d.ts.map +1 -1
  62. package/dist/daemon/session-manager.js +62 -12
  63. package/dist/daemon/session-manager.js.map +1 -1
  64. package/dist/index.d.ts +0 -1
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +0 -3
  67. package/dist/index.js.map +1 -1
  68. package/dist/loader.d.ts +3 -20
  69. package/dist/loader.d.ts.map +1 -1
  70. package/dist/loader.js +87 -77
  71. package/dist/loader.js.map +1 -1
  72. package/dist/markdown-utils.d.ts.map +1 -1
  73. package/dist/markdown-utils.js +2 -1
  74. package/dist/markdown-utils.js.map +1 -1
  75. package/dist/marketplace-manager.d.ts.map +1 -1
  76. package/dist/marketplace-manager.js +20 -3
  77. package/dist/marketplace-manager.js.map +1 -1
  78. package/dist/photon-cli-runner.d.ts.map +1 -1
  79. package/dist/photon-cli-runner.js +258 -218
  80. package/dist/photon-cli-runner.js.map +1 -1
  81. package/dist/photon-doc-extractor.d.ts +2 -0
  82. package/dist/photon-doc-extractor.d.ts.map +1 -1
  83. package/dist/photon-doc-extractor.js +45 -7
  84. package/dist/photon-doc-extractor.js.map +1 -1
  85. package/dist/photons/maker.photon.d.ts.map +1 -1
  86. package/dist/photons/maker.photon.js +22 -4
  87. package/dist/photons/maker.photon.js.map +1 -1
  88. package/dist/photons/maker.photon.ts +47 -11
  89. package/dist/security-scanner.d.ts.map +1 -1
  90. package/dist/security-scanner.js +8 -2
  91. package/dist/security-scanner.js.map +1 -1
  92. package/dist/serv/index.d.ts +1 -1
  93. package/dist/serv/index.d.ts.map +1 -1
  94. package/dist/serv/index.js +6 -4
  95. package/dist/serv/index.js.map +1 -1
  96. package/dist/server.d.ts +32 -15
  97. package/dist/server.d.ts.map +1 -1
  98. package/dist/server.js +525 -483
  99. package/dist/server.js.map +1 -1
  100. package/dist/shared/security.d.ts +79 -0
  101. package/dist/shared/security.d.ts.map +1 -0
  102. package/dist/shared/security.js +251 -0
  103. package/dist/shared/security.js.map +1 -0
  104. package/dist/shell-completions.d.ts +21 -0
  105. package/dist/shell-completions.d.ts.map +1 -0
  106. package/dist/shell-completions.js +102 -0
  107. package/dist/shell-completions.js.map +1 -0
  108. package/dist/template-manager.d.ts.map +1 -1
  109. package/dist/template-manager.js +10 -3
  110. package/dist/template-manager.js.map +1 -1
  111. package/dist/version.d.ts.map +1 -1
  112. package/dist/version.js.map +1 -1
  113. package/package.json +12 -7
@@ -14,6 +14,7 @@ import * as os from 'os';
14
14
  import { spawn } from 'child_process';
15
15
  import { fileURLToPath } from 'url';
16
16
  import { createHash } from 'crypto';
17
+ import { isPathWithin, isLocalRequest, setSecurityHeaders, readBody, SimpleRateLimiter, } from '../shared/security.js';
17
18
  /**
18
19
  * Generate a unique ID for a photon based on its path.
19
20
  * This ensures photons with the same name from different paths are distinguishable.
@@ -28,15 +29,17 @@ const __dirname = path.dirname(__filename);
28
29
  import { listPhotonMCPs, resolvePhotonPath } from '../path-resolver.js';
29
30
  import { PhotonLoader } from '../loader.js';
30
31
  import { logger, createLogger } from '../shared/logger.js';
32
+ import { getErrorMessage } from '../shared/error-handler.js';
31
33
  import { toEnvVarName } from '../shared/config-docs.js';
32
34
  import { MarketplaceManager } from '../marketplace-manager.js';
33
35
  import { PhotonDocExtractor } from '../photon-doc-extractor.js';
34
36
  import { TemplateManager } from '../template-manager.js';
35
37
  import { subscribeChannel, pingDaemon } from '../daemon/client.js';
38
+ import { ensureDaemon } from '../daemon/manager.js';
36
39
  import { SchemaExtractor, } from '@portel/photon-core';
37
40
  import { generateOpenAPISpec } from './openapi-generator.js';
38
41
  import { handleStreamableHTTP, broadcastNotification, broadcastToBeam, sendToSession, requestExternalElicitation, } from './streamable-http-transport.js';
39
- import { SDKMCPClientFactory } from '../mcp-client.js';
42
+ import { SDKMCPClientFactory } from '@portel/photon-core';
40
43
  import { getBundledPhotonPath, BEAM_BUNDLED_PHOTONS } from '../shared-utils.js';
41
44
  // SDK imports for direct resource access (transport wrapper doesn't expose these yet)
42
45
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -45,7 +48,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
45
48
  import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
46
49
  import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
47
50
  // Config file path
48
- const CONFIG_FILE = path.join(os.homedir(), '.photon', 'config.json');
51
+ const CONFIG_FILE = process.env.PHOTON_CONFIG_FILE || path.join(os.homedir(), '.photon', 'config.json');
49
52
  // ════════════════════════════════════════════════════════════════════════════════
50
53
  // EXTERNAL MCP STATE (module-level for MCP transport access)
51
54
  // ════════════════════════════════════════════════════════════════════════════════
@@ -178,8 +181,7 @@ async function loadExternalMCPs(config) {
178
181
  try {
179
182
  const resourcesResult = await sdkClient.listResources();
180
183
  const resources = resourcesResult.resources || [];
181
- const appResources = resources.filter((r) => r.uri?.startsWith('ui://') ||
182
- r.mimeType === 'application/vnd.mcp.ui+html');
184
+ const appResources = resources.filter((r) => r.uri?.startsWith('ui://') || r.mimeType === 'application/vnd.mcp.ui+html');
183
185
  // Count only non-UI resources (UI resources are internal implementation detail)
184
186
  mcpInfo.resourceCount = resources.length - appResources.length;
185
187
  if (appResources.length > 0) {
@@ -257,8 +259,7 @@ async function loadExternalMCPs(config) {
257
259
  const resourcesResult = await sdkClient.listResources();
258
260
  const resources = resourcesResult.resources || [];
259
261
  // Check for MCP App resources (ui:// scheme or application/vnd.mcp.ui+html mime)
260
- const appResources = resources.filter((r) => r.uri?.startsWith('ui://') ||
261
- r.mimeType === 'application/vnd.mcp.ui+html');
262
+ const appResources = resources.filter((r) => r.uri?.startsWith('ui://') || r.mimeType === 'application/vnd.mcp.ui+html');
262
263
  // Count only non-UI resources (UI resources are internal implementation detail)
263
264
  mcpInfo.resourceCount = resources.length - appResources.length;
264
265
  if (appResources.length > 0) {
@@ -278,19 +279,10 @@ async function loadExternalMCPs(config) {
278
279
  mcpInfo.methods = methods;
279
280
  }
280
281
  catch (sdkError) {
281
- // SDK client failed - fall back to wrapper client
282
- logger.debug(`SDK client failed for ${name}, using wrapper: ${sdkError}`);
283
- // Try wrapper client as fallback
284
- const tools = await client.list();
285
- methods = (tools || []).map((tool) => ({
286
- name: tool.name,
287
- description: tool.description || '',
288
- params: tool.inputSchema || { type: 'object', properties: {} },
289
- returns: { type: 'object' },
290
- icon: tool['x-icon'],
291
- }));
292
- mcpInfo.connected = true;
293
- mcpInfo.methods = methods;
282
+ // SDK client failed don't fall back to wrapper for stdio MCPs
283
+ // (same command would fail identically, and wrapper spawns a process
284
+ // without stderr suppression, leaking raw Node.js stack traces)
285
+ throw sdkError;
294
286
  }
295
287
  }
296
288
  else {
@@ -319,7 +311,17 @@ async function loadExternalMCPs(config) {
319
311
  catch (error) {
320
312
  const errorMsg = error instanceof Error ? error.message : String(error);
321
313
  mcpInfo.errorMessage = errorMsg.slice(0, 200);
322
- logger.warn(`⚠️ Failed to connect to external MCP: ${name} - ${errorMsg}`);
314
+ // User-friendly error messages for common failures
315
+ const shortMsg = errorMsg.includes('Cannot find module')
316
+ ? `Module not found (run npm build in the MCP directory)`
317
+ : errorMsg.includes('ENOENT')
318
+ ? `Command not found: ${serverConfig.command}`
319
+ : errorMsg.includes('Connection timeout')
320
+ ? `Connection timed out (server may not be running)`
321
+ : errorMsg.includes('Connection closed')
322
+ ? `Server exited immediately (check configuration)`
323
+ : errorMsg.slice(0, 120);
324
+ logger.warn(`⚠️ External MCP "${name}" — ${shortMsg}`);
323
325
  }
324
326
  results.push(mcpInfo);
325
327
  }
@@ -358,8 +360,7 @@ async function reconnectExternalMCP(name) {
358
360
  try {
359
361
  const resourcesResult = await sdkClient.listResources();
360
362
  const resources = resourcesResult.resources || [];
361
- const appResources = resources.filter((r) => r.uri?.startsWith('ui://') ||
362
- r.mimeType === 'application/vnd.mcp.ui+html');
363
+ const appResources = resources.filter((r) => r.uri?.startsWith('ui://') || r.mimeType === 'application/vnd.mcp.ui+html');
363
364
  // Count only non-UI resources (UI resources are internal implementation detail)
364
365
  mcp.resourceCount = resources.length - appResources.length;
365
366
  if (appResources.length > 0) {
@@ -671,8 +672,8 @@ export async function startBeam(rawWorkingDir, port) {
671
672
  defaultValue: p.defaultValue,
672
673
  }));
673
674
  // Extract @ui template path from class-level JSDoc
674
- const classJsdocMatch = source.match(/\/\*\*[\s\S]*?\*\/\s*(?=export\s+default\s+class)/)
675
- || source.match(/^\/\*\*([\s\S]*?)\*\//);
675
+ const classJsdocMatch = source.match(/\/\*\*[\s\S]*?\*\/\s*(?=export\s+default\s+class)/) ||
676
+ source.match(/^\/\*\*([\s\S]*?)\*\//);
676
677
  if (classJsdocMatch) {
677
678
  const uiMatch = classJsdocMatch[0].match(/@ui\s+([^\s*]+)/);
678
679
  if (uiMatch) {
@@ -745,7 +746,9 @@ export async function startBeam(rawWorkingDir, port) {
745
746
  linkedUi: linkedAsset?.id,
746
747
  ...(schema.isStatic ? { isStatic: true } : {}),
747
748
  ...(schema.webhook ? { webhook: schema.webhook } : {}),
748
- ...(schema.scheduled || schema.cron ? { scheduled: schema.scheduled || schema.cron } : {}),
749
+ ...(schema.scheduled || schema.cron
750
+ ? { scheduled: schema.scheduled || schema.cron }
751
+ : {}),
749
752
  ...(schema.locked ? { locked: schema.locked } : {}),
750
753
  };
751
754
  });
@@ -799,6 +802,7 @@ export async function startBeam(rawWorkingDir, port) {
799
802
  catch {
800
803
  // No install metadata - that's fine
801
804
  }
805
+ const isStateful = schemaSource ? /@stateful\b/.test(schemaSource) : false;
802
806
  return {
803
807
  id: generatePhotonId(photonPath),
804
808
  name,
@@ -818,8 +822,10 @@ export async function startBeam(rawWorkingDir, port) {
818
822
  resourceCount,
819
823
  promptCount,
820
824
  installSource,
825
+ ...(isStateful && { stateful: true }),
821
826
  ...(constructorParams.length > 0 && { requiredParams: constructorParams }),
822
- ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
827
+ ...(mcp.injectedPhotons &&
828
+ mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
823
829
  };
824
830
  }
825
831
  catch (error) {
@@ -839,57 +845,58 @@ export async function startBeam(rawWorkingDir, port) {
839
845
  }
840
846
  }
841
847
  const channelSubscriptions = new Map();
842
- const EVENT_BUFFER_SIZE = 30; // Keep last 30 events per channel
848
+ /** Buffer retention window events older than this are purged */
849
+ const EVENT_BUFFER_DURATION_MS = 5 * 60 * 1000; // 5 minutes
843
850
  const channelEventBuffers = new Map();
844
851
  // Store an event in the channel buffer
845
852
  function bufferEvent(channel, method, params) {
846
853
  let buffer = channelEventBuffers.get(channel);
847
854
  if (!buffer) {
848
- buffer = { events: [], nextId: 1 };
855
+ buffer = { events: [] };
849
856
  channelEventBuffers.set(channel, buffer);
850
857
  }
851
- const eventId = buffer.nextId++;
858
+ const now = Date.now();
852
859
  const event = {
853
- id: eventId,
860
+ id: now,
854
861
  method,
855
862
  params,
856
- timestamp: Date.now(),
863
+ timestamp: now,
857
864
  };
858
865
  buffer.events.push(event);
859
- // Keep only last N events (circular buffer)
860
- if (buffer.events.length > EVENT_BUFFER_SIZE) {
866
+ // Purge events older than retention window
867
+ const cutoff = now - EVENT_BUFFER_DURATION_MS;
868
+ while (buffer.events.length > 0 && buffer.events[0].timestamp < cutoff) {
861
869
  buffer.events.shift();
862
870
  }
863
- return eventId;
871
+ return now;
864
872
  }
865
- // Replay missed events to a specific session, or signal refresh needed
866
- function replayEventsToSession(sessionId, channel, lastEventId) {
873
+ // Replay missed events to a specific session, or signal full sync needed
874
+ function replayEventsToSession(sessionId, channel, lastTimestamp) {
867
875
  const buffer = channelEventBuffers.get(channel);
868
876
  // No buffer = no events ever sent on this channel
869
877
  if (!buffer || buffer.events.length === 0) {
870
878
  return { replayed: 0, refreshNeeded: false };
871
879
  }
872
- // No lastEventId = client is fresh, no replay needed
873
- if (lastEventId === undefined) {
880
+ // No lastTimestamp = client is fresh, no replay needed
881
+ if (lastTimestamp === undefined) {
874
882
  return { replayed: 0, refreshNeeded: false };
875
883
  }
876
884
  const oldestEvent = buffer.events[0];
877
- // If lastEventId is older than our oldest buffered event, signal refresh needed
878
- if (lastEventId < oldestEvent.id) {
885
+ // Stale: client's timestamp is older than buffer window full sync needed
886
+ if (lastTimestamp < oldestEvent.timestamp) {
879
887
  sendToSession(sessionId, 'photon/refresh-needed', { channel });
880
- logger.info(`📡 Replay: ${channel} - lastEventId ${lastEventId} too old (oldest: ${oldestEvent.id}), refresh needed`);
888
+ logger.info(`📡 Stale client on ${channel} - last seen ${new Date(lastTimestamp).toISOString()}, oldest buffered ${new Date(oldestEvent.timestamp).toISOString()}, full sync needed`);
881
889
  return { replayed: 0, refreshNeeded: true };
882
890
  }
883
- // Find events to replay (all events after lastEventId)
884
- const eventsToReplay = buffer.events.filter((e) => e.id > lastEventId);
891
+ // Delta sync: replay events after client's last timestamp
892
+ const eventsToReplay = buffer.events.filter((e) => e.timestamp > lastTimestamp);
885
893
  if (eventsToReplay.length === 0) {
886
894
  return { replayed: 0, refreshNeeded: false };
887
895
  }
888
- // Replay each missed event to this session
889
896
  for (const event of eventsToReplay) {
890
- sendToSession(sessionId, event.method, { ...event.params, _eventId: event.id });
897
+ sendToSession(sessionId, event.method, { ...event.params, _eventId: event.timestamp });
891
898
  }
892
- logger.info(`📡 Replay: ${channel} - replayed ${eventsToReplay.length} events (${lastEventId + 1} to ${buffer.nextId - 1})`);
899
+ logger.info(`📡 Delta sync: ${channel} - replayed ${eventsToReplay.length} events`);
893
900
  return { replayed: eventsToReplay.length, refreshNeeded: false };
894
901
  }
895
902
  // ══════════════════════════════════════════════════════════════════════════════
@@ -963,8 +970,8 @@ export async function startBeam(rawWorkingDir, port) {
963
970
  // Called when a client starts viewing a board (from MCP notification)
964
971
  // photonId: hash of photon path (unique across servers)
965
972
  // itemId: whatever the photon uses to identify the item (e.g., board name)
966
- // lastEventId: optional - if provided, replay missed events or signal refresh needed
967
- function onClientViewingBoard(sessionId, photonId, itemId, lastEventId) {
973
+ // lastTimestamp: optional - if provided, delta sync missed events or signal full sync needed
974
+ function onClientViewingBoard(sessionId, photonId, itemId, lastTimestamp) {
968
975
  const prevState = sessionViewState.get(sessionId);
969
976
  // Unsubscribe from previous item if different
970
977
  if (prevState?.itemId && (prevState.photonId !== photonId || prevState.itemId !== itemId)) {
@@ -975,9 +982,9 @@ export async function startBeam(rawWorkingDir, port) {
975
982
  const channel = `${photonId}:${itemId}`;
976
983
  sessionViewState.set(sessionId, { photonId, itemId });
977
984
  subscribeToChannel(channel);
978
- // Replay missed events if lastEventId is provided
979
- if (lastEventId !== undefined) {
980
- replayEventsToSession(sessionId, channel, lastEventId);
985
+ // Delta sync missed events if lastTimestamp is provided
986
+ if (lastTimestamp !== undefined) {
987
+ replayEventsToSession(sessionId, channel, lastTimestamp);
981
988
  }
982
989
  }
983
990
  // Called when a client disconnects
@@ -1014,8 +1021,12 @@ export async function startBeam(rawWorkingDir, port) {
1014
1021
  return null; // UI asset not found
1015
1022
  }
1016
1023
  };
1024
+ // Security: rate limiter for API endpoints
1025
+ const apiRateLimiter = new SimpleRateLimiter(30, 60_000);
1017
1026
  // Create HTTP server
1018
1027
  const server = http.createServer(async (req, res) => {
1028
+ // Security: set standard security headers on all responses
1029
+ setSecurityHeaders(res);
1019
1030
  const url = new URL(req.url || '/', `http://${req.headers.host}`);
1020
1031
  // ══════════════════════════════════════════════════════════════════════════
1021
1032
  // MCP Streamable HTTP Transport (standard MCP clients like Claude Desktop)
@@ -1133,17 +1144,18 @@ export async function startBeam(rawWorkingDir, port) {
1133
1144
  root = path.resolve(workdirEnv);
1134
1145
  }
1135
1146
  }
1136
- const dirPath = url.searchParams.get('path') || root || workingDir;
1147
+ // Security: default browse root to workingDir if not specified
1148
+ if (!root) {
1149
+ root = workingDir;
1150
+ }
1151
+ const dirPath = url.searchParams.get('path') || root;
1137
1152
  try {
1138
1153
  const resolved = path.resolve(dirPath);
1139
- // Validate path is within root (if specified)
1140
- if (root) {
1141
- const resolvedRoot = path.resolve(root);
1142
- if (!resolved.startsWith(resolvedRoot)) {
1143
- res.writeHead(403);
1144
- res.end(JSON.stringify({ error: 'Access denied: outside allowed directory' }));
1145
- return;
1146
- }
1154
+ // Security: always enforce path boundary using isPathWithin
1155
+ if (!isPathWithin(resolved, root)) {
1156
+ res.writeHead(403);
1157
+ res.end(JSON.stringify({ error: 'Access denied: outside allowed directory' }));
1158
+ return;
1147
1159
  }
1148
1160
  const stat = await fs.stat(resolved);
1149
1161
  if (!stat.isDirectory()) {
@@ -1187,6 +1199,12 @@ export async function startBeam(rawWorkingDir, port) {
1187
1199
  return;
1188
1200
  }
1189
1201
  const resolved = path.resolve(filePath);
1202
+ // Security: prevent path traversal — file must be within working directory
1203
+ if (!isPathWithin(resolved, workingDir)) {
1204
+ res.writeHead(403);
1205
+ res.end('Access denied: outside allowed directory');
1206
+ return;
1207
+ }
1190
1208
  try {
1191
1209
  const fileStat = await fs.stat(resolved);
1192
1210
  if (!fileStat.isFile()) {
@@ -1367,9 +1385,19 @@ export async function startBeam(rawWorkingDir, port) {
1367
1385
  }
1368
1386
  // Resolve template path relative to photon's directory
1369
1387
  const photonDir = path.dirname(photon.path);
1370
- const fullTemplatePath = path.isAbsolute(templateFile)
1371
- ? templateFile
1372
- : path.join(photonDir, templateFile);
1388
+ // Security: reject absolute template paths — must be relative to photon dir
1389
+ if (path.isAbsolute(templateFile)) {
1390
+ res.writeHead(403);
1391
+ res.end(JSON.stringify({ error: 'Absolute template paths are not allowed' }));
1392
+ return;
1393
+ }
1394
+ const fullTemplatePath = path.join(photonDir, templateFile);
1395
+ // Security: validate resolved path is within photon directory
1396
+ if (!isPathWithin(fullTemplatePath, photonDir)) {
1397
+ res.writeHead(403);
1398
+ res.end(JSON.stringify({ error: 'Template path traversal detected' }));
1399
+ return;
1400
+ }
1373
1401
  try {
1374
1402
  const templateContent = await fs.readFile(fullTemplatePath, 'utf-8');
1375
1403
  res.setHeader('Content-Type', 'text/html');
@@ -1608,38 +1636,49 @@ export async function startBeam(rawWorkingDir, port) {
1608
1636
  }
1609
1637
  // Invoke API: Direct HTTP endpoint for method invocation (used by PWA)
1610
1638
  if (url.pathname === '/api/invoke' && req.method === 'POST') {
1611
- let body = '';
1612
- req.on('data', (chunk) => (body += chunk));
1613
- req.on('end', async () => {
1614
- try {
1615
- const { photon: photonName, method, args } = JSON.parse(body);
1616
- if (!photonName || !method) {
1617
- res.writeHead(400);
1618
- res.end(JSON.stringify({ error: 'Missing photon or method' }));
1619
- return;
1620
- }
1621
- const mcp = photonMCPs.get(photonName);
1622
- if (!mcp || !mcp.instance) {
1623
- res.writeHead(404);
1624
- res.end(JSON.stringify({ error: `Photon not found: ${photonName}` }));
1625
- return;
1626
- }
1627
- if (typeof mcp.instance[method] !== 'function') {
1628
- res.writeHead(404);
1629
- res.end(JSON.stringify({ error: `Method not found: ${method}` }));
1630
- return;
1631
- }
1632
- const result = await mcp.instance[method](args || {});
1633
- res.setHeader('Content-Type', 'application/json');
1634
- res.writeHead(200);
1635
- res.end(JSON.stringify({ result }));
1639
+ // Security: only allow local requests
1640
+ if (!isLocalRequest(req)) {
1641
+ res.writeHead(403);
1642
+ res.end(JSON.stringify({ error: 'Forbidden: non-local request' }));
1643
+ return;
1644
+ }
1645
+ // Security: rate limiting
1646
+ const clientKey = req.socket?.remoteAddress || 'unknown';
1647
+ if (!apiRateLimiter.isAllowed(clientKey)) {
1648
+ res.writeHead(429);
1649
+ res.end(JSON.stringify({ error: 'Too many requests' }));
1650
+ return;
1651
+ }
1652
+ try {
1653
+ const body = await readBody(req);
1654
+ const { photon: photonName, method, args } = JSON.parse(body);
1655
+ if (!photonName || !method) {
1656
+ res.writeHead(400);
1657
+ res.end(JSON.stringify({ error: 'Missing photon or method' }));
1658
+ return;
1636
1659
  }
1637
- catch (err) {
1638
- res.setHeader('Content-Type', 'application/json');
1639
- res.writeHead(500);
1640
- res.end(JSON.stringify({ error: err.message || String(err) }));
1660
+ const mcp = photonMCPs.get(photonName);
1661
+ if (!mcp || !mcp.instance) {
1662
+ res.writeHead(404);
1663
+ res.end(JSON.stringify({ error: `Photon not found: ${photonName}` }));
1664
+ return;
1641
1665
  }
1642
- });
1666
+ if (typeof mcp.instance[method] !== 'function') {
1667
+ res.writeHead(404);
1668
+ res.end(JSON.stringify({ error: `Method not found: ${method}` }));
1669
+ return;
1670
+ }
1671
+ const result = await mcp.instance[method](args || {});
1672
+ res.setHeader('Content-Type', 'application/json');
1673
+ res.writeHead(200);
1674
+ res.end(JSON.stringify({ result }));
1675
+ }
1676
+ catch (err) {
1677
+ const status = err.message?.includes('too large') ? 413 : 500;
1678
+ res.setHeader('Content-Type', 'application/json');
1679
+ res.writeHead(status);
1680
+ res.end(JSON.stringify({ error: err.message || String(err) }));
1681
+ }
1643
1682
  return;
1644
1683
  }
1645
1684
  // Platform Bridge API: Generate platform compatibility script
@@ -2232,6 +2271,26 @@ export async function startBeam(rawWorkingDir, port) {
2232
2271
  logger.info(isNewPhoton
2233
2272
  ? `✨ New photon detected: ${photonName}`
2234
2273
  : `🔄 File change detected, reloading ${photonName}...`);
2274
+ // Auto-scaffold empty photon files with a starter template
2275
+ if (isNewPhoton) {
2276
+ try {
2277
+ const rawContent = await fs.readFile(photonPath, 'utf-8');
2278
+ if (rawContent.trim().length === 0) {
2279
+ const className = photonName
2280
+ .split(/[-_]/)
2281
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
2282
+ .join('');
2283
+ const scaffold = `/**\n * ${className} Photon\n */\n\nexport default class ${className} {\n /**\n * Example tool\n * @param message Message to echo\n */\n async echo(params: { message: string }) {\n return \`Echo: \${params.message}\`;\n }\n}\n`;
2284
+ await fs.writeFile(photonPath, scaffold, 'utf-8');
2285
+ logger.info(`📝 Scaffolded empty file: ${photonName}.photon.ts`);
2286
+ // The write triggers another watcher event which will load the scaffolded photon
2287
+ return;
2288
+ }
2289
+ }
2290
+ catch {
2291
+ // File read failed, continue with normal load attempt
2292
+ }
2293
+ }
2235
2294
  // For new photons, check if configuration is needed first
2236
2295
  if (isNewPhoton) {
2237
2296
  const extractor = new SchemaExtractor();
@@ -2342,6 +2401,7 @@ export async function startBeam(rawWorkingDir, port) {
2342
2401
  // Can't extract params
2343
2402
  }
2344
2403
  backfillEnvDefaults(mcp.instance, reloadConstructorParams);
2404
+ const isStateful = /@stateful\b/.test(reloadSource);
2345
2405
  const reloadedPhoton = {
2346
2406
  id: generatePhotonId(photonPath),
2347
2407
  name: photonName,
@@ -2353,8 +2413,10 @@ export async function startBeam(rawWorkingDir, port) {
2353
2413
  description: reloadClassMeta.description,
2354
2414
  icon: reloadClassMeta.icon,
2355
2415
  internal: reloadClassMeta.internal,
2416
+ ...(isStateful && { stateful: true }),
2356
2417
  ...(reloadConstructorParams.length > 0 && { requiredParams: reloadConstructorParams }),
2357
- ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2418
+ ...(mcp.injectedPhotons &&
2419
+ mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2358
2420
  };
2359
2421
  if (isNewPhoton) {
2360
2422
  photons.push(reloadedPhoton);
@@ -2499,12 +2561,22 @@ export async function startBeam(rawWorkingDir, port) {
2499
2561
  process.exit(1);
2500
2562
  }
2501
2563
  });
2502
- server.listen(currentPort, '0.0.0.0', () => {
2564
+ // Security: bind to localhost by default, configurable via BEAM_BIND_ADDRESS
2565
+ const bindAddress = process.env.BEAM_BIND_ADDRESS || '127.0.0.1';
2566
+ server.listen(currentPort, bindAddress, () => {
2503
2567
  process.env.BEAM_PORT = String(currentPort);
2504
2568
  const url = `http://localhost:${currentPort}`;
2505
2569
  console.log(`\n⚡ Photon Beam → ${url} (loading photons...)\n`);
2506
2570
  resolve();
2507
2571
  });
2572
+ // Configure server and socket timeouts to prevent premature disconnections
2573
+ // Disable server timeout for long-lived SSE connections (0 = no timeout)
2574
+ server.setTimeout(0);
2575
+ // Enable TCP keepalive on all connections to prevent intermediary timeouts
2576
+ server.on('connection', (socket) => {
2577
+ socket.setKeepAlive(true, 60000); // Send keepalive probe every 60s
2578
+ socket.setTimeout(0); // Disable socket inactivity timeout
2579
+ });
2508
2580
  };
2509
2581
  tryListen();
2510
2582
  });
@@ -2529,12 +2601,49 @@ export async function startBeam(rawWorkingDir, port) {
2529
2601
  const photonStatus = unconfiguredCount > 0
2530
2602
  ? `${configuredCount} ready, ${unconfiguredCount} need setup`
2531
2603
  : `${configuredCount} photon${configuredCount !== 1 ? 's' : ''} ready`;
2532
- const mcpStatus = externalMCPList.length > 0
2533
- ? `, ${connectedMCPs}/${externalMCPList.length} MCPs`
2534
- : '';
2604
+ const mcpStatus = externalMCPList.length > 0 ? `, ${connectedMCPs}/${externalMCPList.length} MCPs` : '';
2535
2605
  console.log(`⚡ Photon Beam ready (${photonStatus}${mcpStatus})`);
2536
2606
  // Notify connected clients that photon list is now available
2537
2607
  broadcastPhotonChange();
2608
+ // Auto-start daemon and subscribe to state-changed events for stateful photons
2609
+ // Uses reconnect: true so subscriptions survive daemon restarts
2610
+ const statefulPhotons = photons.filter((p) => p.stateful && p.configured);
2611
+ if (statefulPhotons.length > 0) {
2612
+ try {
2613
+ await ensureDaemon();
2614
+ for (const photon of statefulPhotons) {
2615
+ const photonName = photon.name;
2616
+ const channel = `${photonName}:state-changed`;
2617
+ subscribeChannel(photonName, channel, (message) => {
2618
+ broadcastToBeam('photon/state-changed', {
2619
+ photon: photonName,
2620
+ method: message?.method,
2621
+ data: message?.data,
2622
+ });
2623
+ }, {
2624
+ reconnect: true,
2625
+ onReconnect: () => logger.info(`📡 Reconnected ${channel} subscription`),
2626
+ onRefreshNeeded: () => {
2627
+ logger.info(`📡 Refresh needed for ${channel} (events lost during daemon restart)`);
2628
+ broadcastToBeam('photon/state-changed', {
2629
+ photon: photonName,
2630
+ method: '_refresh',
2631
+ data: {},
2632
+ });
2633
+ },
2634
+ })
2635
+ .then(() => {
2636
+ logger.info(`📡 Subscribed to ${channel} for cross-client sync`);
2637
+ })
2638
+ .catch((err) => {
2639
+ logger.warn(`Failed to subscribe to ${channel}: ${getErrorMessage(err)}`);
2640
+ });
2641
+ }
2642
+ }
2643
+ catch (err) {
2644
+ logger.warn(`Failed to start daemon for stateful photons: ${getErrorMessage(err)}`);
2645
+ }
2646
+ }
2538
2647
  // Set up file watchers for symlinked and bundled photon assets (now that photons are loaded)
2539
2648
  for (const photon of photons) {
2540
2649
  if (!photon.path) {
@@ -2673,7 +2782,9 @@ export async function startBeam(rawWorkingDir, port) {
2673
2782
  externalMCPSDKClients.delete(name);
2674
2783
  }
2675
2784
  }
2676
- catch { /* ignore */ }
2785
+ catch {
2786
+ /* ignore */
2787
+ }
2677
2788
  externalMCPClients.delete(name);
2678
2789
  logger.info(`🔌 Removed external MCP: ${name}`);
2679
2790
  }
@@ -2701,7 +2812,9 @@ export async function startBeam(rawWorkingDir, port) {
2701
2812
  externalMCPSDKClients.delete(name);
2702
2813
  }
2703
2814
  }
2704
- catch { /* ignore */ }
2815
+ catch {
2816
+ /* ignore */
2817
+ }
2705
2818
  externalMCPClients.delete(name);
2706
2819
  externalMCPs.splice(idx, 1);
2707
2820
  }
@@ -2812,7 +2925,8 @@ async function configurePhotonViaMCP(photonName, config, photons, photonMCPs, lo
2812
2925
  isApp,
2813
2926
  appEntry: mainMethod,
2814
2927
  assets: mcp.assets,
2815
- ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2928
+ ...(mcp.injectedPhotons &&
2929
+ mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2816
2930
  };
2817
2931
  photons[photonIndex] = configuredPhoton;
2818
2932
  logger.info(`✅ ${photonName} configured via MCP`);
@@ -2908,7 +3022,8 @@ async function reloadPhotonViaMCP(photonName, photons, photonMCPs, loader, saved
2908
3022
  description: reloadClassMeta.description,
2909
3023
  icon: reloadClassMeta.icon,
2910
3024
  internal: reloadClassMeta.internal,
2911
- ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
3025
+ ...(mcp.injectedPhotons &&
3026
+ mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2912
3027
  };
2913
3028
  photons[photonIndex] = reloadedPhoton;
2914
3029
  logger.info(`🔄 ${photonName} reloaded via MCP`);
@@ -3000,10 +3115,7 @@ async function generatePhotonHelpMarkdown(photonName, photons) {
3000
3115
  const mdPath = path.join(sourceDir, `${photonName}.md`);
3001
3116
  // Check if .md file already exists and is newer than the photon source
3002
3117
  try {
3003
- const [mdStat, srcStat] = await Promise.all([
3004
- fs.stat(mdPath),
3005
- fs.stat(photon.path),
3006
- ]);
3118
+ const [mdStat, srcStat] = await Promise.all([fs.stat(mdPath), fs.stat(photon.path)]);
3007
3119
  if (mdStat.mtimeMs >= srcStat.mtimeMs) {
3008
3120
  const existing = await fs.readFile(mdPath, 'utf-8');
3009
3121
  if (existing.trim()) {