@portel/photon 1.6.1 → 1.7.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 (58) hide show
  1. package/README.md +92 -140
  2. package/dist/auto-ui/beam.d.ts.map +1 -1
  3. package/dist/auto-ui/beam.js +102 -65
  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 +1 -1
  8. package/dist/auto-ui/design-system/tokens.js.map +1 -1
  9. package/dist/auto-ui/platform-compat.d.ts.map +1 -1
  10. package/dist/auto-ui/platform-compat.js +12 -2
  11. package/dist/auto-ui/platform-compat.js.map +1 -1
  12. package/dist/auto-ui/playground-html.js +5 -5
  13. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  14. package/dist/auto-ui/streamable-http-transport.js +17 -7
  15. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  16. package/dist/beam.bundle.js +771 -371
  17. package/dist/beam.bundle.js.map +2 -2
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +12 -3
  20. package/dist/cli.js.map +1 -1
  21. package/dist/daemon/server.js +62 -50
  22. package/dist/daemon/server.js.map +1 -1
  23. package/dist/loader.d.ts.map +1 -1
  24. package/dist/loader.js +35 -3
  25. package/dist/loader.js.map +1 -1
  26. package/dist/markdown-utils.d.ts.map +1 -1
  27. package/dist/markdown-utils.js +2 -1
  28. package/dist/markdown-utils.js.map +1 -1
  29. package/dist/marketplace-manager.d.ts.map +1 -1
  30. package/dist/marketplace-manager.js +20 -3
  31. package/dist/marketplace-manager.js.map +1 -1
  32. package/dist/photon-doc-extractor.d.ts.map +1 -1
  33. package/dist/photon-doc-extractor.js +3 -1
  34. package/dist/photon-doc-extractor.js.map +1 -1
  35. package/dist/photons/maker.photon.d.ts.map +1 -1
  36. package/dist/photons/maker.photon.js +20 -4
  37. package/dist/photons/maker.photon.js.map +1 -1
  38. package/dist/photons/maker.photon.ts +45 -11
  39. package/dist/security-scanner.d.ts.map +1 -1
  40. package/dist/security-scanner.js +8 -2
  41. package/dist/security-scanner.js.map +1 -1
  42. package/dist/serv/index.d.ts +1 -1
  43. package/dist/serv/index.d.ts.map +1 -1
  44. package/dist/serv/index.js +6 -4
  45. package/dist/serv/index.js.map +1 -1
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +69 -26
  48. package/dist/server.js.map +1 -1
  49. package/dist/shared/security.d.ts +79 -0
  50. package/dist/shared/security.d.ts.map +1 -0
  51. package/dist/shared/security.js +255 -0
  52. package/dist/shared/security.js.map +1 -0
  53. package/dist/template-manager.d.ts.map +1 -1
  54. package/dist/template-manager.js +10 -3
  55. package/dist/template-manager.js.map +1 -1
  56. package/dist/version.d.ts.map +1 -1
  57. package/dist/version.js.map +1 -1
  58. package/package.json +4 -3
package/README.md CHANGED
@@ -14,7 +14,9 @@ A framework, runtime, and ecosystem. Batteries included.
14
14
  [![Node](https://img.shields.io/badge/node-%3E%3D18-43853d.svg)](https://nodejs.org)
15
15
  [![MCP](https://img.shields.io/badge/MCP-compatible-7c3aed.svg)](https://modelcontextprotocol.io)
16
16
 
17
- [Quick Start](#quick-start) · [How It Works](#how-it-works) · [Beam UI](#beam) · [Marketplace](#marketplace) · [Docs](#documentation)
17
+ [Quick Start](#quick-start) · [Why Photon](#why-did-we-build-this) · [Beam UI](#beam) · [How It Works](#how-it-works) · [Docs](#documentation)
18
+
19
+ [![Watch: Why Photon? (2 min)](https://img.youtube.com/vi/FI0M8s6ZKv4/maxresdefault.jpg)](https://www.youtube.com/watch?v=FI0M8s6ZKv4)
18
20
 
19
21
  </div>
20
22
 
@@ -22,7 +24,7 @@ A framework, runtime, and ecosystem. Batteries included.
22
24
 
23
25
  ## What Is This Thing?
24
26
 
25
- So, here is the situation. You write a single TypeScript file. Just one. And somehow, through some dark magic I dont fully understand either, you get three things at once:
27
+ So, here is the situation. You write a single TypeScript file. Just one. And somehow, through some dark magic I don't fully understand either, you get three things at once:
26
28
 
27
29
  1. **An MCP server** (so Claude or Cursor can use your tools).
28
30
  2. **A CLI tool** (so you can run it from the terminal like a normal human).
@@ -36,20 +38,6 @@ It looks like this:
36
38
 
37
39
  You just write the logic. Photon deals with the protocols, schemas, and the boring stuff that usually makes you question your life choices.
38
40
 
39
- ### The Basics
40
-
41
- If you are just skimming, here is what you need to know:
42
-
43
- | Concept | What it is | Learn more |
44
- |---------|-----------|------------|
45
- | **MCP** | A way for AI to use your tools. It’s a standard. | [modelcontextprotocol.io](https://modelcontextprotocol.io/introduction) |
46
- | **Photon file** | A `.photon.ts` file. You define tools as methods in a class. | [Guide](./GUIDE.md) |
47
- | **Beam** | A web dashboard. It shows your tools as forms. | [Beam UI](#beam) |
48
- | **Marketplace** | A way to get other people’s photons. | [Marketplace](#marketplace) |
49
- | **Daemon** | A background thing that handles messages and jobs. | [Daemon Pub/Sub](./DAEMON-PUBSUB.md) |
50
- | **Tags** | JSDoc comments that tell Photon what to do. | [Tag Reference](./DOCBLOCK-TAGS.md) |
51
- | **Custom UI** | When the auto-generated forms aren't enough. | [Custom UI Guide](./CUSTOM-UI.md) |
52
-
53
41
  ### Who Is This For?
54
42
 
55
43
  * **Developers** who want to give AI access to their database but are too lazy to write a full server.
@@ -60,6 +48,18 @@ You don't need to know what "MCP" actually stands for. If you can write a TypeSc
60
48
 
61
49
  ---
62
50
 
51
+ ## Why did we build this?
52
+
53
+ Three reasons, if you want the short version. ([Read the longer version](./WHY-PHOTON.md))
54
+
55
+ **MCP is personal.** The best MCP is the one built for exactly one use case. Yours. Your team's. Your company's. When you stop building for everyone, the code gets absurdly simple. One file. Twelve lines. Not twelve hundred.
56
+
57
+ **Solve once, run forever.** If an LLM figured out your workflow the first time, why ask it to re-derive the same answer from scratch every time? Photon lets you keep the answer. No middleman, no tokens, no latency.
58
+
59
+ **Same door, every key.** AI calls it through MCP. You call it through CLI. You open it in Beam. Same methods, same data, same result. And half the time, you don't need AI at all. You just need the data.
60
+
61
+ ---
62
+
63
63
  ## Quick Start
64
64
 
65
65
  If you are the type who likes to just run commands and see what happens:
@@ -81,11 +81,69 @@ npx @portel/photon
81
81
 
82
82
  ---
83
83
 
84
+ ## Beam
85
+
86
+ Beam is the dashboard. It's where you go to poke your tools and see if they work before you let an AI loose on them.
87
+
88
+ Run `photon`. That's it.
89
+
90
+ <div align="center">
91
+ <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/beam-dashboard.png" alt="Beam Dashboard" width="100%">
92
+ </div>
93
+
94
+ ---
95
+
96
+ ## Connecting to AI
97
+
98
+ If you want to use this with Claude or Cursor, you need the config.
99
+
100
+ ```bash
101
+ photon info weather --mcp
102
+ ```
103
+
104
+ It spits out some JSON:
105
+
106
+ ```json
107
+ {
108
+ "mcpServers": {
109
+ "weather": {
110
+ "command": "photon",
111
+ "args": ["mcp", "weather"]
112
+ }
113
+ }
114
+ }
115
+ ```
116
+
117
+ Copy that. Paste it into your AI client's config file. Done.
118
+
119
+ Works with [Claude Desktop](https://claude.ai/download), [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor](https://cursor.com), and any [MCP-compatible client](https://modelcontextprotocol.io).
120
+
121
+ ---
122
+
123
+ ## Marketplace
124
+
125
+ We also have a marketplace. 35 photons and counting.
126
+
127
+ <div align="center">
128
+ <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/beam-marketplace.png" alt="Marketplace" width="100%">
129
+ </div>
130
+
131
+ ```bash
132
+ photon search postgres
133
+ photon add postgres
134
+ ```
135
+
136
+ Browse the full catalog and documentation in the [official photons repository](https://github.com/portel-dev/photons).
137
+
138
+ You can also make a private marketplace for your team, so internal tools stay off the public internet.
139
+
140
+ ---
141
+
84
142
  ## How It Works
85
143
 
86
144
  A photon is just a TypeScript class. The **public methods become tools**. Photon reads your code, looks at the types, reads your comments, and then generates everything else.
87
145
 
88
- Ill show you.
146
+ I'll show you.
89
147
 
90
148
  ### Step 1: The Bare Minimum
91
149
 
@@ -107,7 +165,7 @@ export default class Weather {
107
165
  * `photon` (The web UI)
108
166
 
109
167
  <div align="center">
110
- <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/readme-step-1.png" alt="Step 1 — Bare method in Beam" width="600">
168
+ <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/readme-step-1.png" alt="Step 1 — Bare method in Beam" width="100%">
111
169
  </div>
112
170
 
113
171
  ### Step 2: Adding Descriptions
@@ -134,7 +192,7 @@ export default class Weather {
134
192
  **What happens:** Now the UI has helpful text. Also, the AI client reads this to understand what the tool does.
135
193
 
136
194
  <div align="center">
137
- <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/readme-step-2.png" alt="Step 2 — JSDoc descriptions in Beam" width="600">
195
+ <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/readme-step-2.png" alt="Step 2 — JSDoc descriptions in Beam" width="100%">
138
196
  </div>
139
197
 
140
198
  ### Step 3: Configuration (The clever bit)
@@ -160,7 +218,7 @@ export default class Weather {
160
218
  **What happens:** Beam creates a settings panel. `apiKey` becomes a password field. It also maps to environment variables like `WEATHER_API_KEY`. It just works.
161
219
 
162
220
  <div align="center">
163
- <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/readme-step-3.png" alt="Step 3 — Configuration panel in Beam" width="600">
221
+ <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/readme-step-3.png" alt="Step 3 — Configuration panel in Beam" width="100%">
164
222
  </div>
165
223
 
166
224
  ### Step 4: Validation (Stop bad inputs)
@@ -223,7 +281,7 @@ VideoProcessor requires the following CLI tools to be installed:
223
281
  > See the full [Tag Reference](./DOCBLOCK-TAGS.md) for all available tags. There are 30+ covering validation, UI hints, scheduling, webhooks, and more.
224
282
 
225
283
  <div align="center">
226
- <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/readme-step-4.png" alt="Step 4 — Validation and formatting in Beam" width="600">
284
+ <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/readme-step-4.png" alt="Step 4 — Validation and formatting in Beam" width="100%">
227
285
  </div>
228
286
 
229
287
  ### Step 5: Custom UI (When you want to be fancy)
@@ -267,7 +325,7 @@ export default class Weather {
267
325
  > Custom UIs follow the [MCP Apps Extension (SEP-1865)](https://github.com/nicolo-ribaudo/modelcontextprotocol/blob/nicolo/sep-1865/docs/specification/draft/extensions/apps.mdx) standard and work across compatible hosts. See the [Custom UI Guide](./CUSTOM-UI.md).
268
326
 
269
327
  <div align="center">
270
- <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/readme-step-5.png" alt="Step 5 — Custom UI result in Beam" width="600">
328
+ <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/readme-step-5.png" alt="Step 5 — Custom UI result in Beam" width="100%">
271
329
  </div>
272
330
 
273
331
  ### In Summary
@@ -281,129 +339,24 @@ export default class Weather {
281
339
  | **5. Custom UI** | HTML | A custom app |
282
340
 
283
341
  <div align="center">
284
- <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/photon-ecosystem.png" alt="Photon Ecosystem" width="600">
342
+ <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/photon-ecosystem.png" alt="Photon Ecosystem" width="100%">
285
343
  </div>
286
344
 
287
345
  ---
288
346
 
289
- ## Beam
290
-
291
- Beam is the dashboard. It’s where you go to poke your tools and see if they work before you let an AI loose on them.
292
-
293
- Run `photon`. That’s it.
294
-
295
- <div align="center">
296
- <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/beam-dashboard.png" alt="Beam Dashboard" width="700">
297
- </div>
298
-
299
- ---
300
-
301
- ## Connecting to AI
302
-
303
- If you want to use this with Claude or Cursor, you need the config.
304
-
305
- ```bash
306
- photon info weather --mcp
307
- ```
308
-
309
- It spits out some JSON:
310
-
311
- ```json
312
- {
313
- "mcpServers": {
314
- "weather": {
315
- "command": "photon",
316
- "args": ["mcp", "weather"]
317
- }
318
- }
319
- }
320
- ```
321
-
322
- Copy that. Paste it into your AI client’s config file. Done.
347
+ ## The Basics
323
348
 
324
- Works with [Claude Desktop](https://claude.ai/download), [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor](https://cursor.com), and any [MCP-compatible client](https://modelcontextprotocol.io).
325
-
326
- ---
327
-
328
- ## Why did we build this?
329
-
330
- Writing an MCP server usually involves 4 to 6 files and about 150 lines of code before you even start writing the thing you actually wanted to write.
331
-
332
- With Photon, it’s one file.
333
-
334
- | | Traditional MCP | Photon |
335
- |---|---|---|
336
- | **Files** | 4-6 (server, transport, schemas, types, config) | 1 |
337
- | **Boilerplate** | 150+ lines | 0 |
338
- | **Dependencies** | Manual `npm install` | Automatic |
339
- | **Schema** | Hand-written JSON Schema | Generated from TS types |
340
- | **Config** | Manual env var parsing | Automatic from Constructor |
341
-
342
- It is unnecessarily difficult to do it the old way. So we stopped doing it.
343
-
344
- ---
345
-
346
- ## Marketplace
347
-
348
- We also have a marketplace. 31 photons and counting.
349
-
350
- <div align="center">
351
- <img src="https://raw.githubusercontent.com/portel-dev/photon/main/assets/beam-marketplace.png" alt="Marketplace" width="700">
352
- </div>
353
-
354
- ```bash
355
- photon search postgres
356
- photon add postgres
357
- ```
358
-
359
- ### Available Photons
360
-
361
- **Productivity**
362
-
363
- | Photon | What it does | Tools |
364
- |--------|-------------|-------|
365
- | 📌 **kanban** | Multi-tenant task boards for humans and AI | 33 |
366
- | 📬 **git-box** | Mailbox-style Git interface, manage repos like an inbox | 58 |
367
- | 📬 **form-inbox** | Webhook-powered form submission collector | 12 |
368
- | 📅 **google-calendar** | Calendar integration via OAuth | 9 |
369
- | 🎫 **jira** | Project management and issue tracking | 10 |
370
- | 💬 **slack** | Send messages and manage Slack workspaces | 7 |
371
- | 📧 **email** | Send and receive via SMTP/IMAP | 8 |
372
-
373
- **Infrastructure**
374
-
375
- | Photon | What it does | Tools |
376
- |--------|-------------|-------|
377
- | 📁 **filesystem** | Safe, cross-platform file operations | 13 |
378
- | 🔀 **git** | Local git repository operations | 11 |
379
- | 🐙 **github-issues** | Manage GitHub issues and comments | 7 |
380
- | 🐳 **docker** | Container and image management | 10 |
381
- | ☁️ **aws-s3** | S3 object storage operations | 11 |
382
- | 🌐 **web** | DuckDuckGo search + Readability extraction | 2 |
383
-
384
- **Databases**
385
-
386
- | Photon | What it does | Tools |
387
- |--------|-------------|-------|
388
- | 🐘 **postgres** | PostgreSQL queries and schema ops | 7 |
389
- | 🗄️ **sqlite** | SQLite database operations | 9 |
390
- | 🍃 **mongodb** | MongoDB document CRUD and aggregation | 13 |
391
- | ⚡ **redis** | Key-value store, lists, sets, pub/sub | 18 |
392
-
393
- **Utilities and Demos**
394
-
395
- | Photon | What it does | Tools |
396
- |--------|-------------|-------|
397
- | 🕐 **time** | Timezone conversion and queries | 3 |
398
- | 🧮 **math** | Expression evaluator (trig, stats, etc.) | 1 |
399
- | 📊 **code-diagram** | Generate Mermaid diagrams from code | 3 |
400
- | 🔴 **connect-four** | Play against AI with distributed locks | 8 |
401
- | 🍳 **kitchen-sink** | Every runtime feature in one file | 25 |
402
- | 📋 **dashboard** | MCP Apps UI demo | 6 |
403
- | 📺 **team-dashboard** | TV/monitor-optimized team display | 20 |
404
- | 🎭 **mcp-orchestrator** | Combine multiple MCPs into workflows | 10 |
349
+ If you are just skimming, here is what you need to know:
405
350
 
406
- You can also make a private marketplace for your team, so internal tools stay off the public internet.
351
+ | Concept | What it is | Learn more |
352
+ |---------|-----------|------------|
353
+ | **MCP** | A way for AI to use your tools. It's a standard. | [modelcontextprotocol.io](https://modelcontextprotocol.io/introduction) |
354
+ | **Photon file** | A `.photon.ts` file. You define tools as methods in a class. | [Guide](./GUIDE.md) |
355
+ | **Beam** | A web dashboard. It shows your tools as forms. | [Beam UI](#beam) |
356
+ | **Marketplace** | A way to get other people's photons. | [Marketplace](#marketplace) |
357
+ | **Daemon** | A background thing that handles messages and jobs. | [Daemon Pub/Sub](./DAEMON-PUBSUB.md) |
358
+ | **Tags** | JSDoc comments that tell Photon what to do. | [Tag Reference](./DOCBLOCK-TAGS.md) |
359
+ | **Custom UI** | When the auto-generated forms aren't enough. | [Custom UI Guide](./CUSTOM-UI.md) |
407
360
 
408
361
  ---
409
362
 
@@ -516,4 +469,3 @@ If you find a bug, or if my code offends you, feel free to open an issue or a PR
516
469
  Made by [Portel](https://github.com/portel-dev)
517
470
 
518
471
  </div>
519
-
@@ -1 +1 @@
1
- {"version":3,"file":"beam.d.ts","sourceRoot":"","sources":["../../src/auto-ui/beam.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AA0xBH,wBAAsB,SAAS,CAAC,aAAa,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAs8ElF;AAkYD;;;GAGG;AACH,wBAAsB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAsB9C"}
1
+ {"version":3,"file":"beam.d.ts","sourceRoot":"","sources":["../../src/auto-ui/beam.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAixBH,wBAAsB,SAAS,CAAC,aAAa,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA0/ElF;AAiYD;;;GAGG;AACH,wBAAsB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,CAsB9C"}
@@ -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.
@@ -178,8 +179,7 @@ async function loadExternalMCPs(config) {
178
179
  try {
179
180
  const resourcesResult = await sdkClient.listResources();
180
181
  const resources = resourcesResult.resources || [];
181
- const appResources = resources.filter((r) => r.uri?.startsWith('ui://') ||
182
- r.mimeType === 'application/vnd.mcp.ui+html');
182
+ const appResources = resources.filter((r) => r.uri?.startsWith('ui://') || r.mimeType === 'application/vnd.mcp.ui+html');
183
183
  // Count only non-UI resources (UI resources are internal implementation detail)
184
184
  mcpInfo.resourceCount = resources.length - appResources.length;
185
185
  if (appResources.length > 0) {
@@ -257,8 +257,7 @@ async function loadExternalMCPs(config) {
257
257
  const resourcesResult = await sdkClient.listResources();
258
258
  const resources = resourcesResult.resources || [];
259
259
  // 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');
260
+ const appResources = resources.filter((r) => r.uri?.startsWith('ui://') || r.mimeType === 'application/vnd.mcp.ui+html');
262
261
  // Count only non-UI resources (UI resources are internal implementation detail)
263
262
  mcpInfo.resourceCount = resources.length - appResources.length;
264
263
  if (appResources.length > 0) {
@@ -358,8 +357,7 @@ async function reconnectExternalMCP(name) {
358
357
  try {
359
358
  const resourcesResult = await sdkClient.listResources();
360
359
  const resources = resourcesResult.resources || [];
361
- const appResources = resources.filter((r) => r.uri?.startsWith('ui://') ||
362
- r.mimeType === 'application/vnd.mcp.ui+html');
360
+ const appResources = resources.filter((r) => r.uri?.startsWith('ui://') || r.mimeType === 'application/vnd.mcp.ui+html');
363
361
  // Count only non-UI resources (UI resources are internal implementation detail)
364
362
  mcp.resourceCount = resources.length - appResources.length;
365
363
  if (appResources.length > 0) {
@@ -671,8 +669,8 @@ export async function startBeam(rawWorkingDir, port) {
671
669
  defaultValue: p.defaultValue,
672
670
  }));
673
671
  // 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]*?)\*\//);
672
+ const classJsdocMatch = source.match(/\/\*\*[\s\S]*?\*\/\s*(?=export\s+default\s+class)/) ||
673
+ source.match(/^\/\*\*([\s\S]*?)\*\//);
676
674
  if (classJsdocMatch) {
677
675
  const uiMatch = classJsdocMatch[0].match(/@ui\s+([^\s*]+)/);
678
676
  if (uiMatch) {
@@ -745,7 +743,9 @@ export async function startBeam(rawWorkingDir, port) {
745
743
  linkedUi: linkedAsset?.id,
746
744
  ...(schema.isStatic ? { isStatic: true } : {}),
747
745
  ...(schema.webhook ? { webhook: schema.webhook } : {}),
748
- ...(schema.scheduled || schema.cron ? { scheduled: schema.scheduled || schema.cron } : {}),
746
+ ...(schema.scheduled || schema.cron
747
+ ? { scheduled: schema.scheduled || schema.cron }
748
+ : {}),
749
749
  ...(schema.locked ? { locked: schema.locked } : {}),
750
750
  };
751
751
  });
@@ -819,7 +819,8 @@ export async function startBeam(rawWorkingDir, port) {
819
819
  promptCount,
820
820
  installSource,
821
821
  ...(constructorParams.length > 0 && { requiredParams: constructorParams }),
822
- ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
822
+ ...(mcp.injectedPhotons &&
823
+ mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
823
824
  };
824
825
  }
825
826
  catch (error) {
@@ -1014,8 +1015,12 @@ export async function startBeam(rawWorkingDir, port) {
1014
1015
  return null; // UI asset not found
1015
1016
  }
1016
1017
  };
1018
+ // Security: rate limiter for API endpoints
1019
+ const apiRateLimiter = new SimpleRateLimiter(30, 60_000);
1017
1020
  // Create HTTP server
1018
1021
  const server = http.createServer(async (req, res) => {
1022
+ // Security: set standard security headers on all responses
1023
+ setSecurityHeaders(res);
1019
1024
  const url = new URL(req.url || '/', `http://${req.headers.host}`);
1020
1025
  // ══════════════════════════════════════════════════════════════════════════
1021
1026
  // MCP Streamable HTTP Transport (standard MCP clients like Claude Desktop)
@@ -1133,17 +1138,18 @@ export async function startBeam(rawWorkingDir, port) {
1133
1138
  root = path.resolve(workdirEnv);
1134
1139
  }
1135
1140
  }
1136
- const dirPath = url.searchParams.get('path') || root || workingDir;
1141
+ // Security: default browse root to workingDir if not specified
1142
+ if (!root) {
1143
+ root = workingDir;
1144
+ }
1145
+ const dirPath = url.searchParams.get('path') || root;
1137
1146
  try {
1138
1147
  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
- }
1148
+ // Security: always enforce path boundary using isPathWithin
1149
+ if (!isPathWithin(resolved, root)) {
1150
+ res.writeHead(403);
1151
+ res.end(JSON.stringify({ error: 'Access denied: outside allowed directory' }));
1152
+ return;
1147
1153
  }
1148
1154
  const stat = await fs.stat(resolved);
1149
1155
  if (!stat.isDirectory()) {
@@ -1187,6 +1193,12 @@ export async function startBeam(rawWorkingDir, port) {
1187
1193
  return;
1188
1194
  }
1189
1195
  const resolved = path.resolve(filePath);
1196
+ // Security: prevent path traversal — file must be within working directory
1197
+ if (!isPathWithin(resolved, workingDir)) {
1198
+ res.writeHead(403);
1199
+ res.end('Access denied: outside allowed directory');
1200
+ return;
1201
+ }
1190
1202
  try {
1191
1203
  const fileStat = await fs.stat(resolved);
1192
1204
  if (!fileStat.isFile()) {
@@ -1367,9 +1379,19 @@ export async function startBeam(rawWorkingDir, port) {
1367
1379
  }
1368
1380
  // Resolve template path relative to photon's directory
1369
1381
  const photonDir = path.dirname(photon.path);
1370
- const fullTemplatePath = path.isAbsolute(templateFile)
1371
- ? templateFile
1372
- : path.join(photonDir, templateFile);
1382
+ // Security: reject absolute template paths — must be relative to photon dir
1383
+ if (path.isAbsolute(templateFile)) {
1384
+ res.writeHead(403);
1385
+ res.end(JSON.stringify({ error: 'Absolute template paths are not allowed' }));
1386
+ return;
1387
+ }
1388
+ const fullTemplatePath = path.join(photonDir, templateFile);
1389
+ // Security: validate resolved path is within photon directory
1390
+ if (!isPathWithin(fullTemplatePath, photonDir)) {
1391
+ res.writeHead(403);
1392
+ res.end(JSON.stringify({ error: 'Template path traversal detected' }));
1393
+ return;
1394
+ }
1373
1395
  try {
1374
1396
  const templateContent = await fs.readFile(fullTemplatePath, 'utf-8');
1375
1397
  res.setHeader('Content-Type', 'text/html');
@@ -1608,38 +1630,49 @@ export async function startBeam(rawWorkingDir, port) {
1608
1630
  }
1609
1631
  // Invoke API: Direct HTTP endpoint for method invocation (used by PWA)
1610
1632
  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 }));
1633
+ // Security: only allow local requests
1634
+ if (!isLocalRequest(req)) {
1635
+ res.writeHead(403);
1636
+ res.end(JSON.stringify({ error: 'Forbidden: non-local request' }));
1637
+ return;
1638
+ }
1639
+ // Security: rate limiting
1640
+ const clientKey = req.socket?.remoteAddress || 'unknown';
1641
+ if (!apiRateLimiter.isAllowed(clientKey)) {
1642
+ res.writeHead(429);
1643
+ res.end(JSON.stringify({ error: 'Too many requests' }));
1644
+ return;
1645
+ }
1646
+ try {
1647
+ const body = await readBody(req);
1648
+ const { photon: photonName, method, args } = JSON.parse(body);
1649
+ if (!photonName || !method) {
1650
+ res.writeHead(400);
1651
+ res.end(JSON.stringify({ error: 'Missing photon or method' }));
1652
+ return;
1636
1653
  }
1637
- catch (err) {
1638
- res.setHeader('Content-Type', 'application/json');
1639
- res.writeHead(500);
1640
- res.end(JSON.stringify({ error: err.message || String(err) }));
1654
+ const mcp = photonMCPs.get(photonName);
1655
+ if (!mcp || !mcp.instance) {
1656
+ res.writeHead(404);
1657
+ res.end(JSON.stringify({ error: `Photon not found: ${photonName}` }));
1658
+ return;
1641
1659
  }
1642
- });
1660
+ if (typeof mcp.instance[method] !== 'function') {
1661
+ res.writeHead(404);
1662
+ res.end(JSON.stringify({ error: `Method not found: ${method}` }));
1663
+ return;
1664
+ }
1665
+ const result = await mcp.instance[method](args || {});
1666
+ res.setHeader('Content-Type', 'application/json');
1667
+ res.writeHead(200);
1668
+ res.end(JSON.stringify({ result }));
1669
+ }
1670
+ catch (err) {
1671
+ const status = err.message?.includes('too large') ? 413 : 500;
1672
+ res.setHeader('Content-Type', 'application/json');
1673
+ res.writeHead(status);
1674
+ res.end(JSON.stringify({ error: err.message || String(err) }));
1675
+ }
1643
1676
  return;
1644
1677
  }
1645
1678
  // Platform Bridge API: Generate platform compatibility script
@@ -2354,7 +2387,8 @@ export async function startBeam(rawWorkingDir, port) {
2354
2387
  icon: reloadClassMeta.icon,
2355
2388
  internal: reloadClassMeta.internal,
2356
2389
  ...(reloadConstructorParams.length > 0 && { requiredParams: reloadConstructorParams }),
2357
- ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2390
+ ...(mcp.injectedPhotons &&
2391
+ mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2358
2392
  };
2359
2393
  if (isNewPhoton) {
2360
2394
  photons.push(reloadedPhoton);
@@ -2499,7 +2533,9 @@ export async function startBeam(rawWorkingDir, port) {
2499
2533
  process.exit(1);
2500
2534
  }
2501
2535
  });
2502
- server.listen(currentPort, '0.0.0.0', () => {
2536
+ // Security: bind to localhost by default, configurable via BEAM_BIND_ADDRESS
2537
+ const bindAddress = process.env.BEAM_BIND_ADDRESS || '127.0.0.1';
2538
+ server.listen(currentPort, bindAddress, () => {
2503
2539
  process.env.BEAM_PORT = String(currentPort);
2504
2540
  const url = `http://localhost:${currentPort}`;
2505
2541
  console.log(`\n⚡ Photon Beam → ${url} (loading photons...)\n`);
@@ -2529,9 +2565,7 @@ export async function startBeam(rawWorkingDir, port) {
2529
2565
  const photonStatus = unconfiguredCount > 0
2530
2566
  ? `${configuredCount} ready, ${unconfiguredCount} need setup`
2531
2567
  : `${configuredCount} photon${configuredCount !== 1 ? 's' : ''} ready`;
2532
- const mcpStatus = externalMCPList.length > 0
2533
- ? `, ${connectedMCPs}/${externalMCPList.length} MCPs`
2534
- : '';
2568
+ const mcpStatus = externalMCPList.length > 0 ? `, ${connectedMCPs}/${externalMCPList.length} MCPs` : '';
2535
2569
  console.log(`⚡ Photon Beam ready (${photonStatus}${mcpStatus})`);
2536
2570
  // Notify connected clients that photon list is now available
2537
2571
  broadcastPhotonChange();
@@ -2673,7 +2707,9 @@ export async function startBeam(rawWorkingDir, port) {
2673
2707
  externalMCPSDKClients.delete(name);
2674
2708
  }
2675
2709
  }
2676
- catch { /* ignore */ }
2710
+ catch {
2711
+ /* ignore */
2712
+ }
2677
2713
  externalMCPClients.delete(name);
2678
2714
  logger.info(`🔌 Removed external MCP: ${name}`);
2679
2715
  }
@@ -2701,7 +2737,9 @@ export async function startBeam(rawWorkingDir, port) {
2701
2737
  externalMCPSDKClients.delete(name);
2702
2738
  }
2703
2739
  }
2704
- catch { /* ignore */ }
2740
+ catch {
2741
+ /* ignore */
2742
+ }
2705
2743
  externalMCPClients.delete(name);
2706
2744
  externalMCPs.splice(idx, 1);
2707
2745
  }
@@ -2812,7 +2850,8 @@ async function configurePhotonViaMCP(photonName, config, photons, photonMCPs, lo
2812
2850
  isApp,
2813
2851
  appEntry: mainMethod,
2814
2852
  assets: mcp.assets,
2815
- ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2853
+ ...(mcp.injectedPhotons &&
2854
+ mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2816
2855
  };
2817
2856
  photons[photonIndex] = configuredPhoton;
2818
2857
  logger.info(`✅ ${photonName} configured via MCP`);
@@ -2908,7 +2947,8 @@ async function reloadPhotonViaMCP(photonName, photons, photonMCPs, loader, saved
2908
2947
  description: reloadClassMeta.description,
2909
2948
  icon: reloadClassMeta.icon,
2910
2949
  internal: reloadClassMeta.internal,
2911
- ...(mcp.injectedPhotons && mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2950
+ ...(mcp.injectedPhotons &&
2951
+ mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
2912
2952
  };
2913
2953
  photons[photonIndex] = reloadedPhoton;
2914
2954
  logger.info(`🔄 ${photonName} reloaded via MCP`);
@@ -3000,10 +3040,7 @@ async function generatePhotonHelpMarkdown(photonName, photons) {
3000
3040
  const mdPath = path.join(sourceDir, `${photonName}.md`);
3001
3041
  // Check if .md file already exists and is newer than the photon source
3002
3042
  try {
3003
- const [mdStat, srcStat] = await Promise.all([
3004
- fs.stat(mdPath),
3005
- fs.stat(photon.path),
3006
- ]);
3043
+ const [mdStat, srcStat] = await Promise.all([fs.stat(mdPath), fs.stat(photon.path)]);
3007
3044
  if (mdStat.mtimeMs >= srcStat.mtimeMs) {
3008
3045
  const existing = await fs.readFile(mdPath, 'utf-8');
3009
3046
  if (existing.trim()) {