@portel/photon-core 2.14.0 → 2.16.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.
@@ -368,7 +368,7 @@ function wrapStatefulMethods(
368
368
 
369
369
  // Skip framework-injected methods from withPhotonCapabilities
370
370
  const frameworkMethods = new Set([
371
- 'emit', 'call', 'mcp', 'setMCPFactory', 'onInitialize', 'onShutdown',
371
+ 'emit', 'render', 'call', 'mcp', 'setMCPFactory', 'onInitialize', 'onShutdown',
372
372
  ]);
373
373
 
374
374
  // Walk the prototype chain to find all public methods
@@ -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
@@ -1329,6 +1350,7 @@ export class SchemaExtractor {
1329
1350
  .replace(/\{@pattern\s+[^}]+\}/g, '')
1330
1351
  .replace(/\{@format\s+[^}]+\}/g, '')
1331
1352
  .replace(/\{@choice\s+[^}]+\}/g, '')
1353
+ .replace(/\{@choice-from\s+[^}]+\}/g, '')
1332
1354
  .replace(/\{@field\s+[^}]+\}/g, '')
1333
1355
  .replace(/\{@default\s+[^}]+\}/g, '')
1334
1356
  .replace(/\{@unique(?:Items)?\s*\}/g, '')
@@ -1404,6 +1426,12 @@ export class SchemaExtractor {
1404
1426
  paramConstraints.enum = choices;
1405
1427
  }
1406
1428
 
1429
+ // Extract {@choice-from toolName} or {@choice-from toolName.field}
1430
+ const choiceFromMatch = description.match(/\{@choice-from\s+([^}]+)\}/);
1431
+ if (choiceFromMatch) {
1432
+ paramConstraints.choiceFrom = choiceFromMatch[1].trim();
1433
+ }
1434
+
1407
1435
  // Extract {@field type} - hints for UI form rendering
1408
1436
  const fieldMatch = description.match(/\{@field\s+([a-z]+)\}/);
1409
1437
  if (fieldMatch) {
@@ -1526,7 +1554,7 @@ export class SchemaExtractor {
1526
1554
  }
1527
1555
 
1528
1556
  // Validate no unknown {@...} tags (typos in constraint names)
1529
- const allKnownTags = ['min', 'max', 'pattern', 'format', 'choice', 'field', 'default', 'unique', 'uniqueItems',
1557
+ const allKnownTags = ['min', 'max', 'pattern', 'format', 'choice', 'choice-from', 'field', 'default', 'unique', 'uniqueItems',
1530
1558
  'example', 'multipleOf', 'deprecated', 'readOnly', 'writeOnly', 'label', 'placeholder',
1531
1559
  'hint', 'hidden', 'accept', 'minItems', 'maxItems'];
1532
1560
  const unknownTagRegex = /\{@([\w-]+)\s*(?:\s+[^}]*)?\}/g;
@@ -1715,6 +1743,10 @@ export class SchemaExtractor {
1715
1743
  s.enum = constraints.enum;
1716
1744
  }
1717
1745
  }
1746
+ // Apply dynamic choice provider (x-choiceFrom extension)
1747
+ if (constraints.choiceFrom !== undefined) {
1748
+ s['x-choiceFrom'] = constraints.choiceFrom;
1749
+ }
1718
1750
  // Apply field hint for UI rendering
1719
1751
  if (constraints.field !== undefined) {
1720
1752
  s.field = constraints.field;
@@ -2652,7 +2684,7 @@ export class SchemaExtractor {
2652
2684
  };
2653
2685
  }
2654
2686
 
2655
- // Check if matches an @photon declaration
2687
+ // Check if matches an @photon declaration (exact match)
2656
2688
  if (photonMap.has(param.name)) {
2657
2689
  return {
2658
2690
  param,
@@ -2661,6 +2693,25 @@ export class SchemaExtractor {
2661
2693
  };
2662
2694
  }
2663
2695
 
2696
+ // Instance-aware DI: if paramName ends with a photon dep name (case-insensitive),
2697
+ // the prefix becomes the instance name.
2698
+ // e.g., personalWhatsapp + @photon whatsapp → instance "personal" of whatsapp
2699
+ // workWhatsapp + @photon whatsapp → instance "work" of whatsapp
2700
+ for (const [depName, dep] of photonMap) {
2701
+ const lowerParam = param.name.toLowerCase();
2702
+ const lowerDep = depName.toLowerCase();
2703
+ if (lowerParam.endsWith(lowerDep) && lowerParam.length > lowerDep.length) {
2704
+ const prefix = param.name.slice(0, param.name.length - depName.length);
2705
+ // Ensure the prefix is a valid instance name (lowercase the first char)
2706
+ const instanceName = prefix.charAt(0).toLowerCase() + prefix.slice(1);
2707
+ return {
2708
+ param,
2709
+ injectionType: 'photon' as const,
2710
+ photonDependency: { ...dep, instanceName: instanceName || undefined },
2711
+ };
2712
+ }
2713
+ }
2714
+
2664
2715
  // Non-primitive with default on @stateful class → persisted state
2665
2716
  if (isStateful && param.hasDefault) {
2666
2717
  return {
@@ -2826,7 +2877,15 @@ export class SchemaExtractor {
2826
2877
  // Link UI asset to this method
2827
2878
  const asset = uiAssets.find(a => a.id === uiId);
2828
2879
  if (asset) {
2829
- asset.linkedTool = methodName;
2880
+ // First method wins as primary (used for app detection)
2881
+ if (!asset.linkedTool) {
2882
+ asset.linkedTool = methodName;
2883
+ }
2884
+ // Track all methods that reference this UI
2885
+ if (!asset.linkedTools) asset.linkedTools = [];
2886
+ if (!asset.linkedTools.includes(methodName)) {
2887
+ asset.linkedTools.push(methodName);
2888
+ }
2830
2889
  }
2831
2890
  }
2832
2891
  }
@@ -2882,7 +2941,7 @@ export class SchemaExtractor {
2882
2941
  /**
2883
2942
  * Capability types that can be auto-detected from source code
2884
2943
  */
2885
- export type PhotonCapability = 'emit' | 'memory' | 'call' | 'mcp' | 'lock' | 'instanceMeta' | 'allInstances';
2944
+ export type PhotonCapability = 'emit' | 'memory' | 'call' | 'mcp' | 'lock' | 'instanceMeta' | 'allInstances' | 'caller';
2886
2945
 
2887
2946
  /**
2888
2947
  * Detect capabilities used by a Photon from its source code.
@@ -2896,11 +2955,13 @@ export type PhotonCapability = 'emit' | 'memory' | 'call' | 'mcp' | 'lock' | 'in
2896
2955
  export function detectCapabilities(source: string): Set<PhotonCapability> {
2897
2956
  const caps = new Set<PhotonCapability>();
2898
2957
  if (/this\.emit\s*\(/.test(source)) caps.add('emit');
2958
+ if (/this\.render\s*\(/.test(source)) caps.add('emit'); // render() needs emit injection
2899
2959
  if (/this\.memory\b/.test(source)) caps.add('memory');
2900
2960
  if (/this\.call\s*\(/.test(source)) caps.add('call');
2901
2961
  if (/this\.mcp\s*\(/.test(source)) caps.add('mcp');
2902
2962
  if (/this\.withLock\s*\(/.test(source)) caps.add('lock');
2903
2963
  if (/this\.instanceMeta\b/.test(source)) caps.add('instanceMeta');
2904
2964
  if (/this\.allInstances\s*\(/.test(source)) caps.add('allInstances');
2965
+ if (/this\.caller\b/.test(source)) caps.add('caller');
2905
2966
  return caps;
2906
2967
  }
package/src/types.ts CHANGED
@@ -432,8 +432,10 @@ export interface UIAsset {
432
432
  resolvedPath?: string;
433
433
  /** MIME type (detected from extension) */
434
434
  mimeType?: string;
435
- /** Tool this UI is linked to (from method @ui annotation) */
435
+ /** Primary tool this UI is linked to (first method with @ui annotation — used for app detection) */
436
436
  linkedTool?: string;
437
+ /** All tools that reference this UI asset (multiple methods can share one template) */
438
+ linkedTools?: string[];
437
439
  /** MCP resource URI (set by loader, e.g., 'ui://photon-name/main-ui') */
438
440
  uri?: string;
439
441
  }