@portel/photon-core 2.15.0 → 2.17.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portel/photon-core",
3
- "version": "2.15.0",
3
+ "version": "2.17.0",
4
4
  "description": "Core library for parsing, loading, and managing .photon.ts files - runtime-agnostic foundation for building custom Photon runtimes",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -110,12 +110,32 @@ export async function autoDiscoverAssets(
110
110
  assets: PhotonAssets,
111
111
  ): Promise<void> {
112
112
  // Auto-discover UI files
113
+ // .photon.html files (declarative mode) take priority over .html files with the same base name.
113
114
  const uiDir = path.join(assetFolder, 'ui');
114
115
  if (await fileExists(uiDir)) {
115
116
  try {
116
117
  const files = await fs.readdir(uiDir);
118
+ // Collect .photon.html files first so they take priority
119
+ const photonHtmlFiles = new Set<string>();
117
120
  for (const file of files) {
118
- const id = path.basename(file, path.extname(file));
121
+ if (file.endsWith('.photon.html')) {
122
+ photonHtmlFiles.add(file.replace(/\.photon\.html$/, ''));
123
+ }
124
+ }
125
+ for (const file of files) {
126
+ // Determine the asset ID:
127
+ // - foo.photon.html → id "foo"
128
+ // - foo.html → id "foo" (but skipped if foo.photon.html exists)
129
+ let id: string;
130
+ if (file.endsWith('.photon.html')) {
131
+ id = file.replace(/\.photon\.html$/, '');
132
+ } else {
133
+ id = path.basename(file, path.extname(file));
134
+ // Skip .html files when a .photon.html with the same base name exists
135
+ if (file.endsWith('.html') && photonHtmlFiles.has(id)) {
136
+ continue;
137
+ }
138
+ }
119
139
  if (!assets.ui.find((u) => u.id === id)) {
120
140
  assets.ui.push({
121
141
  id,
package/src/base.ts CHANGED
@@ -40,7 +40,7 @@
40
40
  */
41
41
 
42
42
  import { MCPClient, MCPClientFactory, createMCPProxy } from '@portel/mcp';
43
- import { executionContext } from '@portel/cli';
43
+ import { executionContext, type CallerInfo } from '@portel/cli';
44
44
  import { getBroker } from './channels/index.js';
45
45
  import { withLock as withLockHelper } from './decorators.js';
46
46
  import { MemoryProvider } from './memory.js';
@@ -95,6 +95,28 @@ export class Photon {
95
95
  */
96
96
  _sessionId?: string;
97
97
 
98
+ /**
99
+ * Authenticated caller identity
100
+ *
101
+ * Populated from MCP OAuth when `@auth` is enabled on the photon.
102
+ * Returns the identity of whoever is calling the current method —
103
+ * human (via social login) or agent (via API key).
104
+ *
105
+ * Returns an anonymous caller if no auth token was provided.
106
+ *
107
+ * @example
108
+ * ```typescript
109
+ * // In a method:
110
+ * const userId = this.caller.id; // stable user ID from JWT
111
+ * const name = this.caller.name; // display name
112
+ * const isAnon = this.caller.anonymous; // true if no auth
113
+ * ```
114
+ */
115
+ get caller(): CallerInfo {
116
+ const store = executionContext.getStore();
117
+ return store?.caller ?? { id: 'anonymous', anonymous: true };
118
+ }
119
+
98
120
  /**
99
121
  * Scoped key-value storage for photon data
100
122
  *
@@ -236,6 +258,33 @@ export class Photon {
236
258
  return path.join(dir, name, 'assets', subpath);
237
259
  }
238
260
 
261
+ /**
262
+ * Get a URL path for an asset served by Beam
263
+ *
264
+ * Returns a relative URL like `/api/assets/my-photon/images/logo.png`
265
+ * that Beam serves from the photon's assets directory. Use this in
266
+ * HTML, markdown, or slides where browser-accessible URLs are needed.
267
+ *
268
+ * @param subpath Path within the assets folder
269
+ * @returns URL path (relative to Beam host)
270
+ *
271
+ * @example
272
+ * ```typescript
273
+ * const logoUrl = this.assetUrl('images/logo.png');
274
+ * // → '/api/assets/my-photon/images/logo.png'
275
+ *
276
+ * return `![Logo](${logoUrl})`; // works in markdown/slides
277
+ * ```
278
+ */
279
+ protected assetUrl(subpath: string): string {
280
+ const name = this._photonName || this.constructor.name
281
+ .replace(/MCP$/, '')
282
+ .replace(/([A-Z])/g, '-$1')
283
+ .toLowerCase()
284
+ .replace(/^-/, '');
285
+ return `/api/assets/${encodeURIComponent(name)}/${subpath}`;
286
+ }
287
+
239
288
  /**
240
289
  * Dynamic photon access
241
290
  *
@@ -457,16 +506,13 @@ export class Photon {
457
506
  ]);
458
507
 
459
508
  // Get all property names from prototype chain
509
+ // Use getOwnPropertyDescriptor to avoid triggering getters (which may call storage())
460
510
  let current = prototype;
461
511
  while (current && current !== Photon.prototype) {
462
512
  Object.getOwnPropertyNames(current).forEach((name) => {
463
- // Skip private methods (starting with _) and convention methods
464
- if (
465
- !name.startsWith('_') &&
466
- !conventionMethods.has(name) &&
467
- typeof (prototype as any)[name] === 'function' &&
468
- !methods.includes(name)
469
- ) {
513
+ if (name.startsWith('_') || conventionMethods.has(name) || methods.includes(name)) return;
514
+ const desc = Object.getOwnPropertyDescriptor(current, name);
515
+ if (desc && typeof desc.value === 'function') {
470
516
  methods.push(name);
471
517
  }
472
518
  });
@@ -618,4 +664,106 @@ export class Photon {
618
664
  ): Promise<T> {
619
665
  return withLockHelper(lockName, fn, timeout);
620
666
  }
667
+
668
+ // ═══════════════════════════════════════════════════════════════════════════
669
+ // IDENTITY-AWARE LOCK MANAGEMENT
670
+ // ═══════════════════════════════════════════════════════════════════════════
671
+
672
+ /**
673
+ * Identity-aware lock handler - injected by runtime
674
+ * @internal
675
+ */
676
+ _lockHandler?: {
677
+ assign(lockName: string, holder: string, timeout?: number): Promise<boolean>;
678
+ transfer(lockName: string, fromHolder: string, toHolder: string, timeout?: number): Promise<boolean>;
679
+ release(lockName: string, holder: string): Promise<boolean>;
680
+ query(lockName: string): Promise<{ holder: string | null; acquiredAt?: number; expiresAt?: number }>;
681
+ };
682
+
683
+ /**
684
+ * Assign a lock to a specific caller (identity-aware)
685
+ *
686
+ * Unlike `withLock` which auto-acquires/releases around a function,
687
+ * this explicitly assigns a lock to a caller ID. The lock persists
688
+ * until transferred or released.
689
+ *
690
+ * @param lockName Name of the lock
691
+ * @param callerId Caller ID to assign the lock to
692
+ * @param timeout Lock timeout in ms (default 30000, auto-extended on transfer)
693
+ *
694
+ * @example
695
+ * ```typescript
696
+ * // Assign "turn" lock to first player
697
+ * await this.acquireLock('turn', this.caller.id);
698
+ * ```
699
+ */
700
+ protected async acquireLock(lockName: string, callerId: string, timeout?: number): Promise<boolean> {
701
+ if (!this._lockHandler) {
702
+ console.warn(`[photon] acquireLock('${lockName}'): no lock handler configured`);
703
+ return true;
704
+ }
705
+ return this._lockHandler.assign(lockName, callerId, timeout);
706
+ }
707
+
708
+ /**
709
+ * Transfer a lock from the current holder to another caller
710
+ *
711
+ * Only succeeds if `fromCallerId` is the current holder.
712
+ *
713
+ * @param lockName Name of the lock
714
+ * @param toCallerId Caller ID to transfer the lock to
715
+ * @param fromCallerId Current holder (defaults to this.caller.id)
716
+ *
717
+ * @example
718
+ * ```typescript
719
+ * // After a chess move, transfer turn to opponent
720
+ * await this.transferLock('turn', opponentId);
721
+ * ```
722
+ */
723
+ protected async transferLock(lockName: string, toCallerId: string, fromCallerId?: string): Promise<boolean> {
724
+ if (!this._lockHandler) {
725
+ console.warn(`[photon] transferLock('${lockName}'): no lock handler configured`);
726
+ return true;
727
+ }
728
+ return this._lockHandler.transfer(lockName, fromCallerId ?? this.caller.id, toCallerId);
729
+ }
730
+
731
+ /**
732
+ * Release a lock (make the method open to anyone)
733
+ *
734
+ * @param lockName Name of the lock
735
+ * @param callerId Holder to release from (defaults to this.caller.id)
736
+ *
737
+ * @example
738
+ * ```typescript
739
+ * // Presenter releases navigation control to audience
740
+ * await this.releaseLock('navigation');
741
+ * ```
742
+ */
743
+ protected async releaseLock(lockName: string, callerId?: string): Promise<boolean> {
744
+ if (!this._lockHandler) {
745
+ console.warn(`[photon] releaseLock('${lockName}'): no lock handler configured`);
746
+ return true;
747
+ }
748
+ return this._lockHandler.release(lockName, callerId ?? this.caller.id);
749
+ }
750
+
751
+ /**
752
+ * Query who holds a specific lock
753
+ *
754
+ * @param lockName Name of the lock
755
+ * @returns Lock holder info, or null holder if unlocked
756
+ *
757
+ * @example
758
+ * ```typescript
759
+ * const lock = await this.getLock('turn');
760
+ * if (lock.holder === this.caller.id) { ... }
761
+ * ```
762
+ */
763
+ protected async getLock(lockName: string): Promise<{ holder: string | null; acquiredAt?: number; expiresAt?: number }> {
764
+ if (!this._lockHandler) {
765
+ return { holder: null };
766
+ }
767
+ return this._lockHandler.query(lockName);
768
+ }
621
769
  }
package/src/index.ts CHANGED
@@ -74,6 +74,7 @@ export {
74
74
  executionContext,
75
75
  runWithContext,
76
76
  getContext,
77
+ type CallerInfo,
77
78
 
78
79
  // Text Utils
79
80
  TextUtils,
package/src/mixins.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { MCPClient, MCPClientFactory, createMCPProxy } from '@portel/mcp';
26
- import { executionContext } from '@portel/cli';
26
+ import { executionContext, type CallerInfo } from '@portel/cli';
27
27
  import { getBroker } from './channels/index.js';
28
28
  import { MemoryProvider } from './memory.js';
29
29
  import { ScheduleProvider } from './schedule.js';
@@ -94,6 +94,14 @@ export function withPhotonCapabilities<T extends Constructor>(Base: T): T {
94
94
  */
95
95
  private _mcpClients: Map<string, MCPClient & Record<string, (params?: any) => Promise<any>>> = new Map();
96
96
 
97
+ /**
98
+ * Authenticated caller identity from MCP OAuth
99
+ */
100
+ get caller(): CallerInfo {
101
+ const store = executionContext.getStore();
102
+ return store?.caller ?? { id: 'anonymous', anonymous: true };
103
+ }
104
+
97
105
  /**
98
106
  * Scoped key-value storage for photon data
99
107
  */
@@ -24,6 +24,13 @@ export interface ExtractedMetadata {
24
24
  configSchema?: ConfigSchema;
25
25
  /** Notification subscription from @notify-on tag */
26
26
  notificationSubscriptions?: NotificationSubscription;
27
+ /**
28
+ * MCP OAuth auth requirement (from @auth tag)
29
+ * - 'required': all methods require authenticated caller
30
+ * - 'optional': caller populated if token present, anonymous allowed
31
+ * - string URL: OIDC provider URL (implies required)
32
+ */
33
+ auth?: 'required' | 'optional' | string;
27
34
  }
28
35
 
29
36
  /**
@@ -64,6 +71,9 @@ export class SchemaExtractor {
64
71
  // Notification subscriptions tracking (from @notify-on tag)
65
72
  let notificationSubscriptions: NotificationSubscription | undefined;
66
73
 
74
+ // MCP OAuth auth requirement (from @auth tag)
75
+ let auth: 'required' | 'optional' | string | undefined;
76
+
67
77
  try {
68
78
  // If source doesn't contain a class declaration, wrap it in one
69
79
  let sourceToParse = source;
@@ -448,6 +458,12 @@ export class SchemaExtractor {
448
458
  const classJsdoc = this.getJSDocComment(node as any, sourceFile);
449
459
  const isStatefulClass = /@stateful\b/i.test(classJsdoc);
450
460
 
461
+ // Extract @auth tag for MCP OAuth requirement
462
+ const authMatch = classJsdoc.match(/@auth(?:\s+(\S+))?/i);
463
+ if (authMatch) {
464
+ auth = authMatch[1]?.trim() || 'required';
465
+ }
466
+
451
467
  // Extract notification subscriptions from @notify-on tag
452
468
  notificationSubscriptions = this.extractNotifyOn(classJsdoc);
453
469
 
@@ -495,6 +511,11 @@ export class SchemaExtractor {
495
511
  result.notificationSubscriptions = notificationSubscriptions;
496
512
  }
497
513
 
514
+ // Include auth requirement if detected
515
+ if (auth) {
516
+ result.auth = auth;
517
+ }
518
+
498
519
  return result;
499
520
  }
500
521
 
@@ -1220,7 +1241,7 @@ export class SchemaExtractor {
1220
1241
  private extractDescription(jsdocContent: string): string {
1221
1242
  // Split by @tags that appear at start of a JSDoc line (after optional * prefix)
1222
1243
  // This avoids matching @tag references inline in description text
1223
- const beforeTags = jsdocContent.split(/(?:^|\n)\s*\*?\s*@(?:param|example|returns?|throws?|see|since|deprecated|version|author|license|ui|icon|format|stateful|autorun|async|webhook|cron|scheduled|locked|fallback|logged|circuitBreaker|cached|timeout|retryable|throttled|debounced|queued|validate|use|Template|Static|mcp|photon|cli|tags|dependencies|csp|visibility)\b/)[0];
1244
+ const beforeTags = jsdocContent.split(/(?:^|\n)\s*\*?\s*@(?:param|example|returns?|throws?|see|since|deprecated|version|author|license|ui|icon|format|stateful|autorun|async|webhook|cron|scheduled|locked|fallback|logged|circuitBreaker|cached|timeout|retryable|throttled|debounced|queued|validate|use|Template|Static|mcp|photon|cli|tags|dependencies|csp|visibility|auth)\b/)[0];
1224
1245
 
1225
1246
  // Remove leading * from each line and trim
1226
1247
  const lines = beforeTags
@@ -1387,11 +1408,23 @@ export class SchemaExtractor {
1387
1408
  if (formatMatch) {
1388
1409
  const format = formatMatch[1].trim();
1389
1410
  // Validate format is in whitelist (JSON Schema + custom formats)
1390
- const validFormats = ['email', 'date', 'date-time', 'time', 'duration', 'uri', 'uri-reference', 'uuid', 'ipv4', 'ipv6', 'hostname', 'json', 'table'];
1391
- if (!validFormats.includes(format)) {
1411
+ const validFormats = [
1412
+ // JSON Schema standard formats
1413
+ 'email', 'date', 'date-time', 'time', 'duration', 'uri', 'uri-reference', 'uuid', 'ipv4', 'ipv6', 'hostname',
1414
+ // Photon display formats
1415
+ 'json', 'table', 'date-range', 'datetime-range',
1416
+ // Enhanced input formats
1417
+ 'password', 'secret', 'url', 'phone', 'tel', 'search', 'color', 'textarea', 'slider',
1418
+ 'tags', 'rating', 'radio', 'segmented', 'code', 'markdown',
1419
+ // File formats
1420
+ 'path', 'file', 'directory',
1421
+ ];
1422
+ // Allow subtypes like code:typescript, code:python
1423
+ const formatBase = format.split(':')[0];
1424
+ if (!validFormats.includes(format) && !validFormats.includes(formatBase)) {
1392
1425
  console.warn(
1393
1426
  `Invalid @format value: "${format}". ` +
1394
- `Valid formats: ${validFormats.join(', ')}. Format not applied.`
1427
+ `Valid formats: ${validFormats.join(', ')} (with optional :subtype). Format not applied.`
1395
1428
  );
1396
1429
  } else {
1397
1430
  paramConstraints.format = format;
@@ -1619,7 +1652,13 @@ export class SchemaExtractor {
1619
1652
  `Pattern is ignored when specific values are defined via enum or @choice.`
1620
1653
  );
1621
1654
  }
1622
- // Skip enum types for most constraints (but still apply deprecated, examples, etc.)
1655
+ // Skip enum types for most constraints (but still apply format, deprecated, examples, default)
1656
+ if (constraints.format !== undefined) {
1657
+ s.format = constraints.format;
1658
+ }
1659
+ if (constraints.default !== undefined) {
1660
+ s.default = constraints.default;
1661
+ }
1623
1662
  if (constraints.examples !== undefined) {
1624
1663
  s.examples = constraints.examples;
1625
1664
  }
@@ -2285,7 +2324,7 @@ export class SchemaExtractor {
2285
2324
  }
2286
2325
 
2287
2326
  // Match content formats
2288
- if (['json', 'markdown', 'yaml', 'xml', 'html', 'mermaid'].includes(format)) {
2327
+ if (['json', 'markdown', 'yaml', 'xml', 'html', 'mermaid', 'embed'].includes(format)) {
2289
2328
  return format as OutputFormat;
2290
2329
  }
2291
2330
 
@@ -2300,7 +2339,14 @@ export class SchemaExtractor {
2300
2339
  }
2301
2340
 
2302
2341
  // Match visualization formats
2303
- if (['metric', 'gauge', 'timeline', 'dashboard', 'cart', 'qr'].includes(format)) {
2342
+ if (['metric', 'gauge', 'progress', 'badge', 'timeline', 'dashboard', 'cart', 'qr', 'slides',
2343
+ 'steps', 'stepper', 'log', 'image', 'hero', 'banner', 'quote', 'profile', 'heatmap',
2344
+ 'kanban', 'calendar', 'map', 'cron', 'comparison', 'invoice', 'receipt', 'network', 'graph'].includes(format)) {
2345
+ return format as OutputFormat;
2346
+ }
2347
+
2348
+ // Match layout/design formats
2349
+ if (['stat-group', 'statgroup', 'feature-grid', 'features', 'carousel', 'gallery', 'masonry'].includes(format)) {
2304
2350
  return format as OutputFormat;
2305
2351
  }
2306
2352
 
@@ -2920,7 +2966,7 @@ export class SchemaExtractor {
2920
2966
  /**
2921
2967
  * Capability types that can be auto-detected from source code
2922
2968
  */
2923
- export type PhotonCapability = 'emit' | 'memory' | 'call' | 'mcp' | 'lock' | 'instanceMeta' | 'allInstances';
2969
+ export type PhotonCapability = 'emit' | 'memory' | 'call' | 'mcp' | 'lock' | 'instanceMeta' | 'allInstances' | 'caller';
2924
2970
 
2925
2971
  /**
2926
2972
  * Detect capabilities used by a Photon from its source code.
@@ -2941,5 +2987,6 @@ export function detectCapabilities(source: string): Set<PhotonCapability> {
2941
2987
  if (/this\.withLock\s*\(/.test(source)) caps.add('lock');
2942
2988
  if (/this\.instanceMeta\b/.test(source)) caps.add('instanceMeta');
2943
2989
  if (/this\.allInstances\s*\(/.test(source)) caps.add('allInstances');
2990
+ if (/this\.caller\b/.test(source)) caps.add('caller');
2944
2991
  return caps;
2945
2992
  }
package/src/types.ts CHANGED
@@ -7,11 +7,12 @@
7
7
  * - Structural: primitive, table, tree, list, none, card, grid, chips, kv
8
8
  * - Content: json, markdown, yaml, xml, html, mermaid, code, code:<lang>
9
9
  * - Visualization: chart, chart:<type>, metric, gauge, timeline, dashboard, cart
10
+ * - Content: json, markdown, yaml, xml, html, mermaid, code, code:<lang>, slides
10
11
  * - Container: panels, tabs, accordion, stack, columns
11
12
  */
12
13
  export type OutputFormat =
13
14
  | 'primitive' | 'table' | 'tree' | 'list' | 'none'
14
- | 'json' | 'markdown' | 'yaml' | 'xml' | 'html' | 'mermaid'
15
+ | 'json' | 'markdown' | 'yaml' | 'xml' | 'html' | 'mermaid' | 'slides'
15
16
  | 'card' | 'grid' | 'chips' | 'kv' | 'qr'
16
17
  | 'chart' | `chart:${string}` | 'metric' | 'gauge' | 'timeline' | 'dashboard' | 'cart'
17
18
  | 'panels' | 'tabs' | 'accordion' | 'stack' | 'columns'