capdag 0.88.20458
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/README.md +131 -0
- package/RULES.md +111 -0
- package/capdag.js +4322 -0
- package/capdag.test.js +2874 -0
- package/package.json +36 -0
package/capdag.js
ADDED
|
@@ -0,0 +1,4322 @@
|
|
|
1
|
+
// Cap URN JavaScript Implementation
|
|
2
|
+
// Follows the exact same rules as Rust, Go, and Objective-C implementations
|
|
3
|
+
|
|
4
|
+
// Import TaggedUrn from the tagged-urn package
|
|
5
|
+
const { TaggedUrn } = require('tagged-urn');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Error types for Cap URN operations
|
|
9
|
+
*/
|
|
10
|
+
class CapUrnError extends Error {
|
|
11
|
+
constructor(code, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'CapUrnError';
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Error codes
|
|
19
|
+
const ErrorCodes = {
|
|
20
|
+
INVALID_FORMAT: 1,
|
|
21
|
+
EMPTY_TAG: 2,
|
|
22
|
+
INVALID_CHARACTER: 3,
|
|
23
|
+
INVALID_TAG_FORMAT: 4,
|
|
24
|
+
MISSING_CAP_PREFIX: 5,
|
|
25
|
+
DUPLICATE_KEY: 6,
|
|
26
|
+
NUMERIC_KEY: 7,
|
|
27
|
+
UNTERMINATED_QUOTE: 8,
|
|
28
|
+
INVALID_ESCAPE_SEQUENCE: 9,
|
|
29
|
+
MISSING_IN_SPEC: 10,
|
|
30
|
+
MISSING_OUT_SPEC: 11
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Note: All parsing is delegated to TaggedUrn from tagged-urn-js
|
|
34
|
+
// No duplicate state machine or parsing helpers needed here
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Cap URN implementation with required direction (in/out) and optional tags
|
|
38
|
+
*
|
|
39
|
+
* Direction is now a REQUIRED first-class field:
|
|
40
|
+
* - inSpec: The input media URN (required, must start with "media:")
|
|
41
|
+
* - outSpec: The output media URN (required, must start with "media:")
|
|
42
|
+
* - tags: Other optional tags (no longer contains in/out)
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Check if a value is a valid media URN or wildcard
|
|
46
|
+
* @param {string} value - The value to check
|
|
47
|
+
* @returns {boolean} True if valid media URN or wildcard
|
|
48
|
+
*/
|
|
49
|
+
function isValidMediaUrnOrWildcard(value) {
|
|
50
|
+
return value === '*' || (value && value.startsWith('media:'));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class CapUrn {
|
|
54
|
+
/**
|
|
55
|
+
* Create a new CapUrn with required direction specs
|
|
56
|
+
* @param {string} inSpec - Required input media URN (e.g., "media:void") or wildcard "*"
|
|
57
|
+
* @param {string} outSpec - Required output media URN (e.g., "media:object") or wildcard "*"
|
|
58
|
+
* @param {Object} tags - Other tags (must NOT contain 'in' or 'out')
|
|
59
|
+
*/
|
|
60
|
+
constructor(inSpec, outSpec, tags = {}) {
|
|
61
|
+
// Validate in/out are media URNs or wildcards
|
|
62
|
+
if (!isValidMediaUrnOrWildcard(inSpec)) {
|
|
63
|
+
throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'in' media URN: ${inSpec}. Must start with 'media:' or be '*'`);
|
|
64
|
+
}
|
|
65
|
+
if (!isValidMediaUrnOrWildcard(outSpec)) {
|
|
66
|
+
throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'out' media URN: ${outSpec}. Must start with 'media:' or be '*'`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.inSpec = inSpec;
|
|
70
|
+
this.outSpec = outSpec;
|
|
71
|
+
this.tags = {};
|
|
72
|
+
// Copy tags, filtering out any 'in' or 'out' that might have slipped through
|
|
73
|
+
for (const [key, value] of Object.entries(tags)) {
|
|
74
|
+
const keyLower = key.toLowerCase();
|
|
75
|
+
if (keyLower !== 'in' && keyLower !== 'out') {
|
|
76
|
+
this.tags[keyLower] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get the input media URN
|
|
83
|
+
* @returns {string} The input media URN
|
|
84
|
+
*/
|
|
85
|
+
getInSpec() {
|
|
86
|
+
return this.inSpec;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get the output media URN
|
|
91
|
+
* @returns {string} The output media URN
|
|
92
|
+
*/
|
|
93
|
+
getOutSpec() {
|
|
94
|
+
return this.outSpec;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create a Cap URN from string representation
|
|
99
|
+
* Format: cap:in="<media-urn>";out="<media-urn>";key1=value1;key2=value2;...
|
|
100
|
+
*
|
|
101
|
+
* IMPORTANT: 'in' and 'out' tags are REQUIRED and must be valid media URNs.
|
|
102
|
+
*
|
|
103
|
+
* Uses TaggedUrn for parsing to ensure consistent behavior across implementations.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} s - The Cap URN string
|
|
106
|
+
* @returns {CapUrn} The parsed Cap URN
|
|
107
|
+
* @throws {CapUrnError} If parsing fails or in/out are missing/invalid
|
|
108
|
+
*/
|
|
109
|
+
static fromString(s) {
|
|
110
|
+
if (!s || typeof s !== 'string') {
|
|
111
|
+
throw new CapUrnError(ErrorCodes.INVALID_FORMAT, 'Cap URN cannot be empty');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Check for 'cap:' prefix early to give better error messages
|
|
115
|
+
if (!s.startsWith('cap:')) {
|
|
116
|
+
throw new CapUrnError(ErrorCodes.MISSING_CAP_PREFIX, "Cap URN must start with 'cap:' prefix");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Use TaggedUrn for parsing
|
|
120
|
+
let taggedUrn;
|
|
121
|
+
try {
|
|
122
|
+
taggedUrn = TaggedUrn.fromString(s);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// Convert TaggedUrnError to CapUrnError with appropriate error code
|
|
125
|
+
const msg = e.message || '';
|
|
126
|
+
const msgLower = msg.toLowerCase();
|
|
127
|
+
if (msgLower.includes('invalid character')) {
|
|
128
|
+
throw new CapUrnError(ErrorCodes.INVALID_CHARACTER, msg);
|
|
129
|
+
}
|
|
130
|
+
if (msgLower.includes('duplicate')) {
|
|
131
|
+
throw new CapUrnError(ErrorCodes.DUPLICATE_KEY, msg);
|
|
132
|
+
}
|
|
133
|
+
if (msgLower.includes('unterminated') || msgLower.includes('unclosed')) {
|
|
134
|
+
throw new CapUrnError(ErrorCodes.UNTERMINATED_QUOTE, msg);
|
|
135
|
+
}
|
|
136
|
+
if (msgLower.includes('numeric') || msgLower.includes('purely numeric')) {
|
|
137
|
+
throw new CapUrnError(ErrorCodes.NUMERIC_KEY, msg);
|
|
138
|
+
}
|
|
139
|
+
throw new CapUrnError(ErrorCodes.INVALID_FORMAT, msg);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Double-check prefix (should always be 'cap' after the early check above)
|
|
143
|
+
if (taggedUrn.getPrefix() !== 'cap') {
|
|
144
|
+
throw new CapUrnError(ErrorCodes.MISSING_CAP_PREFIX, `Expected 'cap:' prefix, got '${taggedUrn.getPrefix()}:'`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Extract required 'in' and 'out' tags
|
|
148
|
+
const inSpec = taggedUrn.getTag('in');
|
|
149
|
+
const outSpec = taggedUrn.getTag('out');
|
|
150
|
+
|
|
151
|
+
if (!inSpec) {
|
|
152
|
+
throw new CapUrnError(ErrorCodes.MISSING_IN_SPEC, "Cap URN requires 'in' tag for input media URN");
|
|
153
|
+
}
|
|
154
|
+
if (!outSpec) {
|
|
155
|
+
throw new CapUrnError(ErrorCodes.MISSING_OUT_SPEC, "Cap URN requires 'out' tag for output media URN");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Validate in/out are media URNs or wildcards
|
|
159
|
+
if (!isValidMediaUrnOrWildcard(inSpec)) {
|
|
160
|
+
throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'in' media URN: ${inSpec}. Must start with 'media:' or be '*'`);
|
|
161
|
+
}
|
|
162
|
+
if (!isValidMediaUrnOrWildcard(outSpec)) {
|
|
163
|
+
throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'out' media URN: ${outSpec}. Must start with 'media:' or be '*'`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Build remaining tags (excluding in/out)
|
|
167
|
+
const remainingTags = {};
|
|
168
|
+
for (const [key, value] of Object.entries(taggedUrn.tags)) {
|
|
169
|
+
if (key !== 'in' && key !== 'out') {
|
|
170
|
+
remainingTags[key] = value;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return new CapUrn(inSpec, outSpec, remainingTags);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Create a Cap URN from a tags object
|
|
179
|
+
* Extracts 'in' and 'out' from tags (required), stores rest as regular tags
|
|
180
|
+
*
|
|
181
|
+
* @param {Object} tags - Object containing all tags including 'in' and 'out'
|
|
182
|
+
* @returns {CapUrn} The parsed Cap URN
|
|
183
|
+
* @throws {CapUrnError} If 'in' or 'out' tags are missing or invalid
|
|
184
|
+
*/
|
|
185
|
+
static fromTags(tags) {
|
|
186
|
+
const inSpec = tags['in'] || tags['IN'];
|
|
187
|
+
const outSpec = tags['out'] || tags['OUT'];
|
|
188
|
+
|
|
189
|
+
if (!inSpec) {
|
|
190
|
+
throw new CapUrnError(ErrorCodes.MISSING_IN_SPEC, "Cap URN requires 'in' tag for input media URN");
|
|
191
|
+
}
|
|
192
|
+
if (!outSpec) {
|
|
193
|
+
throw new CapUrnError(ErrorCodes.MISSING_OUT_SPEC, "Cap URN requires 'out' tag for output media URN");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Validate in/out are media URNs or wildcards
|
|
197
|
+
if (!isValidMediaUrnOrWildcard(inSpec)) {
|
|
198
|
+
throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'in' media URN: ${inSpec}. Must start with 'media:' or be '*'`);
|
|
199
|
+
}
|
|
200
|
+
if (!isValidMediaUrnOrWildcard(outSpec)) {
|
|
201
|
+
throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'out' media URN: ${outSpec}. Must start with 'media:' or be '*'`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Build remaining tags (excluding in/out)
|
|
205
|
+
const remainingTags = {};
|
|
206
|
+
for (const [key, value] of Object.entries(tags)) {
|
|
207
|
+
const keyLower = key.toLowerCase();
|
|
208
|
+
if (keyLower !== 'in' && keyLower !== 'out') {
|
|
209
|
+
remainingTags[keyLower] = value;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return new CapUrn(inSpec, outSpec, remainingTags);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get the canonical string representation of this cap URN
|
|
218
|
+
* Always includes "cap:" prefix
|
|
219
|
+
* Tags are sorted alphabetically for consistent representation (in/out included)
|
|
220
|
+
* Uses TaggedUrn for serialization to ensure consistent quoting
|
|
221
|
+
*
|
|
222
|
+
* @returns {string} The canonical string representation
|
|
223
|
+
*/
|
|
224
|
+
toString() {
|
|
225
|
+
// Build complete tags map including in and out
|
|
226
|
+
const allTags = { ...this.tags, 'in': this.inSpec, 'out': this.outSpec };
|
|
227
|
+
|
|
228
|
+
// Use TaggedUrn for canonical serialization
|
|
229
|
+
const taggedUrn = new TaggedUrn('cap', allTags, true);
|
|
230
|
+
return taggedUrn.toString();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get the value of a specific tag
|
|
235
|
+
* Key is normalized to lowercase for lookup
|
|
236
|
+
* Returns inSpec for "in" key, outSpec for "out" key
|
|
237
|
+
*
|
|
238
|
+
* @param {string} key - The tag key
|
|
239
|
+
* @returns {string|undefined} The tag value or undefined if not found
|
|
240
|
+
*/
|
|
241
|
+
getTag(key) {
|
|
242
|
+
const keyLower = key.toLowerCase();
|
|
243
|
+
if (keyLower === 'in') {
|
|
244
|
+
return this.inSpec;
|
|
245
|
+
}
|
|
246
|
+
if (keyLower === 'out') {
|
|
247
|
+
return this.outSpec;
|
|
248
|
+
}
|
|
249
|
+
return this.tags[keyLower];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Check if this cap has a specific tag with a specific value
|
|
254
|
+
* Key is normalized to lowercase; value comparison is case-sensitive
|
|
255
|
+
* Checks inSpec for "in" key, outSpec for "out" key
|
|
256
|
+
*
|
|
257
|
+
* @param {string} key - The tag key
|
|
258
|
+
* @param {string} value - The tag value to check
|
|
259
|
+
* @returns {boolean} Whether the tag exists with the specified value
|
|
260
|
+
*/
|
|
261
|
+
hasTag(key, value) {
|
|
262
|
+
const keyLower = key.toLowerCase();
|
|
263
|
+
if (keyLower === 'in') {
|
|
264
|
+
return this.inSpec === value;
|
|
265
|
+
}
|
|
266
|
+
if (keyLower === 'out') {
|
|
267
|
+
return this.outSpec === value;
|
|
268
|
+
}
|
|
269
|
+
const tagValue = this.tags[keyLower];
|
|
270
|
+
return tagValue !== undefined && tagValue === value;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Create a new cap URN with an added or updated tag
|
|
275
|
+
* Key is normalized to lowercase; value is preserved as-is
|
|
276
|
+
* SILENTLY IGNORES attempts to set "in" or "out" - use withInSpec/withOutSpec instead
|
|
277
|
+
*
|
|
278
|
+
* @param {string} key - The tag key
|
|
279
|
+
* @param {string} value - The tag value
|
|
280
|
+
* @returns {CapUrn} A new CapUrn instance with the tag added/updated
|
|
281
|
+
*/
|
|
282
|
+
withTag(key, value) {
|
|
283
|
+
const keyLower = key.toLowerCase();
|
|
284
|
+
// Silently ignore attempts to set in/out via withTag
|
|
285
|
+
if (keyLower === 'in' || keyLower === 'out') {
|
|
286
|
+
return this;
|
|
287
|
+
}
|
|
288
|
+
const newTags = { ...this.tags };
|
|
289
|
+
newTags[keyLower] = value;
|
|
290
|
+
return new CapUrn(this.inSpec, this.outSpec, newTags);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Create a new cap URN with a different input spec
|
|
295
|
+
*
|
|
296
|
+
* @param {string} inSpec - The new input spec ID
|
|
297
|
+
* @returns {CapUrn} A new CapUrn instance with the updated inSpec
|
|
298
|
+
*/
|
|
299
|
+
withInSpec(inSpec) {
|
|
300
|
+
return new CapUrn(inSpec, this.outSpec, this.tags);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Create a new cap URN with a different output spec
|
|
305
|
+
*
|
|
306
|
+
* @param {string} outSpec - The new output spec ID
|
|
307
|
+
* @returns {CapUrn} A new CapUrn instance with the updated outSpec
|
|
308
|
+
*/
|
|
309
|
+
withOutSpec(outSpec) {
|
|
310
|
+
return new CapUrn(this.inSpec, outSpec, this.tags);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Create a new cap URN with a tag removed
|
|
315
|
+
* Key is normalized to lowercase for case-insensitive removal
|
|
316
|
+
* SILENTLY IGNORES attempts to remove "in" or "out" - they are required
|
|
317
|
+
*
|
|
318
|
+
* @param {string} key - The tag key to remove
|
|
319
|
+
* @returns {CapUrn} A new CapUrn instance with the tag removed
|
|
320
|
+
*/
|
|
321
|
+
withoutTag(key) {
|
|
322
|
+
const keyLower = key.toLowerCase();
|
|
323
|
+
// Silently ignore attempts to remove in/out - they are required
|
|
324
|
+
if (keyLower === 'in' || keyLower === 'out') {
|
|
325
|
+
return this;
|
|
326
|
+
}
|
|
327
|
+
const newTags = { ...this.tags };
|
|
328
|
+
delete newTags[keyLower];
|
|
329
|
+
return new CapUrn(this.inSpec, this.outSpec, newTags);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Check if this cap (pattern/handler) accepts a request (instance).
|
|
334
|
+
*
|
|
335
|
+
* Direction (in/out) uses TaggedUrn.accepts()/conformsTo() (via MediaUrn matching):
|
|
336
|
+
* - Input: capIn.accepts(requestIn) — cap's input spec is pattern, request's input is instance
|
|
337
|
+
* - Output: capOut.conformsTo(requestOut) — cap's output is instance, request's output is pattern
|
|
338
|
+
* For other tags:
|
|
339
|
+
* - For each tag in the request: cap has same value, wildcard (*), or missing tag
|
|
340
|
+
* - For each tag in the cap: if request is missing that tag, that's fine (cap is more specific)
|
|
341
|
+
* Missing tags (except in/out) are treated as wildcards (less specific, can handle any value).
|
|
342
|
+
*
|
|
343
|
+
* @param {CapUrn} request - The request cap to check
|
|
344
|
+
* @returns {boolean} Whether this cap accepts the request
|
|
345
|
+
*/
|
|
346
|
+
accepts(request) {
|
|
347
|
+
if (!request) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Direction specs: TaggedUrn semantic matching via MediaUrn
|
|
352
|
+
// Check in_urn: cap's input spec (pattern) accepts request's input (instance).
|
|
353
|
+
// "media:" on the PATTERN side (this.inSpec) means "I accept any input" — skip check.
|
|
354
|
+
// "*" is also treated as wildcard. "media:" on the instance side still participates.
|
|
355
|
+
if (this.inSpec !== '*' && this.inSpec !== 'media:' && request.inSpec !== '*') {
|
|
356
|
+
const capIn = TaggedUrn.fromString(this.inSpec);
|
|
357
|
+
const requestIn = TaggedUrn.fromString(request.inSpec);
|
|
358
|
+
if (!capIn.accepts(requestIn)) {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Check out_urn: cap's output (instance) conforms to request's output (pattern).
|
|
364
|
+
// "media:" on the PATTERN side (this.outSpec) means "I accept any output" — skip check.
|
|
365
|
+
// "*" is also treated as wildcard. "media:" on the instance side still participates.
|
|
366
|
+
if (this.outSpec !== '*' && this.outSpec !== 'media:' && request.outSpec !== '*') {
|
|
367
|
+
const capOut = TaggedUrn.fromString(this.outSpec);
|
|
368
|
+
const requestOut = TaggedUrn.fromString(request.outSpec);
|
|
369
|
+
if (!capOut.conformsTo(requestOut)) {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Check all other tags that the request specifies
|
|
375
|
+
for (const [requestKey, requestValue] of Object.entries(request.tags)) {
|
|
376
|
+
const capValue = this.tags[requestKey];
|
|
377
|
+
|
|
378
|
+
if (capValue === undefined) {
|
|
379
|
+
// Missing tag in cap is treated as wildcard - can handle any value
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (capValue === '*') {
|
|
384
|
+
// Cap has wildcard - can handle any value
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (requestValue === '*') {
|
|
389
|
+
// Request accepts any value - cap's specific value matches
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (capValue !== requestValue) {
|
|
394
|
+
// Cap has specific value that doesn't match request's specific value
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// If cap has additional specific tags that request doesn't specify, that's fine
|
|
400
|
+
// The cap is just more specific than needed
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Check if this cap (instance) conforms to another cap (pattern).
|
|
406
|
+
* Equivalent to cap.accepts(this).
|
|
407
|
+
*
|
|
408
|
+
* @param {CapUrn} cap - The cap to check conformance against
|
|
409
|
+
* @returns {boolean} Whether this cap conforms to the given cap
|
|
410
|
+
*/
|
|
411
|
+
conformsTo(cap) {
|
|
412
|
+
return cap.accepts(this);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Calculate specificity score for cap matching
|
|
417
|
+
*
|
|
418
|
+
* More specific caps have higher scores and are preferred.
|
|
419
|
+
* Direction specs contribute their MediaUrn tag count (more tags = more specific).
|
|
420
|
+
* Other tags contribute 1 per non-wildcard value.
|
|
421
|
+
*
|
|
422
|
+
* @returns {number} The specificity score
|
|
423
|
+
*/
|
|
424
|
+
specificity() {
|
|
425
|
+
let count = 0;
|
|
426
|
+
// Direction specs contribute their MediaUrn tag count
|
|
427
|
+
if (this.inSpec !== '*') {
|
|
428
|
+
const inMedia = TaggedUrn.fromString(this.inSpec);
|
|
429
|
+
count += Object.keys(inMedia.tags).length;
|
|
430
|
+
}
|
|
431
|
+
if (this.outSpec !== '*') {
|
|
432
|
+
const outMedia = TaggedUrn.fromString(this.outSpec);
|
|
433
|
+
count += Object.keys(outMedia.tags).length;
|
|
434
|
+
}
|
|
435
|
+
// Count non-wildcard tags
|
|
436
|
+
count += Object.values(this.tags).filter(value => value !== '*').length;
|
|
437
|
+
return count;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Check if this cap is more specific than another
|
|
442
|
+
*
|
|
443
|
+
* @param {CapUrn} other - The other cap to compare with
|
|
444
|
+
* @returns {boolean} Whether this cap is more specific
|
|
445
|
+
*/
|
|
446
|
+
isMoreSpecificThan(other) {
|
|
447
|
+
if (!other) {
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return this.specificity() > other.specificity();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Create a new cap with a specific tag set to wildcard
|
|
456
|
+
* Handles "in" and "out" specially
|
|
457
|
+
*
|
|
458
|
+
* @param {string} key - The tag key to set to wildcard
|
|
459
|
+
* @returns {CapUrn} A new CapUrn instance with the tag set to wildcard
|
|
460
|
+
*/
|
|
461
|
+
withWildcardTag(key) {
|
|
462
|
+
const keyLower = key.toLowerCase();
|
|
463
|
+
if (keyLower === 'in') {
|
|
464
|
+
return this.withInSpec('*');
|
|
465
|
+
}
|
|
466
|
+
if (keyLower === 'out') {
|
|
467
|
+
return this.withOutSpec('*');
|
|
468
|
+
}
|
|
469
|
+
if (this.tags.hasOwnProperty(keyLower)) {
|
|
470
|
+
return this.withTag(key, '*');
|
|
471
|
+
}
|
|
472
|
+
return this;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Create a new cap with only specified tags
|
|
477
|
+
* Always preserves inSpec and outSpec (they are required)
|
|
478
|
+
*
|
|
479
|
+
* @param {string[]} keys - Array of tag keys to include
|
|
480
|
+
* @returns {CapUrn} A new CapUrn instance with only the specified tags (plus in/out)
|
|
481
|
+
*/
|
|
482
|
+
subset(keys) {
|
|
483
|
+
const newTags = {};
|
|
484
|
+
for (const key of keys) {
|
|
485
|
+
const normalizedKey = key.toLowerCase();
|
|
486
|
+
// Skip in/out - they are always preserved via constructor
|
|
487
|
+
if (normalizedKey !== 'in' && normalizedKey !== 'out') {
|
|
488
|
+
if (this.tags.hasOwnProperty(normalizedKey)) {
|
|
489
|
+
newTags[normalizedKey] = this.tags[normalizedKey];
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return new CapUrn(this.inSpec, this.outSpec, newTags);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Merge with another cap (other takes precedence for conflicts)
|
|
498
|
+
* Direction specs (in/out) are taken from other
|
|
499
|
+
*
|
|
500
|
+
* @param {CapUrn} other - The cap to merge with
|
|
501
|
+
* @returns {CapUrn} A new CapUrn instance with merged tags
|
|
502
|
+
*/
|
|
503
|
+
merge(other) {
|
|
504
|
+
if (!other) {
|
|
505
|
+
return new CapUrn(this.inSpec, this.outSpec, this.tags);
|
|
506
|
+
}
|
|
507
|
+
const newTags = { ...this.tags, ...other.tags };
|
|
508
|
+
return new CapUrn(other.inSpec, other.outSpec, newTags);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Check if this cap URN is equal to another
|
|
513
|
+
* Compares direction specs (in/out) and tags
|
|
514
|
+
*
|
|
515
|
+
* @param {CapUrn} other - The other cap URN to compare with
|
|
516
|
+
* @returns {boolean} Whether the cap URNs are equal
|
|
517
|
+
*/
|
|
518
|
+
equals(other) {
|
|
519
|
+
if (!other || !(other instanceof CapUrn)) {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Compare direction specs
|
|
524
|
+
if (this.inSpec !== other.inSpec || this.outSpec !== other.outSpec) {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Compare tags
|
|
529
|
+
const thisKeys = Object.keys(this.tags).sort();
|
|
530
|
+
const otherKeys = Object.keys(other.tags).sort();
|
|
531
|
+
|
|
532
|
+
if (thisKeys.length !== otherKeys.length) {
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
for (let i = 0; i < thisKeys.length; i++) {
|
|
537
|
+
if (thisKeys[i] !== otherKeys[i]) {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
if (this.tags[thisKeys[i]] !== other.tags[otherKeys[i]]) {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Get a hash string for this cap URN
|
|
550
|
+
* Two equivalent cap URNs will have the same hash
|
|
551
|
+
*
|
|
552
|
+
* @returns {string} A hash of the canonical string representation
|
|
553
|
+
*/
|
|
554
|
+
hash() {
|
|
555
|
+
// Simple hash function for the canonical string
|
|
556
|
+
const canonical = this.toString();
|
|
557
|
+
let hash = 0;
|
|
558
|
+
for (let i = 0; i < canonical.length; i++) {
|
|
559
|
+
const char = canonical.charCodeAt(i);
|
|
560
|
+
hash = ((hash << 5) - hash) + char;
|
|
561
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
562
|
+
}
|
|
563
|
+
return hash.toString(16);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Cap URN Builder for fluent construction
|
|
569
|
+
*/
|
|
570
|
+
class CapUrnBuilder {
|
|
571
|
+
constructor() {
|
|
572
|
+
this._inSpec = null;
|
|
573
|
+
this._outSpec = null;
|
|
574
|
+
this._tags = {};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Set the input spec ID
|
|
579
|
+
*
|
|
580
|
+
* @param {string} spec - The input spec ID
|
|
581
|
+
* @returns {CapUrnBuilder} This builder instance for chaining
|
|
582
|
+
*/
|
|
583
|
+
inSpec(spec) {
|
|
584
|
+
this._inSpec = spec;
|
|
585
|
+
return this;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Set the output spec ID
|
|
590
|
+
*
|
|
591
|
+
* @param {string} spec - The output spec ID
|
|
592
|
+
* @returns {CapUrnBuilder} This builder instance for chaining
|
|
593
|
+
*/
|
|
594
|
+
outSpec(spec) {
|
|
595
|
+
this._outSpec = spec;
|
|
596
|
+
return this;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Add or update a tag
|
|
601
|
+
* Key is normalized to lowercase; value is preserved as-is
|
|
602
|
+
* SILENTLY IGNORES attempts to set "in" or "out" - use inSpec/outSpec methods
|
|
603
|
+
*
|
|
604
|
+
* @param {string} key - The tag key
|
|
605
|
+
* @param {string} value - The tag value
|
|
606
|
+
* @returns {CapUrnBuilder} This builder instance for chaining
|
|
607
|
+
*/
|
|
608
|
+
tag(key, value) {
|
|
609
|
+
const keyLower = key.toLowerCase();
|
|
610
|
+
// Silently ignore in/out - use inSpec/outSpec methods
|
|
611
|
+
if (keyLower !== 'in' && keyLower !== 'out') {
|
|
612
|
+
this._tags[keyLower] = value;
|
|
613
|
+
}
|
|
614
|
+
return this;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Build the final CapUrn
|
|
619
|
+
*
|
|
620
|
+
* @returns {CapUrn} A new CapUrn instance
|
|
621
|
+
* @throws {CapUrnError} If inSpec or outSpec are not set
|
|
622
|
+
*/
|
|
623
|
+
build() {
|
|
624
|
+
if (!this._inSpec) {
|
|
625
|
+
throw new CapUrnError(ErrorCodes.MISSING_IN_SPEC, "Cap URN requires 'in' spec - call inSpec() before build()");
|
|
626
|
+
}
|
|
627
|
+
if (!this._outSpec) {
|
|
628
|
+
throw new CapUrnError(ErrorCodes.MISSING_OUT_SPEC, "Cap URN requires 'out' spec - call outSpec() before build()");
|
|
629
|
+
}
|
|
630
|
+
return new CapUrn(this._inSpec, this._outSpec, this._tags);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Cap Matcher utility class
|
|
636
|
+
*/
|
|
637
|
+
class CapMatcher {
|
|
638
|
+
/**
|
|
639
|
+
* Find the most specific cap that accepts a request
|
|
640
|
+
*
|
|
641
|
+
* @param {CapUrn[]} caps - Array of available caps
|
|
642
|
+
* @param {CapUrn} request - The request to match
|
|
643
|
+
* @returns {CapUrn|null} The best matching cap or null if no match
|
|
644
|
+
*/
|
|
645
|
+
static findBestMatch(caps, request) {
|
|
646
|
+
let best = null;
|
|
647
|
+
let bestSpecificity = -1;
|
|
648
|
+
|
|
649
|
+
for (const cap of caps) {
|
|
650
|
+
if (cap.accepts(request)) {
|
|
651
|
+
const specificity = cap.specificity();
|
|
652
|
+
if (specificity > bestSpecificity) {
|
|
653
|
+
best = cap;
|
|
654
|
+
bestSpecificity = specificity;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
return best;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Find all caps that accept a request, sorted by specificity
|
|
664
|
+
*
|
|
665
|
+
* @param {CapUrn[]} caps - Array of available caps
|
|
666
|
+
* @param {CapUrn} request - The request to match
|
|
667
|
+
* @returns {CapUrn[]} Array of matching caps sorted by specificity (most specific first)
|
|
668
|
+
*/
|
|
669
|
+
static findAllMatches(caps, request) {
|
|
670
|
+
const matches = caps.filter(cap => cap.accepts(request));
|
|
671
|
+
|
|
672
|
+
// Sort by specificity (most specific first)
|
|
673
|
+
matches.sort((a, b) => b.specificity() - a.specificity());
|
|
674
|
+
|
|
675
|
+
return matches;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Check if two cap sets are compatible
|
|
680
|
+
*
|
|
681
|
+
* @param {CapUrn[]} caps1 - First set of caps
|
|
682
|
+
* @param {CapUrn[]} caps2 - Second set of caps
|
|
683
|
+
* @returns {boolean} Whether any caps from the two sets are compatible
|
|
684
|
+
*/
|
|
685
|
+
static areCompatible(caps1, caps2) {
|
|
686
|
+
for (const c1 of caps1) {
|
|
687
|
+
for (const c2 of caps2) {
|
|
688
|
+
if (c1.accepts(c2) || c2.accepts(c1)) {
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ============================================================================
|
|
698
|
+
// MEDIA SPEC PARSING
|
|
699
|
+
// ============================================================================
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* MediaSpec error types
|
|
703
|
+
*/
|
|
704
|
+
class MediaSpecError extends Error {
|
|
705
|
+
constructor(code, message) {
|
|
706
|
+
super(message);
|
|
707
|
+
this.name = 'MediaSpecError';
|
|
708
|
+
this.code = code;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const MediaSpecErrorCodes = {
|
|
713
|
+
UNRESOLVABLE_MEDIA_URN: 1,
|
|
714
|
+
DUPLICATE_MEDIA_URN: 2
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
// ============================================================================
|
|
718
|
+
// BUILT-IN SPEC IDS AND DEFINITIONS
|
|
719
|
+
// ============================================================================
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Well-known built-in media URN constants
|
|
723
|
+
* These media URNs are implicitly available and do not need to be declared in mediaSpecs
|
|
724
|
+
*
|
|
725
|
+
* Cardinality and Structure use orthogonal marker tags:
|
|
726
|
+
* - `list` marker: presence = list/array, absence = scalar (default)
|
|
727
|
+
* - `record` marker: presence = has internal fields, absence = opaque (default)
|
|
728
|
+
*
|
|
729
|
+
* Examples:
|
|
730
|
+
* - `media:pdf` → scalar, opaque (no markers)
|
|
731
|
+
* - `media:textable;list` → list, opaque (has list marker)
|
|
732
|
+
* - `media:json;textable;record` → scalar, record (has record marker)
|
|
733
|
+
* - `media:json;list;record;textable` → list of records (has both markers)
|
|
734
|
+
*/
|
|
735
|
+
|
|
736
|
+
// Primitive types - URNs must match base.toml definitions
|
|
737
|
+
// Media URN for void (no input/output) - no coercion tags
|
|
738
|
+
const MEDIA_VOID = 'media:void';
|
|
739
|
+
// Media URN for string type - textable (can become text), scalar by default (no list marker)
|
|
740
|
+
const MEDIA_STRING = 'media:textable';
|
|
741
|
+
// Media URN for integer type - textable, numeric (math ops valid), scalar by default
|
|
742
|
+
const MEDIA_INTEGER = 'media:integer;textable;numeric';
|
|
743
|
+
// Media URN for number type - textable, numeric, scalar by default
|
|
744
|
+
const MEDIA_NUMBER = 'media:textable;numeric';
|
|
745
|
+
// Media URN for boolean type - uses "bool" not "boolean" per base.toml
|
|
746
|
+
const MEDIA_BOOLEAN = 'media:bool;textable';
|
|
747
|
+
// Media URN for a generic record/object type - has internal key-value structure but NOT textable
|
|
748
|
+
// Use MEDIA_JSON for textable JSON objects.
|
|
749
|
+
const MEDIA_OBJECT = 'media:record';
|
|
750
|
+
// Media URN for binary data - the most general media type (no constraints)
|
|
751
|
+
const MEDIA_BINARY = 'media:';
|
|
752
|
+
|
|
753
|
+
// Array types - URNs must match base.toml definitions
|
|
754
|
+
// Media URN for string array type - textable with list marker
|
|
755
|
+
const MEDIA_STRING_ARRAY = 'media:list;textable';
|
|
756
|
+
// Media URN for integer array type - textable, numeric with list marker
|
|
757
|
+
const MEDIA_INTEGER_ARRAY = 'media:integer;list;textable;numeric';
|
|
758
|
+
// Media URN for number array type - textable, numeric with list marker
|
|
759
|
+
const MEDIA_NUMBER_ARRAY = 'media:list;textable;numeric';
|
|
760
|
+
// Media URN for boolean array type - uses "bool" with list marker
|
|
761
|
+
const MEDIA_BOOLEAN_ARRAY = 'media:bool;list;textable';
|
|
762
|
+
// Media URN for object array type - list of records (NOT textable)
|
|
763
|
+
// Use a specific format like JSON array for textable object arrays.
|
|
764
|
+
const MEDIA_OBJECT_ARRAY = 'media:list;record';
|
|
765
|
+
|
|
766
|
+
// Semantic media types for specialized content
|
|
767
|
+
// Media URN for PNG image data
|
|
768
|
+
const MEDIA_PNG = 'media:image;png';
|
|
769
|
+
// Media URN for audio data (wav, mp3, flac, etc.)
|
|
770
|
+
const MEDIA_AUDIO = 'media:wav;audio';
|
|
771
|
+
// Media URN for video data (mp4, webm, mov, etc.)
|
|
772
|
+
const MEDIA_VIDEO = 'media:video';
|
|
773
|
+
|
|
774
|
+
// Semantic AI input types - distinguished by their purpose/context
|
|
775
|
+
// Media URN for audio input containing speech for transcription (Whisper)
|
|
776
|
+
const MEDIA_AUDIO_SPEECH = 'media:audio;wav;speech';
|
|
777
|
+
// Media URN for thumbnail image output
|
|
778
|
+
const MEDIA_IMAGE_THUMBNAIL = 'media:image;png;thumbnail';
|
|
779
|
+
|
|
780
|
+
// Document types (PRIMARY naming - type IS the format)
|
|
781
|
+
// Media URN for PDF documents
|
|
782
|
+
const MEDIA_PDF = 'media:pdf';
|
|
783
|
+
// Media URN for EPUB documents
|
|
784
|
+
const MEDIA_EPUB = 'media:epub';
|
|
785
|
+
|
|
786
|
+
// Text format types (PRIMARY naming - type IS the format)
|
|
787
|
+
// Media URN for Markdown text
|
|
788
|
+
const MEDIA_MD = 'media:md;textable';
|
|
789
|
+
// Media URN for plain text
|
|
790
|
+
const MEDIA_TXT = 'media:txt;textable';
|
|
791
|
+
// Media URN for reStructuredText
|
|
792
|
+
const MEDIA_RST = 'media:rst;textable';
|
|
793
|
+
// Media URN for log files
|
|
794
|
+
const MEDIA_LOG = 'media:log;textable';
|
|
795
|
+
// Media URN for HTML documents
|
|
796
|
+
const MEDIA_HTML = 'media:html;textable';
|
|
797
|
+
// Media URN for XML documents
|
|
798
|
+
const MEDIA_XML = 'media:xml;textable';
|
|
799
|
+
// Media URN for JSON data - has record marker (structured key-value)
|
|
800
|
+
const MEDIA_JSON = 'media:json;record;textable';
|
|
801
|
+
// Media URN for JSON with schema constraint (input for structured queries)
|
|
802
|
+
const MEDIA_JSON_SCHEMA = 'media:json;json-schema;record;textable';
|
|
803
|
+
// Media URN for YAML data - has record marker (structured key-value)
|
|
804
|
+
const MEDIA_YAML = 'media:record;textable;yaml';
|
|
805
|
+
|
|
806
|
+
// File path types - for arguments that represent filesystem paths
|
|
807
|
+
// Media URN for a single file path - textable, scalar by default (no list marker)
|
|
808
|
+
const MEDIA_FILE_PATH = 'media:file-path;textable';
|
|
809
|
+
// Media URN for an array of file paths - textable with list marker
|
|
810
|
+
const MEDIA_FILE_PATH_ARRAY = 'media:file-path;list;textable';
|
|
811
|
+
|
|
812
|
+
// Semantic text input types - distinguished by their purpose/context
|
|
813
|
+
// Media URN for frontmatter text (book metadata) - scalar by default
|
|
814
|
+
const MEDIA_FRONTMATTER_TEXT = 'media:frontmatter;textable';
|
|
815
|
+
// Media URN for model spec (provider:model format, HuggingFace name, etc.) - scalar by default
|
|
816
|
+
const MEDIA_MODEL_SPEC = 'media:model-spec;textable';
|
|
817
|
+
// Media URN for MLX model path - scalar by default
|
|
818
|
+
const MEDIA_MLX_MODEL_PATH = 'media:mlx-model-path;textable';
|
|
819
|
+
// Media URN for model repository (input for list-models) - has record marker
|
|
820
|
+
const MEDIA_MODEL_REPO = 'media:model-repo;record;textable';
|
|
821
|
+
|
|
822
|
+
// CAPDAG output types - record marker for structured JSON objects, list marker for arrays
|
|
823
|
+
// Media URN for model dimension output - scalar by default (no list marker)
|
|
824
|
+
const MEDIA_MODEL_DIM = 'media:integer;model-dim;numeric;textable';
|
|
825
|
+
// Media URN for model download output - has record marker
|
|
826
|
+
const MEDIA_DOWNLOAD_OUTPUT = 'media:download-result;record;textable';
|
|
827
|
+
// Media URN for model list output - has record marker
|
|
828
|
+
const MEDIA_LIST_OUTPUT = 'media:model-list;record;textable';
|
|
829
|
+
// Media URN for model status output - has record marker
|
|
830
|
+
const MEDIA_STATUS_OUTPUT = 'media:model-status;record;textable';
|
|
831
|
+
// Media URN for model contents output - has record marker
|
|
832
|
+
const MEDIA_CONTENTS_OUTPUT = 'media:model-contents;record;textable';
|
|
833
|
+
// Media URN for model availability output - has record marker
|
|
834
|
+
const MEDIA_AVAILABILITY_OUTPUT = 'media:model-availability;record;textable';
|
|
835
|
+
// Media URN for model path output - has record marker
|
|
836
|
+
const MEDIA_PATH_OUTPUT = 'media:model-path;record;textable';
|
|
837
|
+
// Media URN for embedding vector output - has record marker
|
|
838
|
+
const MEDIA_EMBEDDING_VECTOR = 'media:embedding-vector;record;textable';
|
|
839
|
+
// Media URN for LLM inference output - has record marker
|
|
840
|
+
const MEDIA_LLM_INFERENCE_OUTPUT = 'media:generated-text;record;textable';
|
|
841
|
+
// Media URN for extracted metadata - has record marker
|
|
842
|
+
const MEDIA_FILE_METADATA = 'media:file-metadata;record;textable';
|
|
843
|
+
// Media URN for extracted outline - has record marker
|
|
844
|
+
const MEDIA_DOCUMENT_OUTLINE = 'media:document-outline;record;textable';
|
|
845
|
+
// Media URN for disbound page - has list marker (array of page objects)
|
|
846
|
+
const MEDIA_DISBOUND_PAGE = 'media:disbound-page;list;textable';
|
|
847
|
+
// Media URN for vision inference output - textable, scalar by default
|
|
848
|
+
const MEDIA_IMAGE_DESCRIPTION = 'media:image-description;textable';
|
|
849
|
+
// Media URN for transcription output - has record marker
|
|
850
|
+
const MEDIA_TRANSCRIPTION_OUTPUT = 'media:record;textable;transcription';
|
|
851
|
+
// Media URN for decision output (bit choice) - scalar by default
|
|
852
|
+
const MEDIA_DECISION = 'media:bool;decision;textable';
|
|
853
|
+
// Media URN for decision array output (bit choices) - has list marker
|
|
854
|
+
const MEDIA_DECISION_ARRAY = 'media:bool;decision;list;textable';
|
|
855
|
+
|
|
856
|
+
// =============================================================================
|
|
857
|
+
// STANDARD CAP URN CONSTANTS
|
|
858
|
+
// =============================================================================
|
|
859
|
+
|
|
860
|
+
// Standard echo capability URN
|
|
861
|
+
// Accepts any media type as input and outputs any media type
|
|
862
|
+
const CAP_IDENTITY = 'cap:in=media:;out=media:';
|
|
863
|
+
|
|
864
|
+
// =============================================================================
|
|
865
|
+
// MEDIA URN CLASS
|
|
866
|
+
// =============================================================================
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Error types for MediaUrn operations
|
|
870
|
+
*/
|
|
871
|
+
class MediaUrnError extends Error {
|
|
872
|
+
constructor(code, message) {
|
|
873
|
+
super(message);
|
|
874
|
+
this.name = 'MediaUrnError';
|
|
875
|
+
this.code = code;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const MediaUrnErrorCodes = {
|
|
880
|
+
INVALID_PREFIX: 'INVALID_PREFIX',
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* MediaUrn wraps a TaggedUrn with prefix validation and media-specific convenience methods.
|
|
885
|
+
* Mirrors the Rust MediaUrn type.
|
|
886
|
+
*/
|
|
887
|
+
class MediaUrn {
|
|
888
|
+
/**
|
|
889
|
+
* @param {TaggedUrn} taggedUrn - A parsed TaggedUrn with prefix 'media'
|
|
890
|
+
*/
|
|
891
|
+
constructor(taggedUrn) {
|
|
892
|
+
if (taggedUrn.getPrefix() !== 'media') {
|
|
893
|
+
throw new MediaUrnError(
|
|
894
|
+
MediaUrnErrorCodes.INVALID_PREFIX,
|
|
895
|
+
`Expected prefix 'media', got '${taggedUrn.getPrefix()}'`
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
this._urn = taggedUrn;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Parse a media URN string. Validates the prefix is 'media'.
|
|
903
|
+
* @param {string} str - The media URN string (e.g., "media:pdf")
|
|
904
|
+
* @returns {MediaUrn}
|
|
905
|
+
* @throws {MediaUrnError} If prefix is not 'media'
|
|
906
|
+
*/
|
|
907
|
+
static fromString(str) {
|
|
908
|
+
const urn = TaggedUrn.fromString(str);
|
|
909
|
+
return new MediaUrn(urn);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** @returns {boolean} True if the "textable" marker tag is NOT present (binary = not textable) */
|
|
913
|
+
isBinary() { return this._urn.getTag('textable') === undefined; }
|
|
914
|
+
|
|
915
|
+
// =========================================================================
|
|
916
|
+
// CARDINALITY (list marker)
|
|
917
|
+
// =========================================================================
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Returns true if this media is a list (has `list` marker tag).
|
|
921
|
+
* Returns false if scalar (no `list` marker = default).
|
|
922
|
+
* @returns {boolean}
|
|
923
|
+
*/
|
|
924
|
+
isList() { return this._hasMarkerTag('list'); }
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Returns true if this media is a scalar (no `list` marker).
|
|
928
|
+
* Scalar is the default cardinality.
|
|
929
|
+
* @returns {boolean}
|
|
930
|
+
*/
|
|
931
|
+
isScalar() { return !this._hasMarkerTag('list'); }
|
|
932
|
+
|
|
933
|
+
// =========================================================================
|
|
934
|
+
// STRUCTURE (record marker)
|
|
935
|
+
// =========================================================================
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Returns true if this media is a record (has `record` marker tag).
|
|
939
|
+
* A record has internal key-value structure (e.g., JSON object).
|
|
940
|
+
* @returns {boolean}
|
|
941
|
+
*/
|
|
942
|
+
isRecord() { return this._hasMarkerTag('record'); }
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Returns true if this media is opaque (no `record` marker).
|
|
946
|
+
* Opaque is the default structure - no internal fields recognized.
|
|
947
|
+
* @returns {boolean}
|
|
948
|
+
*/
|
|
949
|
+
isOpaque() { return !this._hasMarkerTag('record'); }
|
|
950
|
+
|
|
951
|
+
// =========================================================================
|
|
952
|
+
// HELPER: Check for marker tag presence
|
|
953
|
+
// =========================================================================
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Check if a marker tag (tag with wildcard/no value) is present.
|
|
957
|
+
* A marker tag is stored as key="*" in the tagged URN.
|
|
958
|
+
* @param {string} tagName
|
|
959
|
+
* @returns {boolean}
|
|
960
|
+
* @private
|
|
961
|
+
*/
|
|
962
|
+
_hasMarkerTag(tagName) {
|
|
963
|
+
const value = this._urn.getTag(tagName);
|
|
964
|
+
return value === '*';
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** @returns {boolean} True if the "json" marker tag is present */
|
|
968
|
+
isJson() { return this._urn.getTag('json') !== undefined; }
|
|
969
|
+
|
|
970
|
+
/** @returns {boolean} True if the "textable" marker tag is present */
|
|
971
|
+
isText() { return this._urn.getTag('textable') !== undefined; }
|
|
972
|
+
|
|
973
|
+
/** @returns {boolean} True if the "void" marker tag is present */
|
|
974
|
+
isVoid() { return this._urn.getTag('void') !== undefined; }
|
|
975
|
+
|
|
976
|
+
/** @returns {boolean} True if the "image" marker tag is present */
|
|
977
|
+
isImage() { return this._urn.getTag('image') !== undefined; }
|
|
978
|
+
|
|
979
|
+
/** @returns {boolean} True if the "audio" marker tag is present */
|
|
980
|
+
isAudio() { return this._urn.getTag('audio') !== undefined; }
|
|
981
|
+
|
|
982
|
+
/** @returns {boolean} True if the "video" marker tag is present */
|
|
983
|
+
isVideo() { return this._urn.getTag('video') !== undefined; }
|
|
984
|
+
|
|
985
|
+
/** @returns {boolean} True if the "numeric" marker tag is present */
|
|
986
|
+
isNumeric() { return this._urn.getTag('numeric') !== undefined; }
|
|
987
|
+
|
|
988
|
+
/** @returns {boolean} True if the "bool" marker tag is present */
|
|
989
|
+
isBool() { return this._urn.getTag('bool') !== undefined; }
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Check if this represents a single file path type (not array).
|
|
993
|
+
* Returns true if the "file-path" marker tag is present AND no list marker.
|
|
994
|
+
* @returns {boolean}
|
|
995
|
+
*/
|
|
996
|
+
isFilePath() { return this._hasMarkerTag('file-path') && !this.isList(); }
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Check if this represents a file path array type.
|
|
1000
|
+
* Returns true if the "file-path" marker tag is present AND has list marker.
|
|
1001
|
+
* @returns {boolean}
|
|
1002
|
+
*/
|
|
1003
|
+
isFilePathArray() { return this._hasMarkerTag('file-path') && this.isList(); }
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Check if this represents any file path type (single or array).
|
|
1007
|
+
* Returns true if "file-path" marker tag is present.
|
|
1008
|
+
* @returns {boolean}
|
|
1009
|
+
*/
|
|
1010
|
+
isAnyFilePath() { return this._hasMarkerTag('file-path'); }
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Check if this media URN conforms to another (pattern).
|
|
1014
|
+
* @param {MediaUrn} pattern
|
|
1015
|
+
* @returns {boolean}
|
|
1016
|
+
*/
|
|
1017
|
+
conformsTo(pattern) { return this._urn.conformsTo(pattern._urn); }
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Check if this media URN (as pattern) accepts an instance.
|
|
1021
|
+
* @param {MediaUrn} instance
|
|
1022
|
+
* @returns {boolean}
|
|
1023
|
+
*/
|
|
1024
|
+
accepts(instance) { return this._urn.accepts(instance._urn); }
|
|
1025
|
+
|
|
1026
|
+
/** @returns {number} Specificity score (tag count based) */
|
|
1027
|
+
specificity() { return this._urn.specificity(); }
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Get the file extension from the ext tag, if present.
|
|
1031
|
+
* @returns {string|null}
|
|
1032
|
+
*/
|
|
1033
|
+
extension() {
|
|
1034
|
+
const ext = this._urn.getTag('ext');
|
|
1035
|
+
return ext !== undefined ? ext : null;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* @param {string} key
|
|
1040
|
+
* @param {string} [value]
|
|
1041
|
+
* @returns {boolean}
|
|
1042
|
+
*/
|
|
1043
|
+
hasTag(key, value) {
|
|
1044
|
+
if (value !== undefined) {
|
|
1045
|
+
return this._urn.hasTag(key, value);
|
|
1046
|
+
}
|
|
1047
|
+
return this._urn.getTag(key) !== undefined;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* @param {string} key
|
|
1052
|
+
* @returns {string|undefined}
|
|
1053
|
+
*/
|
|
1054
|
+
getTag(key) { return this._urn.getTag(key); }
|
|
1055
|
+
|
|
1056
|
+
/** @returns {string} Canonical string representation */
|
|
1057
|
+
toString() { return this._urn.toString(); }
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* @param {MediaUrn} other
|
|
1061
|
+
* @returns {boolean}
|
|
1062
|
+
*/
|
|
1063
|
+
equals(other) { return this._urn.equals(other._urn); }
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// =============================================================================
|
|
1067
|
+
// STANDARD CAP URN BUILDERS
|
|
1068
|
+
// =============================================================================
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Build URN for LLM conversation capability
|
|
1072
|
+
* @param {string} langCode - Language code (e.g., "en", "fr")
|
|
1073
|
+
* @returns {CapUrn}
|
|
1074
|
+
*/
|
|
1075
|
+
function llmConversationUrn(langCode) {
|
|
1076
|
+
return new CapUrnBuilder()
|
|
1077
|
+
.tag('op', 'conversation')
|
|
1078
|
+
.tag('unconstrained', '*')
|
|
1079
|
+
.tag('language', langCode)
|
|
1080
|
+
.inSpec(MEDIA_STRING)
|
|
1081
|
+
.outSpec(MEDIA_LLM_INFERENCE_OUTPUT)
|
|
1082
|
+
.build();
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Build URN for model-availability capability
|
|
1087
|
+
* @returns {CapUrn}
|
|
1088
|
+
*/
|
|
1089
|
+
function modelAvailabilityUrn() {
|
|
1090
|
+
return new CapUrnBuilder()
|
|
1091
|
+
.tag('op', 'model-availability')
|
|
1092
|
+
.inSpec(MEDIA_MODEL_SPEC)
|
|
1093
|
+
.outSpec(MEDIA_AVAILABILITY_OUTPUT)
|
|
1094
|
+
.build();
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Build URN for model-path capability
|
|
1099
|
+
* @returns {CapUrn}
|
|
1100
|
+
*/
|
|
1101
|
+
function modelPathUrn() {
|
|
1102
|
+
return new CapUrnBuilder()
|
|
1103
|
+
.tag('op', 'model-path')
|
|
1104
|
+
.inSpec(MEDIA_MODEL_SPEC)
|
|
1105
|
+
.outSpec(MEDIA_PATH_OUTPUT)
|
|
1106
|
+
.build();
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// =============================================================================
|
|
1110
|
+
// SCHEMA URL CONFIGURATION
|
|
1111
|
+
// =============================================================================
|
|
1112
|
+
|
|
1113
|
+
const DEFAULT_SCHEMA_BASE = 'https://capdag.com/schema';
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Get the schema base URL from environment variables or default
|
|
1117
|
+
*
|
|
1118
|
+
* Checks in order:
|
|
1119
|
+
* 1. CAPDAG_SCHEMA_BASE_URL environment variable
|
|
1120
|
+
* 2. CAPDAG_REGISTRY_URL environment variable + "/schema"
|
|
1121
|
+
* 3. Default: "https://capdag.com/schema"
|
|
1122
|
+
*
|
|
1123
|
+
* @returns {string} The schema base URL
|
|
1124
|
+
*/
|
|
1125
|
+
function getSchemaBaseURL() {
|
|
1126
|
+
if (typeof process !== 'undefined' && process.env) {
|
|
1127
|
+
if (process.env.CAPDAG_SCHEMA_BASE_URL) {
|
|
1128
|
+
return process.env.CAPDAG_SCHEMA_BASE_URL;
|
|
1129
|
+
}
|
|
1130
|
+
if (process.env.CAPDAG_REGISTRY_URL) {
|
|
1131
|
+
return process.env.CAPDAG_REGISTRY_URL + '/schema';
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return DEFAULT_SCHEMA_BASE;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Get a profile URL for the given profile name
|
|
1139
|
+
*
|
|
1140
|
+
* @param {string} profileName - The profile name (e.g., 'str', 'int')
|
|
1141
|
+
* @returns {string} The full profile URL
|
|
1142
|
+
*/
|
|
1143
|
+
function getProfileURL(profileName) {
|
|
1144
|
+
return `${getSchemaBaseURL()}/${profileName}`;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// =============================================================================
|
|
1148
|
+
// MEDIA URN TAG UTILITIES
|
|
1149
|
+
// =============================================================================
|
|
1150
|
+
// NOTE: The MEDIA_X constants above are convenience values for referencing
|
|
1151
|
+
// common media URNs in code. Resolution must go through mediaSpecs tables -
|
|
1152
|
+
// there is NO built-in resolution.
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Resolved MediaSpec structure
|
|
1156
|
+
*
|
|
1157
|
+
* A MediaSpec is a resolved media specification containing information about
|
|
1158
|
+
* a value type in the CAPDAG system. MediaSpecs are identified by unique media URNs
|
|
1159
|
+
* and contain fields like media_type, profile_uri, schema, etc.
|
|
1160
|
+
*
|
|
1161
|
+
* MediaSpecs are defined in JSON files in the registry or inline in cap definitions.
|
|
1162
|
+
*/
|
|
1163
|
+
class MediaSpec {
|
|
1164
|
+
/**
|
|
1165
|
+
* Create a new MediaSpec
|
|
1166
|
+
* @param {string} contentType - The MIME content type
|
|
1167
|
+
* @param {string|null} profile - Optional profile URL
|
|
1168
|
+
* @param {Object|null} schema - Optional JSON Schema for local validation
|
|
1169
|
+
* @param {string|null} title - Optional display-friendly title
|
|
1170
|
+
* @param {string|null} description - Optional description
|
|
1171
|
+
* @param {string|null} mediaUrn - Source media URN for tag-based checks
|
|
1172
|
+
* @param {Object|null} validation - Optional validation rules (min, max, min_length, max_length, pattern, allowed_values)
|
|
1173
|
+
* @param {Object|null} metadata - Optional metadata (arbitrary key-value pairs for display/categorization)
|
|
1174
|
+
* @param {string[]} extensions - File extensions for storing this media type (e.g., ['pdf'], ['jpg', 'jpeg'])
|
|
1175
|
+
*/
|
|
1176
|
+
constructor(contentType, profile = null, schema = null, title = null, description = null, mediaUrn = null, validation = null, metadata = null, extensions = []) {
|
|
1177
|
+
this.contentType = contentType;
|
|
1178
|
+
this.profile = profile;
|
|
1179
|
+
this.schema = schema;
|
|
1180
|
+
this.title = title;
|
|
1181
|
+
this.description = description;
|
|
1182
|
+
this.mediaUrn = mediaUrn;
|
|
1183
|
+
this.validation = validation;
|
|
1184
|
+
this.metadata = metadata;
|
|
1185
|
+
this.extensions = extensions;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Get the parsed MediaUrn object for this spec. Lazily created and cached.
|
|
1190
|
+
* @returns {MediaUrn|null} The parsed MediaUrn, or null if no mediaUrn string
|
|
1191
|
+
*/
|
|
1192
|
+
parsedMediaUrn() {
|
|
1193
|
+
if (!this.mediaUrn) return null;
|
|
1194
|
+
if (!this._parsedMediaUrn) {
|
|
1195
|
+
this._parsedMediaUrn = MediaUrn.fromString(this.mediaUrn);
|
|
1196
|
+
}
|
|
1197
|
+
return this._parsedMediaUrn;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** @returns {boolean} True if binary (textable marker tag absent) */
|
|
1201
|
+
isBinary() {
|
|
1202
|
+
const mu = this.parsedMediaUrn();
|
|
1203
|
+
return mu ? mu.isBinary() : false;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** @returns {boolean} True if record structure (has record marker) */
|
|
1207
|
+
isRecord() {
|
|
1208
|
+
const mu = this.parsedMediaUrn();
|
|
1209
|
+
return mu ? mu.isRecord() : false;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** @returns {boolean} True if opaque structure (no record marker) */
|
|
1213
|
+
isOpaque() {
|
|
1214
|
+
const mu = this.parsedMediaUrn();
|
|
1215
|
+
return mu ? mu.isOpaque() : false;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/** @returns {boolean} True if scalar value (no list marker) */
|
|
1219
|
+
isScalar() {
|
|
1220
|
+
const mu = this.parsedMediaUrn();
|
|
1221
|
+
return mu ? mu.isScalar() : false;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/** @returns {boolean} True if list/array (has list marker) */
|
|
1225
|
+
isList() {
|
|
1226
|
+
const mu = this.parsedMediaUrn();
|
|
1227
|
+
return mu ? mu.isList() : false;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/** @returns {boolean} True if JSON representation (json tag present) */
|
|
1231
|
+
isJSON() {
|
|
1232
|
+
const mu = this.parsedMediaUrn();
|
|
1233
|
+
return mu ? mu.isJson() : false;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/** @returns {boolean} True if text (textable tag present) */
|
|
1237
|
+
isText() {
|
|
1238
|
+
const mu = this.parsedMediaUrn();
|
|
1239
|
+
return mu ? mu.isText() : false;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** @returns {boolean} True if image (image tag present) */
|
|
1243
|
+
isImage() {
|
|
1244
|
+
const mu = this.parsedMediaUrn();
|
|
1245
|
+
return mu ? mu.hasTag('image') : false;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/** @returns {boolean} True if audio (audio tag present) */
|
|
1249
|
+
isAudio() {
|
|
1250
|
+
const mu = this.parsedMediaUrn();
|
|
1251
|
+
return mu ? mu.hasTag('audio') : false;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/** @returns {boolean} True if video (video tag present) */
|
|
1255
|
+
isVideo() {
|
|
1256
|
+
const mu = this.parsedMediaUrn();
|
|
1257
|
+
return mu ? mu.hasTag('video') : false;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** @returns {boolean} True if numeric (numeric tag present) */
|
|
1261
|
+
isNumeric() {
|
|
1262
|
+
const mu = this.parsedMediaUrn();
|
|
1263
|
+
return mu ? mu.hasTag('numeric') : false;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/** @returns {boolean} True if boolean (bool tag present) */
|
|
1267
|
+
isBool() {
|
|
1268
|
+
const mu = this.parsedMediaUrn();
|
|
1269
|
+
return mu ? mu.hasTag('bool') : false;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Get the primary type (e.g., "image" from "image/png")
|
|
1274
|
+
* @returns {string} The primary type
|
|
1275
|
+
*/
|
|
1276
|
+
primaryType() {
|
|
1277
|
+
return this.contentType.split('/')[0];
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* Get the subtype (e.g., "png" from "image/png")
|
|
1282
|
+
* @returns {string|undefined} The subtype
|
|
1283
|
+
*/
|
|
1284
|
+
subtype() {
|
|
1285
|
+
const parts = this.contentType.split('/');
|
|
1286
|
+
return parts.length > 1 ? parts[1] : undefined;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* Get the canonical string representation
|
|
1291
|
+
* Format: <media-type>; profile="<url>" (no content-type: prefix)
|
|
1292
|
+
* @returns {string} The media_spec as a string
|
|
1293
|
+
*/
|
|
1294
|
+
toString() {
|
|
1295
|
+
if (this.profile) {
|
|
1296
|
+
return `${this.contentType}; profile="${this.profile}"`;
|
|
1297
|
+
}
|
|
1298
|
+
return this.contentType;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Get MediaSpec from a CapUrn using the output media URN
|
|
1303
|
+
* NOTE: outSpec is now a required first-class field on CapUrn
|
|
1304
|
+
* @param {CapUrn} capUrn - The cap URN
|
|
1305
|
+
* @param {Object} mediaSpecs - Optional mediaSpecs lookup table for resolution
|
|
1306
|
+
* @returns {MediaSpec} The resolved MediaSpec
|
|
1307
|
+
* @throws {MediaSpecError} If media URN cannot be resolved
|
|
1308
|
+
*/
|
|
1309
|
+
static fromCapUrn(capUrn, mediaSpecs = []) {
|
|
1310
|
+
// outSpec is now a required field, so it's always present
|
|
1311
|
+
const mediaUrn = capUrn.getOutSpec();
|
|
1312
|
+
|
|
1313
|
+
// Resolve the media URN to a MediaSpec - no fallbacks, fail hard
|
|
1314
|
+
return resolveMediaUrn(mediaUrn, mediaSpecs);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* Resolve a media URN to a MediaSpec
|
|
1320
|
+
*
|
|
1321
|
+
* Resolution: Look up mediaUrn in mediaSpecs array (by urn field), FAIL HARD if not found.
|
|
1322
|
+
* There is no built-in resolution - all media URNs must be in mediaSpecs.
|
|
1323
|
+
*
|
|
1324
|
+
* @param {string} mediaUrn - The media URN (e.g., "media:textable")
|
|
1325
|
+
* @param {Array} mediaSpecs - The mediaSpecs array (each item has urn, media_type, title, etc.)
|
|
1326
|
+
* @returns {MediaSpec} The resolved MediaSpec
|
|
1327
|
+
* @throws {MediaSpecError} If media URN cannot be resolved
|
|
1328
|
+
*/
|
|
1329
|
+
function resolveMediaUrn(mediaUrn, mediaSpecs = []) {
|
|
1330
|
+
// Look up in mediaSpecs array by urn field
|
|
1331
|
+
if (mediaSpecs && Array.isArray(mediaSpecs)) {
|
|
1332
|
+
const def = mediaSpecs.find(spec => spec.urn === mediaUrn);
|
|
1333
|
+
|
|
1334
|
+
if (def) {
|
|
1335
|
+
// Object form: { urn, media_type, title, profile_uri?, schema?, description?, validation?, metadata?, extensions? }
|
|
1336
|
+
const mediaType = def.media_type || def.mediaType;
|
|
1337
|
+
const profileUri = def.profile_uri || def.profileUri || null;
|
|
1338
|
+
const schema = def.schema || null;
|
|
1339
|
+
const title = def.title || null;
|
|
1340
|
+
const description = def.description || null;
|
|
1341
|
+
const validation = def.validation || null;
|
|
1342
|
+
const metadata = def.metadata || null;
|
|
1343
|
+
const extensions = Array.isArray(def.extensions) ? def.extensions : [];
|
|
1344
|
+
|
|
1345
|
+
if (!mediaType) {
|
|
1346
|
+
throw new MediaSpecError(
|
|
1347
|
+
MediaSpecErrorCodes.UNRESOLVABLE_MEDIA_URN,
|
|
1348
|
+
`Media URN '${mediaUrn}' has invalid definition: missing media_type`
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
return new MediaSpec(mediaType, profileUri, schema, title, description, mediaUrn, validation, metadata, extensions);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// FAIL HARD - media URN must be in mediaSpecs array
|
|
1357
|
+
throw new MediaSpecError(
|
|
1358
|
+
MediaSpecErrorCodes.UNRESOLVABLE_MEDIA_URN,
|
|
1359
|
+
`Cannot resolve media URN: '${mediaUrn}'. Not found in mediaSpecs array.`
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Build an extension index from a mediaSpecs array.
|
|
1365
|
+
* Maps lowercase extension strings to arrays of media URNs that use that extension.
|
|
1366
|
+
*
|
|
1367
|
+
* @param {Array} mediaSpecs - The mediaSpecs array
|
|
1368
|
+
* @returns {Map<string, string[]>} Map from extension to list of URNs
|
|
1369
|
+
*/
|
|
1370
|
+
function buildExtensionIndex(mediaSpecs) {
|
|
1371
|
+
const index = new Map();
|
|
1372
|
+
if (!mediaSpecs || !Array.isArray(mediaSpecs)) {
|
|
1373
|
+
return index;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
for (const spec of mediaSpecs) {
|
|
1377
|
+
if (!spec.urn || !Array.isArray(spec.extensions)) continue;
|
|
1378
|
+
for (const ext of spec.extensions) {
|
|
1379
|
+
const extLower = ext.toLowerCase();
|
|
1380
|
+
if (!index.has(extLower)) {
|
|
1381
|
+
index.set(extLower, []);
|
|
1382
|
+
}
|
|
1383
|
+
const urns = index.get(extLower);
|
|
1384
|
+
if (!urns.includes(spec.urn)) {
|
|
1385
|
+
urns.push(spec.urn);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
return index;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Look up all media URNs that match a file extension (synchronous, no network).
|
|
1394
|
+
*
|
|
1395
|
+
* Returns all media URNs registered for the given file extension.
|
|
1396
|
+
* Multiple URNs may match the same extension (e.g., with different form= parameters).
|
|
1397
|
+
*
|
|
1398
|
+
* The extension should NOT include the leading dot (e.g., "pdf" not ".pdf").
|
|
1399
|
+
* Lookup is case-insensitive.
|
|
1400
|
+
*
|
|
1401
|
+
* @param {string} extension - The file extension to look up (without leading dot)
|
|
1402
|
+
* @param {Array} mediaSpecs - The mediaSpecs array
|
|
1403
|
+
* @returns {string[]} Array of media URNs for the extension
|
|
1404
|
+
* @throws {MediaSpecError} If no media spec is registered for the given extension
|
|
1405
|
+
*
|
|
1406
|
+
* @example
|
|
1407
|
+
* const urns = mediaUrnsForExtension('pdf', mediaSpecs);
|
|
1408
|
+
* // May return ['media:pdf']
|
|
1409
|
+
*/
|
|
1410
|
+
function mediaUrnsForExtension(extension, mediaSpecs) {
|
|
1411
|
+
const index = buildExtensionIndex(mediaSpecs);
|
|
1412
|
+
const extLower = extension.toLowerCase();
|
|
1413
|
+
const urns = index.get(extLower);
|
|
1414
|
+
|
|
1415
|
+
if (!urns || urns.length === 0) {
|
|
1416
|
+
throw new MediaSpecError(
|
|
1417
|
+
MediaSpecErrorCodes.UNRESOLVABLE_MEDIA_URN,
|
|
1418
|
+
`No media spec registered for extension '${extension}'. ` +
|
|
1419
|
+
`Ensure the media spec is defined with an 'extensions' array containing '${extension}'.`
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
return urns;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Get all registered extensions and their corresponding media URNs.
|
|
1428
|
+
*
|
|
1429
|
+
* Returns an array of [extension, urns] pairs for debugging and introspection.
|
|
1430
|
+
*
|
|
1431
|
+
* @param {Array} mediaSpecs - The mediaSpecs array
|
|
1432
|
+
* @returns {Array<[string, string[]]>} Array of [extension, urns] pairs
|
|
1433
|
+
*/
|
|
1434
|
+
function getExtensionMappings(mediaSpecs) {
|
|
1435
|
+
const index = buildExtensionIndex(mediaSpecs);
|
|
1436
|
+
return Array.from(index.entries());
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Validate that media_specs array has no duplicate URNs.
|
|
1441
|
+
*
|
|
1442
|
+
* @param {Array} mediaSpecs - The mediaSpecs array to validate
|
|
1443
|
+
* @returns {{valid: boolean, error?: string, duplicates?: string[]}}
|
|
1444
|
+
*/
|
|
1445
|
+
function validateNoMediaSpecDuplicates(mediaSpecs) {
|
|
1446
|
+
if (!mediaSpecs || !Array.isArray(mediaSpecs) || mediaSpecs.length === 0) {
|
|
1447
|
+
return { valid: true };
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
const seen = new Set();
|
|
1451
|
+
const duplicates = [];
|
|
1452
|
+
|
|
1453
|
+
for (const spec of mediaSpecs) {
|
|
1454
|
+
if (!spec.urn) continue;
|
|
1455
|
+
if (seen.has(spec.urn)) {
|
|
1456
|
+
duplicates.push(spec.urn);
|
|
1457
|
+
} else {
|
|
1458
|
+
seen.add(spec.urn);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
if (duplicates.length > 0) {
|
|
1463
|
+
return {
|
|
1464
|
+
valid: false,
|
|
1465
|
+
error: `Duplicate media URNs in media_specs: ${duplicates.join(', ')}`,
|
|
1466
|
+
duplicates
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
return { valid: true };
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* XV5: Validate that inline media_specs don't redefine existing registry specs.
|
|
1475
|
+
*
|
|
1476
|
+
* Validation requires a registryLookup function to check if media URNs exist.
|
|
1477
|
+
* If no registryLookup is provided, validation passes (graceful degradation).
|
|
1478
|
+
*
|
|
1479
|
+
* @param {Array} mediaSpecs - The inline media_specs array from a capability
|
|
1480
|
+
* @param {Object} [options] - Validation options
|
|
1481
|
+
* @param {Function} [options.registryLookup] - Function to check if media URN exists in registry
|
|
1482
|
+
* Returns true if exists, false otherwise
|
|
1483
|
+
* Should handle errors gracefully (return false)
|
|
1484
|
+
* @returns {Promise<{valid: boolean, error?: string, redefines?: string[]}>}
|
|
1485
|
+
*/
|
|
1486
|
+
async function validateNoMediaSpecRedefinition(mediaSpecs, options = {}) {
|
|
1487
|
+
if (!mediaSpecs || !Array.isArray(mediaSpecs) || mediaSpecs.length === 0) {
|
|
1488
|
+
return { valid: true };
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const { registryLookup } = options;
|
|
1492
|
+
|
|
1493
|
+
// If no registry lookup provided, degrade gracefully and allow
|
|
1494
|
+
if (!registryLookup || typeof registryLookup !== 'function') {
|
|
1495
|
+
return { valid: true };
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
const redefines = [];
|
|
1499
|
+
|
|
1500
|
+
for (const spec of mediaSpecs) {
|
|
1501
|
+
const mediaUrn = spec.urn;
|
|
1502
|
+
if (!mediaUrn) continue;
|
|
1503
|
+
try {
|
|
1504
|
+
const existsInRegistry = await registryLookup(mediaUrn);
|
|
1505
|
+
if (existsInRegistry) {
|
|
1506
|
+
redefines.push(mediaUrn);
|
|
1507
|
+
}
|
|
1508
|
+
} catch (err) {
|
|
1509
|
+
// Registry lookup failed - log warning and allow (graceful degradation)
|
|
1510
|
+
console.warn(`[WARN] XV5: Could not verify inline spec '${mediaUrn}' against registry: ${err.message}. Allowing operation in offline mode.`);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
if (redefines.length > 0) {
|
|
1515
|
+
return {
|
|
1516
|
+
valid: false,
|
|
1517
|
+
error: `XV5: Inline media specs redefine existing registry specs: ${redefines.join(', ')}`,
|
|
1518
|
+
redefines
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
return { valid: true };
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/**
|
|
1526
|
+
* XV5: Synchronous version that checks against a provided lookup function.
|
|
1527
|
+
* If no registryLookup is provided, validation passes (graceful degradation).
|
|
1528
|
+
*
|
|
1529
|
+
* @param {Array} mediaSpecs - The inline media_specs array from a capability
|
|
1530
|
+
* @param {Function} [registryLookup] - Synchronous function to check if media URN exists
|
|
1531
|
+
* Returns true if exists, false otherwise
|
|
1532
|
+
* @returns {{valid: boolean, error?: string, redefines?: string[]}}
|
|
1533
|
+
*/
|
|
1534
|
+
function validateNoMediaSpecRedefinitionSync(mediaSpecs, registryLookup = null) {
|
|
1535
|
+
if (!mediaSpecs || !Array.isArray(mediaSpecs) || mediaSpecs.length === 0) {
|
|
1536
|
+
return { valid: true };
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// If no registry lookup provided, degrade gracefully and allow
|
|
1540
|
+
if (!registryLookup || typeof registryLookup !== 'function') {
|
|
1541
|
+
return { valid: true };
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
const redefines = [];
|
|
1545
|
+
|
|
1546
|
+
for (const spec of mediaSpecs) {
|
|
1547
|
+
const mediaUrn = spec.urn;
|
|
1548
|
+
if (!mediaUrn) continue;
|
|
1549
|
+
if (registryLookup(mediaUrn)) {
|
|
1550
|
+
redefines.push(mediaUrn);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
if (redefines.length > 0) {
|
|
1555
|
+
return {
|
|
1556
|
+
valid: false,
|
|
1557
|
+
error: `XV5: Inline media specs redefine existing registry specs: ${redefines.join(', ')}`,
|
|
1558
|
+
redefines
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
return { valid: true };
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* Check if a CapUrn represents binary output.
|
|
1567
|
+
* Throws error if the output spec cannot be resolved - no fallbacks.
|
|
1568
|
+
* @param {CapUrn} capUrn - The cap URN
|
|
1569
|
+
* @param {Array} mediaSpecs - Optional mediaSpecs array
|
|
1570
|
+
* @returns {boolean} True if binary
|
|
1571
|
+
* @throws {MediaSpecError} If 'out' tag is missing or spec ID cannot be resolved
|
|
1572
|
+
*/
|
|
1573
|
+
function isBinaryCapUrn(capUrn, mediaSpecs = []) {
|
|
1574
|
+
const mediaSpec = MediaSpec.fromCapUrn(capUrn, mediaSpecs);
|
|
1575
|
+
return mediaSpec.isBinary();
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* Check if a CapUrn represents JSON output.
|
|
1580
|
+
* Note: This checks for explicit JSON format marker only.
|
|
1581
|
+
* Throws error if the output spec cannot be resolved - no fallbacks.
|
|
1582
|
+
* @param {CapUrn} capUrn - The cap URN
|
|
1583
|
+
* @param {Array} mediaSpecs - Optional mediaSpecs array
|
|
1584
|
+
* @returns {boolean} True if explicit JSON tag present
|
|
1585
|
+
* @throws {MediaSpecError} If 'out' tag is missing or spec ID cannot be resolved
|
|
1586
|
+
*/
|
|
1587
|
+
function isJSONCapUrn(capUrn, mediaSpecs = []) {
|
|
1588
|
+
const mediaSpec = MediaSpec.fromCapUrn(capUrn, mediaSpecs);
|
|
1589
|
+
return mediaSpec.isJSON();
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/**
|
|
1593
|
+
* Check if a CapUrn represents structured output (map or list).
|
|
1594
|
+
* Structured data can be serialized as JSON when transmitted as text.
|
|
1595
|
+
* Throws error if the output spec cannot be resolved - no fallbacks.
|
|
1596
|
+
* @param {CapUrn} capUrn - The cap URN
|
|
1597
|
+
* @param {Array} mediaSpecs - Optional mediaSpecs array
|
|
1598
|
+
* @returns {boolean} True if structured (map or list)
|
|
1599
|
+
* @throws {MediaSpecError} If 'out' tag is missing or spec ID cannot be resolved
|
|
1600
|
+
*/
|
|
1601
|
+
function isStructuredCapUrn(capUrn, mediaSpecs = []) {
|
|
1602
|
+
const mediaSpec = MediaSpec.fromCapUrn(capUrn, mediaSpecs);
|
|
1603
|
+
return mediaSpec.isStructured();
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/**
|
|
1607
|
+
* Registration attribution - who registered a capability and when
|
|
1608
|
+
*/
|
|
1609
|
+
class RegisteredBy {
|
|
1610
|
+
/**
|
|
1611
|
+
* Create a new registration attribution
|
|
1612
|
+
* @param {string} username - Username of the user who registered this capability
|
|
1613
|
+
* @param {string} registeredAt - ISO 8601 timestamp of when the capability was registered
|
|
1614
|
+
*/
|
|
1615
|
+
constructor(username, registeredAt) {
|
|
1616
|
+
if (!username || typeof username !== 'string') {
|
|
1617
|
+
throw new Error('Username is required and must be a string');
|
|
1618
|
+
}
|
|
1619
|
+
if (!registeredAt || typeof registeredAt !== 'string') {
|
|
1620
|
+
throw new Error('RegisteredAt is required and must be a string');
|
|
1621
|
+
}
|
|
1622
|
+
this.username = username;
|
|
1623
|
+
this.registered_at = registeredAt;
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/**
|
|
1627
|
+
* Create from JSON representation
|
|
1628
|
+
* @param {Object} json - The JSON data
|
|
1629
|
+
* @returns {RegisteredBy} The registration attribution instance
|
|
1630
|
+
*/
|
|
1631
|
+
static fromJSON(json) {
|
|
1632
|
+
return new RegisteredBy(json.username, json.registered_at);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/**
|
|
1636
|
+
* Convert to JSON representation
|
|
1637
|
+
* @returns {Object} The JSON representation
|
|
1638
|
+
*/
|
|
1639
|
+
toJSON() {
|
|
1640
|
+
return {
|
|
1641
|
+
username: this.username,
|
|
1642
|
+
registered_at: this.registered_at
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// ============================================================================
|
|
1648
|
+
// CAP ARGUMENT SYSTEM
|
|
1649
|
+
// ============================================================================
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Known source keys for argument sources
|
|
1653
|
+
*/
|
|
1654
|
+
const KNOWN_SOURCE_KEYS = ['stdin', 'position', 'cli_flag'];
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* Reserved CLI flags that cannot be used
|
|
1658
|
+
*/
|
|
1659
|
+
const RESERVED_CLI_FLAGS = ['manifest', '--help', '--version', '-v', '-h'];
|
|
1660
|
+
|
|
1661
|
+
/**
|
|
1662
|
+
* Argument source - specifies how an argument can be provided
|
|
1663
|
+
*/
|
|
1664
|
+
class ArgSource {
|
|
1665
|
+
constructor() {
|
|
1666
|
+
this.stdin = null; // string (media URN) or null
|
|
1667
|
+
this.position = null; // number or null
|
|
1668
|
+
this.cli_flag = null; // string or null
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* Create an ArgSource from a JSON object
|
|
1673
|
+
* @param {Object} obj - The source object with one of: stdin, position, cli_flag
|
|
1674
|
+
* @returns {ArgSource} The ArgSource instance
|
|
1675
|
+
* @throws {Error} If unknown keys are present (RULE8)
|
|
1676
|
+
*/
|
|
1677
|
+
static fromJSON(obj) {
|
|
1678
|
+
// RULE8: Reject unknown keys
|
|
1679
|
+
for (const key of Object.keys(obj)) {
|
|
1680
|
+
if (!KNOWN_SOURCE_KEYS.includes(key)) {
|
|
1681
|
+
throw new ValidationError('InvalidCapSchema', 'unknown',
|
|
1682
|
+
{ issue: `Unknown source key: ${key}` });
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
const source = new ArgSource();
|
|
1686
|
+
if (obj.stdin !== undefined) source.stdin = obj.stdin;
|
|
1687
|
+
if (obj.position !== undefined) source.position = obj.position;
|
|
1688
|
+
if (obj.cli_flag !== undefined) source.cli_flag = obj.cli_flag;
|
|
1689
|
+
return source;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* Get the type of this source
|
|
1694
|
+
* @returns {string|null} The source type: 'stdin', 'position', 'cli_flag', or null
|
|
1695
|
+
*/
|
|
1696
|
+
getType() {
|
|
1697
|
+
if (this.stdin !== null) return 'stdin';
|
|
1698
|
+
if (this.position !== null) return 'position';
|
|
1699
|
+
if (this.cli_flag !== null) return 'cli_flag';
|
|
1700
|
+
return null;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/**
|
|
1704
|
+
* Convert to JSON representation
|
|
1705
|
+
* @returns {Object} The JSON representation
|
|
1706
|
+
*/
|
|
1707
|
+
toJSON() {
|
|
1708
|
+
if (this.stdin !== null) return { stdin: this.stdin };
|
|
1709
|
+
if (this.position !== null) return { position: this.position };
|
|
1710
|
+
if (this.cli_flag !== null) return { cli_flag: this.cli_flag };
|
|
1711
|
+
return {};
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/**
|
|
1716
|
+
* Cap argument definition - media_urn is the unique identifier
|
|
1717
|
+
*/
|
|
1718
|
+
class CapArg {
|
|
1719
|
+
/**
|
|
1720
|
+
* Create a new CapArg
|
|
1721
|
+
* @param {string} mediaUrn - The unique media URN for this argument
|
|
1722
|
+
* @param {boolean} required - Whether this argument is required
|
|
1723
|
+
* @param {Array<ArgSource>} sources - How this argument can be provided
|
|
1724
|
+
* @param {Object} options - Optional fields: arg_description, default_value, metadata
|
|
1725
|
+
*/
|
|
1726
|
+
constructor(mediaUrn, required, sources, options = {}) {
|
|
1727
|
+
this.media_urn = mediaUrn;
|
|
1728
|
+
this.required = required;
|
|
1729
|
+
this.sources = sources; // Array of ArgSource
|
|
1730
|
+
this.arg_description = options.arg_description || null;
|
|
1731
|
+
this.default_value = options.default_value !== undefined ? options.default_value : null;
|
|
1732
|
+
this.metadata = options.metadata || null;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/**
|
|
1736
|
+
* Create a CapArg from JSON
|
|
1737
|
+
* @param {Object} json - The JSON representation
|
|
1738
|
+
* @returns {CapArg} The CapArg instance
|
|
1739
|
+
*/
|
|
1740
|
+
static fromJSON(json) {
|
|
1741
|
+
const sources = (json.sources || []).map(s => ArgSource.fromJSON(s));
|
|
1742
|
+
return new CapArg(
|
|
1743
|
+
json.media_urn,
|
|
1744
|
+
json.required,
|
|
1745
|
+
sources,
|
|
1746
|
+
{
|
|
1747
|
+
arg_description: json.arg_description,
|
|
1748
|
+
default_value: json.default_value,
|
|
1749
|
+
metadata: json.metadata
|
|
1750
|
+
}
|
|
1751
|
+
);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
/**
|
|
1755
|
+
* Convert to JSON representation
|
|
1756
|
+
* @returns {Object} The JSON representation
|
|
1757
|
+
*/
|
|
1758
|
+
toJSON() {
|
|
1759
|
+
const result = {
|
|
1760
|
+
media_urn: this.media_urn,
|
|
1761
|
+
required: this.required,
|
|
1762
|
+
sources: this.sources.map(s => s.toJSON())
|
|
1763
|
+
};
|
|
1764
|
+
if (this.arg_description) result.arg_description = this.arg_description;
|
|
1765
|
+
if (this.default_value !== null && this.default_value !== undefined) {
|
|
1766
|
+
result.default_value = this.default_value;
|
|
1767
|
+
}
|
|
1768
|
+
if (this.metadata) result.metadata = this.metadata;
|
|
1769
|
+
return result;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
/**
|
|
1773
|
+
* Check if this argument has a stdin source
|
|
1774
|
+
* @returns {boolean} True if has stdin source
|
|
1775
|
+
*/
|
|
1776
|
+
hasStdinSource() {
|
|
1777
|
+
return this.sources.some(s => s.stdin !== null);
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/**
|
|
1781
|
+
* Get the stdin media URN if present
|
|
1782
|
+
* @returns {string|null} The stdin media URN or null
|
|
1783
|
+
*/
|
|
1784
|
+
getStdinMediaUrn() {
|
|
1785
|
+
const stdinSource = this.sources.find(s => s.stdin !== null);
|
|
1786
|
+
return stdinSource ? stdinSource.stdin : null;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* Check if this argument has a position source
|
|
1791
|
+
* @returns {boolean} True if has position source
|
|
1792
|
+
*/
|
|
1793
|
+
hasPositionSource() {
|
|
1794
|
+
return this.sources.some(s => s.position !== null);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* Get the position if present
|
|
1799
|
+
* @returns {number|null} The position or null
|
|
1800
|
+
*/
|
|
1801
|
+
getPosition() {
|
|
1802
|
+
const posSource = this.sources.find(s => s.position !== null);
|
|
1803
|
+
return posSource ? posSource.position : null;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* Check if this argument has a cli_flag source
|
|
1808
|
+
* @returns {boolean} True if has cli_flag source
|
|
1809
|
+
*/
|
|
1810
|
+
hasCliFlagSource() {
|
|
1811
|
+
return this.sources.some(s => s.cli_flag !== null);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* Get the cli_flag if present
|
|
1816
|
+
* @returns {string|null} The cli_flag or null
|
|
1817
|
+
*/
|
|
1818
|
+
getCliFlag() {
|
|
1819
|
+
const flagSource = this.sources.find(s => s.cli_flag !== null);
|
|
1820
|
+
return flagSource ? flagSource.cli_flag : null;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/**
|
|
1825
|
+
* Capability definition class
|
|
1826
|
+
*/
|
|
1827
|
+
class Cap {
|
|
1828
|
+
/**
|
|
1829
|
+
* Create a new capability
|
|
1830
|
+
* @param {CapUrn} urn - The capability URN
|
|
1831
|
+
* @param {string} title - The human-readable title (required)
|
|
1832
|
+
* @param {string} command - The command string
|
|
1833
|
+
* @param {string|null} capDescription - Optional description
|
|
1834
|
+
* @param {Object} metadata - Optional metadata object
|
|
1835
|
+
* @param {Object|null} metadataJson - Optional arbitrary metadata as JSON object
|
|
1836
|
+
*/
|
|
1837
|
+
constructor(urn, title, command, capDescription = null, metadata = {}, metadataJson = null) {
|
|
1838
|
+
if (!(urn instanceof CapUrn)) {
|
|
1839
|
+
throw new Error('URN must be a CapUrn instance');
|
|
1840
|
+
}
|
|
1841
|
+
if (!title || typeof title !== 'string') {
|
|
1842
|
+
throw new Error('Title is required and must be a string');
|
|
1843
|
+
}
|
|
1844
|
+
if (!command || typeof command !== 'string') {
|
|
1845
|
+
throw new Error('Command is required and must be a string');
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
this.urn = urn;
|
|
1849
|
+
this.title = title;
|
|
1850
|
+
this.command = command;
|
|
1851
|
+
this.cap_description = capDescription;
|
|
1852
|
+
this.metadata = metadata || {};
|
|
1853
|
+
this.mediaSpecs = []; // Media spec definitions array
|
|
1854
|
+
this.args = []; // Array of CapArg - unified argument format
|
|
1855
|
+
this.output = null;
|
|
1856
|
+
this.metadata_json = metadataJson;
|
|
1857
|
+
this.registered_by = null; // Registration attribution
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/**
|
|
1861
|
+
* Get the media type expected for stdin (derived from args with stdin source)
|
|
1862
|
+
* @returns {string|null} The media URN for stdin, or null if cap doesn't accept stdin
|
|
1863
|
+
*/
|
|
1864
|
+
stdinMediaType() {
|
|
1865
|
+
return this.getStdinMediaUrn();
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* Get the stdin media URN from args
|
|
1870
|
+
* @returns {string|null} The stdin media URN or null if no arg accepts stdin
|
|
1871
|
+
*/
|
|
1872
|
+
getStdinMediaUrn() {
|
|
1873
|
+
for (const arg of this.args) {
|
|
1874
|
+
const stdinUrn = arg.getStdinMediaUrn();
|
|
1875
|
+
if (stdinUrn) return stdinUrn;
|
|
1876
|
+
}
|
|
1877
|
+
return null;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
/**
|
|
1881
|
+
* Check if this cap accepts stdin input
|
|
1882
|
+
* @returns {boolean} True if any arg has a stdin source
|
|
1883
|
+
*/
|
|
1884
|
+
acceptsStdin() {
|
|
1885
|
+
return this.getStdinMediaUrn() !== null;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* Resolve a media URN to a MediaSpec using this cap's mediaSpecs table
|
|
1890
|
+
* @param {string} mediaUrn - The media URN (e.g., "media:string")
|
|
1891
|
+
* @returns {MediaSpec} The resolved MediaSpec
|
|
1892
|
+
* @throws {MediaSpecError} If media URN cannot be resolved
|
|
1893
|
+
*/
|
|
1894
|
+
resolveMediaUrn(mediaUrn) {
|
|
1895
|
+
return resolveMediaUrn(mediaUrn, this.mediaSpecs);
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* Get the URN as a string
|
|
1900
|
+
* @returns {string} The URN string representation
|
|
1901
|
+
*/
|
|
1902
|
+
urnString() {
|
|
1903
|
+
return this.urn.toString();
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
/**
|
|
1907
|
+
* Check if this capability accepts a request string
|
|
1908
|
+
* @param {string} request - The request string
|
|
1909
|
+
* @returns {boolean} Whether this capability accepts the request
|
|
1910
|
+
*/
|
|
1911
|
+
acceptsRequest(request) {
|
|
1912
|
+
const requestUrn = CapUrn.fromString(request);
|
|
1913
|
+
return this.urn.accepts(requestUrn);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/**
|
|
1917
|
+
* Check if this capability is more specific than another
|
|
1918
|
+
* @param {Cap} other - The other capability
|
|
1919
|
+
* @returns {boolean} Whether this capability is more specific
|
|
1920
|
+
*/
|
|
1921
|
+
isMoreSpecificThan(other) {
|
|
1922
|
+
if (!other) return true;
|
|
1923
|
+
return this.urn.isMoreSpecificThan(other.urn);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* Get metadata value by key
|
|
1928
|
+
* @param {string} key - The metadata key
|
|
1929
|
+
* @returns {string|undefined} The metadata value
|
|
1930
|
+
*/
|
|
1931
|
+
getMetadata(key) {
|
|
1932
|
+
return this.metadata[key];
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
/**
|
|
1936
|
+
* Set metadata value
|
|
1937
|
+
* @param {string} key - The metadata key
|
|
1938
|
+
* @param {string} value - The metadata value
|
|
1939
|
+
*/
|
|
1940
|
+
setMetadata(key, value) {
|
|
1941
|
+
this.metadata[key] = value;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/**
|
|
1945
|
+
* Remove metadata value
|
|
1946
|
+
* @param {string} key - The metadata key to remove
|
|
1947
|
+
* @returns {boolean} Whether the key existed
|
|
1948
|
+
*/
|
|
1949
|
+
removeMetadata(key) {
|
|
1950
|
+
const existed = this.metadata.hasOwnProperty(key);
|
|
1951
|
+
delete this.metadata[key];
|
|
1952
|
+
return existed;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* Check if this capability has specific metadata
|
|
1957
|
+
* @param {string} key - The metadata key
|
|
1958
|
+
* @returns {boolean} Whether the metadata exists
|
|
1959
|
+
*/
|
|
1960
|
+
hasMetadata(key) {
|
|
1961
|
+
return this.metadata.hasOwnProperty(key);
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/**
|
|
1965
|
+
* Add an argument
|
|
1966
|
+
* @param {CapArg} arg - The argument to add
|
|
1967
|
+
*/
|
|
1968
|
+
addArg(arg) {
|
|
1969
|
+
this.args.push(arg);
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* Get all required arguments
|
|
1974
|
+
* @returns {Array<CapArg>} Required arguments
|
|
1975
|
+
*/
|
|
1976
|
+
getRequiredArgs() {
|
|
1977
|
+
return this.args.filter(arg => arg.required);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/**
|
|
1981
|
+
* Get all optional arguments
|
|
1982
|
+
* @returns {Array<CapArg>} Optional arguments
|
|
1983
|
+
*/
|
|
1984
|
+
getOptionalArgs() {
|
|
1985
|
+
return this.args.filter(arg => !arg.required);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* Find argument by media_urn
|
|
1990
|
+
* @param {string} mediaUrn - The media URN to search for
|
|
1991
|
+
* @returns {CapArg|null} The argument or null
|
|
1992
|
+
*/
|
|
1993
|
+
findArgByMediaUrn(mediaUrn) {
|
|
1994
|
+
return this.args.find(arg => arg.media_urn === mediaUrn) || null;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/**
|
|
1998
|
+
* Set the output definition
|
|
1999
|
+
* @param {Object} output - The output definition
|
|
2000
|
+
*/
|
|
2001
|
+
setOutput(output) {
|
|
2002
|
+
this.output = output;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/**
|
|
2006
|
+
* Get metadata JSON
|
|
2007
|
+
* @returns {Object|null} The metadata JSON
|
|
2008
|
+
*/
|
|
2009
|
+
getMetadataJSON() {
|
|
2010
|
+
return this.metadata_json;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/**
|
|
2014
|
+
* Set metadata JSON
|
|
2015
|
+
* @param {Object} metadata - The metadata JSON object
|
|
2016
|
+
*/
|
|
2017
|
+
setMetadataJSON(metadata) {
|
|
2018
|
+
this.metadata_json = metadata;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* Clear metadata JSON
|
|
2023
|
+
*/
|
|
2024
|
+
clearMetadataJSON() {
|
|
2025
|
+
this.metadata_json = null;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/**
|
|
2029
|
+
* Check if this capability equals another
|
|
2030
|
+
* Compares all fields to match Rust reference implementation
|
|
2031
|
+
* @param {Cap} other - The other capability
|
|
2032
|
+
* @returns {boolean} Whether the capabilities are equal
|
|
2033
|
+
*/
|
|
2034
|
+
equals(other) {
|
|
2035
|
+
if (!other || !(other instanceof Cap)) {
|
|
2036
|
+
return false;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
return this.urn.equals(other.urn) &&
|
|
2040
|
+
this.title === other.title &&
|
|
2041
|
+
this.command === other.command &&
|
|
2042
|
+
this.cap_description === other.cap_description &&
|
|
2043
|
+
JSON.stringify(this.metadata) === JSON.stringify(other.metadata) &&
|
|
2044
|
+
JSON.stringify(this.mediaSpecs) === JSON.stringify(other.mediaSpecs) &&
|
|
2045
|
+
JSON.stringify(this.args.map(a => a.toJSON())) === JSON.stringify(other.args.map(a => a.toJSON())) &&
|
|
2046
|
+
JSON.stringify(this.output) === JSON.stringify(other.output) &&
|
|
2047
|
+
JSON.stringify(this.metadata_json) === JSON.stringify(other.metadata_json) &&
|
|
2048
|
+
JSON.stringify(this.registered_by) === JSON.stringify(other.registered_by);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
/**
|
|
2052
|
+
* Convert to JSON representation
|
|
2053
|
+
* @returns {Object} JSON representation
|
|
2054
|
+
*/
|
|
2055
|
+
toJSON() {
|
|
2056
|
+
const result = {
|
|
2057
|
+
urn: this.urn.toString(),
|
|
2058
|
+
title: this.title,
|
|
2059
|
+
command: this.command,
|
|
2060
|
+
cap_description: this.cap_description,
|
|
2061
|
+
metadata: this.metadata,
|
|
2062
|
+
media_specs: this.mediaSpecs,
|
|
2063
|
+
args: this.args.map(a => a.toJSON()),
|
|
2064
|
+
output: this.output
|
|
2065
|
+
};
|
|
2066
|
+
|
|
2067
|
+
if (this.metadata_json !== null && this.metadata_json !== undefined) {
|
|
2068
|
+
result.metadata_json = this.metadata_json;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
return result;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/**
|
|
2075
|
+
* Create a capability from JSON representation
|
|
2076
|
+
* @param {Object} json - The JSON data
|
|
2077
|
+
* @returns {Cap} The capability instance
|
|
2078
|
+
*/
|
|
2079
|
+
static fromJSON(json) {
|
|
2080
|
+
// URN must be a string in canonical format
|
|
2081
|
+
if (typeof json.urn !== 'string') {
|
|
2082
|
+
throw new Error("URN must be a string in canonical format (e.g., 'cap:in=\"media:...\";op=...;out=\"media:...\"')");
|
|
2083
|
+
}
|
|
2084
|
+
const urn = CapUrn.fromString(json.urn);
|
|
2085
|
+
|
|
2086
|
+
const cap = new Cap(urn, json.title, json.command, json.cap_description, json.metadata, json.metadata_json);
|
|
2087
|
+
cap.mediaSpecs = json.media_specs || json.mediaSpecs || [];
|
|
2088
|
+
// Parse args (new format)
|
|
2089
|
+
if (json.args && Array.isArray(json.args)) {
|
|
2090
|
+
cap.args = json.args.map(a => CapArg.fromJSON(a));
|
|
2091
|
+
} else {
|
|
2092
|
+
cap.args = [];
|
|
2093
|
+
}
|
|
2094
|
+
cap.output = json.output;
|
|
2095
|
+
cap.registered_by = json.registered_by ? RegisteredBy.fromJSON(json.registered_by) : null;
|
|
2096
|
+
return cap;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/**
|
|
2100
|
+
* Get the registration attribution
|
|
2101
|
+
* @returns {RegisteredBy|null} The registration attribution or null
|
|
2102
|
+
*/
|
|
2103
|
+
getRegisteredBy() {
|
|
2104
|
+
return this.registered_by;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
/**
|
|
2108
|
+
* Set the registration attribution
|
|
2109
|
+
* @param {RegisteredBy} registeredBy - The registration attribution
|
|
2110
|
+
*/
|
|
2111
|
+
setRegisteredBy(registeredBy) {
|
|
2112
|
+
this.registered_by = registeredBy;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* Clear the registration attribution
|
|
2117
|
+
*/
|
|
2118
|
+
clearRegisteredBy() {
|
|
2119
|
+
this.registered_by = null;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/**
|
|
2124
|
+
* Helper functions for creating capabilities
|
|
2125
|
+
*/
|
|
2126
|
+
function createCap(urn, title, command) {
|
|
2127
|
+
return new Cap(urn, title, command);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function createCapWithDescription(urn, title, command, description) {
|
|
2131
|
+
return new Cap(urn, title, command, description);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
function createCapWithMetadata(urn, title, command, metadata) {
|
|
2135
|
+
return new Cap(urn, title, command, null, metadata);
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
function createCapWithDescriptionAndMetadata(urn, title, command, description, metadata) {
|
|
2139
|
+
return new Cap(urn, title, command, description, metadata);
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
// ============================================================================
|
|
2143
|
+
// VALIDATION SYSTEM
|
|
2144
|
+
// ============================================================================
|
|
2145
|
+
|
|
2146
|
+
/**
|
|
2147
|
+
* Validation error types with descriptive failure information
|
|
2148
|
+
*/
|
|
2149
|
+
class ValidationError extends Error {
|
|
2150
|
+
constructor(type, capUrn, details = {}) {
|
|
2151
|
+
const message = ValidationError.formatMessage(type, capUrn, details);
|
|
2152
|
+
super(message);
|
|
2153
|
+
this.name = 'ValidationError';
|
|
2154
|
+
this.type = type;
|
|
2155
|
+
this.capUrn = capUrn;
|
|
2156
|
+
this.details = details;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
static formatMessage(type, capUrn, details) {
|
|
2160
|
+
switch (type) {
|
|
2161
|
+
case 'UnknownCap':
|
|
2162
|
+
return `Unknown cap '${capUrn}' - cap not registered or advertised`;
|
|
2163
|
+
case 'MissingRequiredArgument':
|
|
2164
|
+
return `Cap '${capUrn}' requires argument '${details.argumentName}' but it was not provided`;
|
|
2165
|
+
case 'UnknownArgument':
|
|
2166
|
+
return `Cap '${capUrn}' does not accept argument '${details.argumentName}' - check capability definition for valid arguments`;
|
|
2167
|
+
case 'InvalidArgumentType':
|
|
2168
|
+
if (details.expectedMediaSpec) {
|
|
2169
|
+
const errors = details.schemaErrors ? details.schemaErrors.join(', ') : 'validation failed';
|
|
2170
|
+
return `Cap '${capUrn}' argument '${details.argumentName}' expects media_spec '${details.expectedMediaSpec}' but ${errors} for value: ${JSON.stringify(details.actualValue)}`;
|
|
2171
|
+
}
|
|
2172
|
+
return `Cap '${capUrn}' argument '${details.argumentName}' expects type '${details.expectedType}' but received '${details.actualType}' with value: ${JSON.stringify(details.actualValue)}`;
|
|
2173
|
+
case 'MediaValidationFailed':
|
|
2174
|
+
return `Cap '${capUrn}' argument '${details.argumentName}' failed validation rule '${details.validationRule}' with value: ${JSON.stringify(details.actualValue)}`;
|
|
2175
|
+
case 'MediaSpecValidationFailed':
|
|
2176
|
+
return `Cap '${capUrn}' argument '${details.argumentName}' failed media spec '${details.mediaUrn}' validation rule '${details.validationRule}' with value: ${JSON.stringify(details.actualValue)}`;
|
|
2177
|
+
case 'InvalidOutputType':
|
|
2178
|
+
if (details.expectedMediaSpec) {
|
|
2179
|
+
const errors = details.schemaErrors ? details.schemaErrors.join(', ') : 'validation failed';
|
|
2180
|
+
return `Cap '${capUrn}' output expects media_spec '${details.expectedMediaSpec}' but ${errors} for value: ${JSON.stringify(details.actualValue)}`;
|
|
2181
|
+
}
|
|
2182
|
+
return `Cap '${capUrn}' output expects type '${details.expectedType}' but received '${details.actualType}' with value: ${JSON.stringify(details.actualValue)}`;
|
|
2183
|
+
case 'OutputValidationFailed':
|
|
2184
|
+
return `Cap '${capUrn}' output failed validation rule '${details.validationRule}' with value: ${JSON.stringify(details.actualValue)}`;
|
|
2185
|
+
case 'OutputMediaSpecValidationFailed':
|
|
2186
|
+
return `Cap '${capUrn}' output failed media spec '${details.mediaUrn}' validation rule '${details.validationRule}' with value: ${JSON.stringify(details.actualValue)}`;
|
|
2187
|
+
case 'InvalidCapSchema':
|
|
2188
|
+
return `Cap '${capUrn}' has invalid schema: ${details.issue}`;
|
|
2189
|
+
case 'TooManyArguments':
|
|
2190
|
+
return `Cap '${capUrn}' expects at most ${details.maxExpected} arguments but received ${details.actualCount}`;
|
|
2191
|
+
case 'JsonParseError':
|
|
2192
|
+
return `Cap '${capUrn}' JSON parsing failed: ${details.error}`;
|
|
2193
|
+
case 'SchemaValidationFailed':
|
|
2194
|
+
return `Cap '${capUrn}' schema validation failed for '${details.fieldName}': ${details.schemaErrors}`;
|
|
2195
|
+
default:
|
|
2196
|
+
return `Cap validation error: ${type}`;
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
/**
|
|
2202
|
+
* Validate cap args against the 12 validation rules
|
|
2203
|
+
* @param {Cap} cap - The capability to validate
|
|
2204
|
+
* @throws {ValidationError} If any validation rule is violated
|
|
2205
|
+
*/
|
|
2206
|
+
function validateCapArgs(cap) {
|
|
2207
|
+
const capUrn = cap.urnString();
|
|
2208
|
+
const args = cap.args;
|
|
2209
|
+
|
|
2210
|
+
// RULE1: No duplicate media_urns (using string comparison for now)
|
|
2211
|
+
const mediaUrns = new Set();
|
|
2212
|
+
for (const arg of args) {
|
|
2213
|
+
if (mediaUrns.has(arg.media_urn)) {
|
|
2214
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2215
|
+
issue: `RULE1: Duplicate media_urn '${arg.media_urn}'`
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
mediaUrns.add(arg.media_urn);
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// RULE2: sources must not be null or empty
|
|
2222
|
+
for (const arg of args) {
|
|
2223
|
+
if (!arg.sources || arg.sources.length === 0) {
|
|
2224
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2225
|
+
issue: `RULE2: Argument '${arg.media_urn}' has empty sources`
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// Collect stdin URNs, positions, and cli_flags for cross-arg validation
|
|
2231
|
+
const stdinUrns = [];
|
|
2232
|
+
const positions = [];
|
|
2233
|
+
const cliFlags = [];
|
|
2234
|
+
|
|
2235
|
+
for (const arg of args) {
|
|
2236
|
+
const sourceTypes = new Set();
|
|
2237
|
+
let hasPosition = false;
|
|
2238
|
+
let hasCliFlag = false;
|
|
2239
|
+
|
|
2240
|
+
for (const source of arg.sources) {
|
|
2241
|
+
const sourceType = source.getType();
|
|
2242
|
+
|
|
2243
|
+
// RULE4: No arg may specify same source type more than once
|
|
2244
|
+
if (sourceTypes.has(sourceType)) {
|
|
2245
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2246
|
+
issue: `RULE4: Argument '${arg.media_urn}' has duplicate source type '${sourceType}'`
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
sourceTypes.add(sourceType);
|
|
2250
|
+
|
|
2251
|
+
if (source.stdin !== null) {
|
|
2252
|
+
stdinUrns.push(source.stdin);
|
|
2253
|
+
}
|
|
2254
|
+
if (source.position !== null) {
|
|
2255
|
+
hasPosition = true;
|
|
2256
|
+
positions.push({ position: source.position, mediaUrn: arg.media_urn });
|
|
2257
|
+
}
|
|
2258
|
+
if (source.cli_flag !== null) {
|
|
2259
|
+
hasCliFlag = true;
|
|
2260
|
+
cliFlags.push({ flag: source.cli_flag, mediaUrn: arg.media_urn });
|
|
2261
|
+
|
|
2262
|
+
// RULE10: Reserved cli_flags
|
|
2263
|
+
if (RESERVED_CLI_FLAGS.includes(source.cli_flag)) {
|
|
2264
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2265
|
+
issue: `RULE10: Argument '${arg.media_urn}' uses reserved cli_flag '${source.cli_flag}'`
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// RULE7: No arg may have both position and cli_flag
|
|
2272
|
+
if (hasPosition && hasCliFlag) {
|
|
2273
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2274
|
+
issue: `RULE7: Argument '${arg.media_urn}' has both position and cli_flag sources`
|
|
2275
|
+
});
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
// RULE3: If multiple args have stdin source, stdin media_urns must be identical
|
|
2280
|
+
if (stdinUrns.length > 1) {
|
|
2281
|
+
const firstStdin = stdinUrns[0];
|
|
2282
|
+
for (let i = 1; i < stdinUrns.length; i++) {
|
|
2283
|
+
if (stdinUrns[i] !== firstStdin) {
|
|
2284
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2285
|
+
issue: `RULE3: Multiple args have different stdin media_urns: '${firstStdin}' vs '${stdinUrns[i]}'`
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
// RULE5: No two args may have same position
|
|
2292
|
+
const positionSet = new Set();
|
|
2293
|
+
for (const { position, mediaUrn } of positions) {
|
|
2294
|
+
if (positionSet.has(position)) {
|
|
2295
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2296
|
+
issue: `RULE5: Duplicate position ${position} in argument '${mediaUrn}'`
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
positionSet.add(position);
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// RULE6: Positions must be sequential (0-based, no gaps when aggregated)
|
|
2303
|
+
if (positions.length > 0) {
|
|
2304
|
+
const sortedPositions = [...positions].sort((a, b) => a.position - b.position);
|
|
2305
|
+
for (let i = 0; i < sortedPositions.length; i++) {
|
|
2306
|
+
if (sortedPositions[i].position !== i) {
|
|
2307
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2308
|
+
issue: `RULE6: Position gap - expected ${i} but found ${sortedPositions[i].position}`
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// RULE9: No two args may have same cli_flag
|
|
2315
|
+
const flagSet = new Set();
|
|
2316
|
+
for (const { flag, mediaUrn } of cliFlags) {
|
|
2317
|
+
if (flagSet.has(flag)) {
|
|
2318
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2319
|
+
issue: `RULE9: Duplicate cli_flag '${flag}' in argument '${mediaUrn}'`
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
flagSet.add(flag);
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
// RULE8: No unknown keys in source objects - this is handled in ArgSource.fromJSON()
|
|
2326
|
+
// RULE11: cli_flag used verbatim as specified - enforced by design
|
|
2327
|
+
// RULE12: media_urn is the key, no name field - enforced by CapArg structure
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
/**
|
|
2331
|
+
* Input argument validator
|
|
2332
|
+
*/
|
|
2333
|
+
class InputValidator {
|
|
2334
|
+
/**
|
|
2335
|
+
* Validate positional arguments against cap input schema
|
|
2336
|
+
*/
|
|
2337
|
+
static validatePositionalArguments(cap, argValues) {
|
|
2338
|
+
const capUrn = cap.urnString();
|
|
2339
|
+
const args = cap.arguments;
|
|
2340
|
+
|
|
2341
|
+
// Check if too many arguments provided
|
|
2342
|
+
const maxArgs = args.required.length + args.optional.length;
|
|
2343
|
+
if (argValues.length > maxArgs) {
|
|
2344
|
+
throw new ValidationError('TooManyArguments', capUrn, {
|
|
2345
|
+
maxExpected: maxArgs,
|
|
2346
|
+
actualCount: argValues.length
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// Validate required arguments
|
|
2351
|
+
for (let i = 0; i < args.required.length; i++) {
|
|
2352
|
+
if (i >= argValues.length) {
|
|
2353
|
+
throw new ValidationError('MissingRequiredArgument', capUrn, {
|
|
2354
|
+
argumentName: args.required[i].name
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
InputValidator.validateSingleArgument(cap, args.required[i], argValues[i]);
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// Validate optional arguments if provided
|
|
2362
|
+
const requiredCount = args.required.length;
|
|
2363
|
+
for (let i = 0; i < args.optional.length; i++) {
|
|
2364
|
+
const argIndex = requiredCount + i;
|
|
2365
|
+
if (argIndex < argValues.length) {
|
|
2366
|
+
InputValidator.validateSingleArgument(cap, args.optional[i], argValues[argIndex]);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
/**
|
|
2372
|
+
* Validate named arguments against cap input schema
|
|
2373
|
+
*/
|
|
2374
|
+
static validateNamedArguments(cap, namedArgs) {
|
|
2375
|
+
const capUrn = cap.urnString();
|
|
2376
|
+
const args = cap.arguments;
|
|
2377
|
+
|
|
2378
|
+
// Extract named argument values into a map
|
|
2379
|
+
const providedArgs = new Map();
|
|
2380
|
+
for (const arg of namedArgs) {
|
|
2381
|
+
if (typeof arg === 'object' && arg.name && arg.hasOwnProperty('value')) {
|
|
2382
|
+
providedArgs.set(arg.name, arg.value);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
// Check that all required arguments are provided as named arguments
|
|
2387
|
+
for (const reqArg of args.required) {
|
|
2388
|
+
if (!providedArgs.has(reqArg.name)) {
|
|
2389
|
+
throw new ValidationError('MissingRequiredArgument', capUrn, {
|
|
2390
|
+
argumentName: `${reqArg.name} (expected as named argument)`
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
// Validate the provided argument value
|
|
2395
|
+
const providedValue = providedArgs.get(reqArg.name);
|
|
2396
|
+
InputValidator.validateSingleArgument(cap, reqArg, providedValue);
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
// Validate optional arguments if provided
|
|
2400
|
+
for (const optArg of args.optional) {
|
|
2401
|
+
if (providedArgs.has(optArg.name)) {
|
|
2402
|
+
const providedValue = providedArgs.get(optArg.name);
|
|
2403
|
+
InputValidator.validateSingleArgument(cap, optArg, providedValue);
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
// Check for unknown arguments
|
|
2408
|
+
const knownArgNames = new Set([
|
|
2409
|
+
...args.required.map(arg => arg.name),
|
|
2410
|
+
...args.optional.map(arg => arg.name)
|
|
2411
|
+
]);
|
|
2412
|
+
|
|
2413
|
+
for (const providedName of providedArgs.keys()) {
|
|
2414
|
+
if (!knownArgNames.has(providedName)) {
|
|
2415
|
+
throw new ValidationError('UnknownArgument', capUrn, {
|
|
2416
|
+
argumentName: providedName
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
/**
|
|
2423
|
+
* Validate a single argument against its definition
|
|
2424
|
+
* Two-pass validation:
|
|
2425
|
+
* 1. Type validation + media spec validation rules (inherent to semantic type)
|
|
2426
|
+
*/
|
|
2427
|
+
static validateSingleArgument(cap, argDef, value) {
|
|
2428
|
+
// Type validation - returns the resolved MediaSpec
|
|
2429
|
+
const mediaSpec = InputValidator.validateArgumentType(cap, argDef, value);
|
|
2430
|
+
|
|
2431
|
+
// Media spec validation rules (inherent to the semantic type)
|
|
2432
|
+
if (mediaSpec && mediaSpec.validation) {
|
|
2433
|
+
InputValidator.validateMediaSpecRules(cap, argDef, mediaSpec, value);
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
/**
|
|
2438
|
+
* Validate argument type using MediaSpec
|
|
2439
|
+
* Resolves spec ID to MediaSpec before validation
|
|
2440
|
+
* @returns {MediaSpec|null} The resolved MediaSpec
|
|
2441
|
+
*/
|
|
2442
|
+
static validateArgumentType(cap, argDef, value) {
|
|
2443
|
+
const capUrn = cap.urnString();
|
|
2444
|
+
|
|
2445
|
+
// Get mediaUrn field (now contains a media URN)
|
|
2446
|
+
const mediaUrn = argDef.mediaUrn || argDef.media_urn;
|
|
2447
|
+
if (!mediaUrn) {
|
|
2448
|
+
// No media_urn - skip validation
|
|
2449
|
+
return null;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
// Resolve media URN to MediaSpec - FAIL HARD if unresolvable
|
|
2453
|
+
let mediaSpec;
|
|
2454
|
+
try {
|
|
2455
|
+
mediaSpec = cap.resolveMediaUrn(mediaUrn);
|
|
2456
|
+
} catch (e) {
|
|
2457
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2458
|
+
issue: `Cannot resolve media URN '${mediaUrn}' for argument '${argDef.name}': ${e.message}`
|
|
2459
|
+
});
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
// For binary media types, expect base64-encoded string
|
|
2463
|
+
if (mediaSpec.isBinary()) {
|
|
2464
|
+
if (typeof value !== 'string') {
|
|
2465
|
+
throw new ValidationError('InvalidArgumentType', capUrn, {
|
|
2466
|
+
argumentName: argDef.name,
|
|
2467
|
+
expectedMediaSpec: mediaUrn,
|
|
2468
|
+
actualValue: value,
|
|
2469
|
+
schemaErrors: ['Expected base64-encoded string for binary type']
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
return mediaSpec;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
// If the resolved media spec has a local schema, validate against it
|
|
2476
|
+
if (mediaSpec.schema) {
|
|
2477
|
+
// TODO: Full JSON Schema validation would require a JSON Schema library
|
|
2478
|
+
// For now, skip local schema validation
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
// For types with profile, validate against profile
|
|
2482
|
+
if (mediaSpec.profile) {
|
|
2483
|
+
const valid = InputValidator.validateAgainstProfile(mediaSpec.profile, value);
|
|
2484
|
+
if (!valid) {
|
|
2485
|
+
throw new ValidationError('InvalidArgumentType', capUrn, {
|
|
2486
|
+
argumentName: argDef.name,
|
|
2487
|
+
expectedMediaSpec: mediaUrn,
|
|
2488
|
+
actualValue: value,
|
|
2489
|
+
schemaErrors: [`Value does not match profile schema`]
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
return mediaSpec;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
/**
|
|
2498
|
+
* Validate value against media spec's inherent validation rules (first pass)
|
|
2499
|
+
* @param {Cap} cap - The capability
|
|
2500
|
+
* @param {Object} argDef - The argument definition
|
|
2501
|
+
* @param {MediaSpec} mediaSpec - The resolved media spec
|
|
2502
|
+
* @param {*} value - The value to validate
|
|
2503
|
+
*/
|
|
2504
|
+
static validateMediaSpecRules(cap, argDef, mediaSpec, value) {
|
|
2505
|
+
const capUrn = cap.urnString();
|
|
2506
|
+
const validation = mediaSpec.validation;
|
|
2507
|
+
const mediaUrn = mediaSpec.mediaUrn;
|
|
2508
|
+
|
|
2509
|
+
// Min/max validation for numbers
|
|
2510
|
+
if (typeof value === 'number') {
|
|
2511
|
+
if (validation.min !== undefined && value < validation.min) {
|
|
2512
|
+
throw new ValidationError('MediaSpecValidationFailed', capUrn, {
|
|
2513
|
+
argumentName: argDef.name,
|
|
2514
|
+
mediaUrn: mediaUrn,
|
|
2515
|
+
validationRule: `min value ${validation.min}`,
|
|
2516
|
+
actualValue: value
|
|
2517
|
+
});
|
|
2518
|
+
}
|
|
2519
|
+
if (validation.max !== undefined && value > validation.max) {
|
|
2520
|
+
throw new ValidationError('MediaSpecValidationFailed', capUrn, {
|
|
2521
|
+
argumentName: argDef.name,
|
|
2522
|
+
mediaUrn: mediaUrn,
|
|
2523
|
+
validationRule: `max value ${validation.max}`,
|
|
2524
|
+
actualValue: value
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
// Length validation for strings and arrays
|
|
2530
|
+
if (typeof value === 'string' || Array.isArray(value)) {
|
|
2531
|
+
const length = value.length;
|
|
2532
|
+
if (validation.min_length !== undefined && length < validation.min_length) {
|
|
2533
|
+
throw new ValidationError('MediaSpecValidationFailed', capUrn, {
|
|
2534
|
+
argumentName: argDef.name,
|
|
2535
|
+
mediaUrn: mediaUrn,
|
|
2536
|
+
validationRule: `min length ${validation.min_length}`,
|
|
2537
|
+
actualValue: value
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
if (validation.max_length !== undefined && length > validation.max_length) {
|
|
2541
|
+
throw new ValidationError('MediaSpecValidationFailed', capUrn, {
|
|
2542
|
+
argumentName: argDef.name,
|
|
2543
|
+
mediaUrn: mediaUrn,
|
|
2544
|
+
validationRule: `max length ${validation.max_length}`,
|
|
2545
|
+
actualValue: value
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
// Pattern validation for strings
|
|
2551
|
+
if (typeof value === 'string' && validation.pattern) {
|
|
2552
|
+
const regex = new RegExp(validation.pattern);
|
|
2553
|
+
if (!regex.test(value)) {
|
|
2554
|
+
throw new ValidationError('MediaSpecValidationFailed', capUrn, {
|
|
2555
|
+
argumentName: argDef.name,
|
|
2556
|
+
mediaUrn: mediaUrn,
|
|
2557
|
+
validationRule: `pattern ${validation.pattern}`,
|
|
2558
|
+
actualValue: value
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
// Allowed values validation
|
|
2564
|
+
if (validation.allowed_values && Array.isArray(validation.allowed_values)) {
|
|
2565
|
+
if (!validation.allowed_values.includes(value)) {
|
|
2566
|
+
throw new ValidationError('MediaSpecValidationFailed', capUrn, {
|
|
2567
|
+
argumentName: argDef.name,
|
|
2568
|
+
mediaUrn: mediaUrn,
|
|
2569
|
+
validationRule: `allowed values [${validation.allowed_values.join(', ')}]`,
|
|
2570
|
+
actualValue: value
|
|
2571
|
+
});
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
/**
|
|
2577
|
+
* Basic validation against common profile schemas
|
|
2578
|
+
* @param {string} profile - Profile URL
|
|
2579
|
+
* @param {*} value - Value to validate
|
|
2580
|
+
* @returns {boolean} True if valid
|
|
2581
|
+
*/
|
|
2582
|
+
static validateAgainstProfile(profile, value) {
|
|
2583
|
+
// Match against standard capdag.com schemas (both /schema/ and /schemas/ for compatibility)
|
|
2584
|
+
if (profile.includes('/schema/str') || profile.includes('/schemas/str')) {
|
|
2585
|
+
return typeof value === 'string';
|
|
2586
|
+
}
|
|
2587
|
+
if (profile.includes('/schema/int') || profile.includes('/schemas/int')) {
|
|
2588
|
+
return Number.isInteger(value);
|
|
2589
|
+
}
|
|
2590
|
+
if (profile.includes('/schema/num') || profile.includes('/schemas/num')) {
|
|
2591
|
+
return typeof value === 'number' && !isNaN(value);
|
|
2592
|
+
}
|
|
2593
|
+
if (profile.includes('/schema/bool') || profile.includes('/schemas/bool')) {
|
|
2594
|
+
return typeof value === 'boolean';
|
|
2595
|
+
}
|
|
2596
|
+
if (profile.includes('/schema/obj') || profile.includes('/schemas/obj')) {
|
|
2597
|
+
// Check obj before obj-array
|
|
2598
|
+
if (profile.includes('-array')) {
|
|
2599
|
+
return Array.isArray(value) && value.every(v => typeof v === 'object' && v !== null && !Array.isArray(v));
|
|
2600
|
+
}
|
|
2601
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
2602
|
+
}
|
|
2603
|
+
if (profile.includes('/schema/str-array') || profile.includes('/schemas/str-array')) {
|
|
2604
|
+
return Array.isArray(value) && value.every(v => typeof v === 'string');
|
|
2605
|
+
}
|
|
2606
|
+
if (profile.includes('/schema/int-array') || profile.includes('/schemas/int-array')) {
|
|
2607
|
+
return Array.isArray(value) && value.every(v => Number.isInteger(v));
|
|
2608
|
+
}
|
|
2609
|
+
if (profile.includes('/schema/num-array') || profile.includes('/schemas/num-array')) {
|
|
2610
|
+
return Array.isArray(value) && value.every(v => typeof v === 'number');
|
|
2611
|
+
}
|
|
2612
|
+
if (profile.includes('/schema/bool-array') || profile.includes('/schemas/bool-array')) {
|
|
2613
|
+
return Array.isArray(value) && value.every(v => typeof v === 'boolean');
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
// Unknown profile - allow any JSON value
|
|
2617
|
+
return true;
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
/**
|
|
2621
|
+
* Get JSON type name for a value
|
|
2622
|
+
*/
|
|
2623
|
+
static getJsonTypeName(value) {
|
|
2624
|
+
if (value === null) return 'null';
|
|
2625
|
+
if (Array.isArray(value)) return 'array';
|
|
2626
|
+
if (typeof value === 'object') return 'object';
|
|
2627
|
+
if (Number.isInteger(value)) return 'integer';
|
|
2628
|
+
return typeof value;
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
/**
|
|
2633
|
+
* Output validator
|
|
2634
|
+
*/
|
|
2635
|
+
class OutputValidator {
|
|
2636
|
+
/**
|
|
2637
|
+
* Validate output against cap output schema using MediaSpec
|
|
2638
|
+
* Resolves spec ID to MediaSpec before validation
|
|
2639
|
+
*/
|
|
2640
|
+
static validateOutput(cap, output) {
|
|
2641
|
+
const capUrn = cap.urnString();
|
|
2642
|
+
const outputDef = cap.output;
|
|
2643
|
+
|
|
2644
|
+
if (!outputDef) return; // No output definition to validate against
|
|
2645
|
+
|
|
2646
|
+
// Type validation - returns the resolved MediaSpec
|
|
2647
|
+
const mediaSpec = OutputValidator.validateOutputType(cap, outputDef, output);
|
|
2648
|
+
|
|
2649
|
+
// Media spec validation rules (inherent to the semantic type)
|
|
2650
|
+
if (mediaSpec && mediaSpec.validation) {
|
|
2651
|
+
OutputValidator.validateOutputMediaSpecRules(cap, mediaSpec, output);
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
/**
|
|
2656
|
+
* Validate output type using MediaSpec
|
|
2657
|
+
* @returns {MediaSpec|null} The resolved MediaSpec
|
|
2658
|
+
*/
|
|
2659
|
+
static validateOutputType(cap, outputDef, value) {
|
|
2660
|
+
const capUrn = cap.urnString();
|
|
2661
|
+
|
|
2662
|
+
// Get mediaUrn field (now contains a media URN)
|
|
2663
|
+
const mediaUrn = outputDef.mediaUrn || outputDef.media_urn;
|
|
2664
|
+
if (!mediaUrn) {
|
|
2665
|
+
// No media_urn - skip validation
|
|
2666
|
+
return null;
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
// Resolve media URN to MediaSpec - FAIL HARD if unresolvable
|
|
2670
|
+
let mediaSpec;
|
|
2671
|
+
try {
|
|
2672
|
+
mediaSpec = cap.resolveMediaUrn(mediaUrn);
|
|
2673
|
+
} catch (e) {
|
|
2674
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2675
|
+
issue: `Cannot resolve media URN '${mediaUrn}' for output: ${e.message}`
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
// For binary media types, expect base64-encoded string
|
|
2680
|
+
if (mediaSpec.isBinary()) {
|
|
2681
|
+
if (typeof value !== 'string') {
|
|
2682
|
+
throw new ValidationError('InvalidOutputType', capUrn, {
|
|
2683
|
+
expectedMediaSpec: mediaUrn,
|
|
2684
|
+
actualValue: value,
|
|
2685
|
+
schemaErrors: ['Expected base64-encoded string for binary type']
|
|
2686
|
+
});
|
|
2687
|
+
}
|
|
2688
|
+
return mediaSpec;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
// If the resolved media spec has a local schema, validate against it
|
|
2692
|
+
if (mediaSpec.schema) {
|
|
2693
|
+
// TODO: Full JSON Schema validation would require a JSON Schema library
|
|
2694
|
+
// For now, skip local schema validation
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
// For types with profile, validate against profile
|
|
2698
|
+
if (mediaSpec.profile) {
|
|
2699
|
+
const valid = InputValidator.validateAgainstProfile(mediaSpec.profile, value);
|
|
2700
|
+
if (!valid) {
|
|
2701
|
+
throw new ValidationError('InvalidOutputType', capUrn, {
|
|
2702
|
+
expectedMediaSpec: mediaUrn,
|
|
2703
|
+
actualValue: value,
|
|
2704
|
+
schemaErrors: [`Value does not match profile schema`]
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
return mediaSpec;
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
/**
|
|
2713
|
+
* Validate output against media spec's inherent validation rules (first pass)
|
|
2714
|
+
*/
|
|
2715
|
+
static validateOutputMediaSpecRules(cap, mediaSpec, value) {
|
|
2716
|
+
const capUrn = cap.urnString();
|
|
2717
|
+
const validation = mediaSpec.validation;
|
|
2718
|
+
const mediaUrn = mediaSpec.mediaUrn;
|
|
2719
|
+
|
|
2720
|
+
// Min/max validation for numbers
|
|
2721
|
+
if (typeof value === 'number') {
|
|
2722
|
+
if (validation.min !== undefined && value < validation.min) {
|
|
2723
|
+
throw new ValidationError('OutputMediaSpecValidationFailed', capUrn, {
|
|
2724
|
+
mediaUrn: mediaUrn,
|
|
2725
|
+
validationRule: `min value ${validation.min}`,
|
|
2726
|
+
actualValue: value
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
if (validation.max !== undefined && value > validation.max) {
|
|
2730
|
+
throw new ValidationError('OutputMediaSpecValidationFailed', capUrn, {
|
|
2731
|
+
mediaUrn: mediaUrn,
|
|
2732
|
+
validationRule: `max value ${validation.max}`,
|
|
2733
|
+
actualValue: value
|
|
2734
|
+
});
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
// Length validation for strings
|
|
2739
|
+
if (typeof value === 'string') {
|
|
2740
|
+
if (validation.min_length !== undefined && value.length < validation.min_length) {
|
|
2741
|
+
throw new ValidationError('OutputMediaSpecValidationFailed', capUrn, {
|
|
2742
|
+
mediaUrn: mediaUrn,
|
|
2743
|
+
validationRule: `min length ${validation.min_length}`,
|
|
2744
|
+
actualValue: value
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
if (validation.max_length !== undefined && value.length > validation.max_length) {
|
|
2748
|
+
throw new ValidationError('OutputMediaSpecValidationFailed', capUrn, {
|
|
2749
|
+
mediaUrn: mediaUrn,
|
|
2750
|
+
validationRule: `max length ${validation.max_length}`,
|
|
2751
|
+
actualValue: value
|
|
2752
|
+
});
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
// Pattern validation for strings
|
|
2757
|
+
if (typeof value === 'string' && validation.pattern) {
|
|
2758
|
+
const regex = new RegExp(validation.pattern);
|
|
2759
|
+
if (!regex.test(value)) {
|
|
2760
|
+
throw new ValidationError('OutputMediaSpecValidationFailed', capUrn, {
|
|
2761
|
+
mediaUrn: mediaUrn,
|
|
2762
|
+
validationRule: `pattern ${validation.pattern}`,
|
|
2763
|
+
actualValue: value
|
|
2764
|
+
});
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
// Allowed values validation
|
|
2769
|
+
if (validation.allowed_values && Array.isArray(validation.allowed_values)) {
|
|
2770
|
+
if (!validation.allowed_values.includes(value)) {
|
|
2771
|
+
throw new ValidationError('OutputMediaSpecValidationFailed', capUrn, {
|
|
2772
|
+
mediaUrn: mediaUrn,
|
|
2773
|
+
validationRule: `allowed values [${validation.allowed_values.join(', ')}]`,
|
|
2774
|
+
actualValue: value
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
/**
|
|
2782
|
+
* Cap validator
|
|
2783
|
+
*/
|
|
2784
|
+
class CapValidator {
|
|
2785
|
+
/**
|
|
2786
|
+
* Validate cap schema
|
|
2787
|
+
*/
|
|
2788
|
+
static validateCap(cap) {
|
|
2789
|
+
const capUrn = cap.urnString();
|
|
2790
|
+
|
|
2791
|
+
// Validate basic cap structure
|
|
2792
|
+
if (!cap.title || typeof cap.title !== 'string') {
|
|
2793
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2794
|
+
issue: 'Cap must have a valid title'
|
|
2795
|
+
});
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
if (!cap.command || typeof cap.command !== 'string') {
|
|
2799
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2800
|
+
issue: 'Cap must have a valid command'
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
// Validate arguments structure
|
|
2805
|
+
if (cap.arguments) {
|
|
2806
|
+
if (cap.arguments.required && !Array.isArray(cap.arguments.required)) {
|
|
2807
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2808
|
+
issue: 'Required arguments must be an array'
|
|
2809
|
+
});
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
if (cap.arguments.optional && !Array.isArray(cap.arguments.optional)) {
|
|
2813
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2814
|
+
issue: 'Optional arguments must be an array'
|
|
2815
|
+
});
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
// Validate output structure
|
|
2820
|
+
if (cap.output && typeof cap.output !== 'object') {
|
|
2821
|
+
throw new ValidationError('InvalidCapSchema', capUrn, {
|
|
2822
|
+
issue: 'Cap output must be an object'
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// ============================================================================
|
|
2829
|
+
// CAP ARGUMENT VALUE - Unified argument type
|
|
2830
|
+
// ============================================================================
|
|
2831
|
+
|
|
2832
|
+
/**
|
|
2833
|
+
* Unified argument type - arguments are identified by media_urn.
|
|
2834
|
+
* The cap definition's sources specify how to extract values (stdin, position, cli_flag).
|
|
2835
|
+
*/
|
|
2836
|
+
class CapArgumentValue {
|
|
2837
|
+
/**
|
|
2838
|
+
* Create a new CapArgumentValue
|
|
2839
|
+
* @param {string} mediaUrn - Semantic identifier, e.g., "media:model-spec;textable"
|
|
2840
|
+
* @param {Uint8Array|Buffer} value - Value bytes (UTF-8 for text, raw for binary)
|
|
2841
|
+
*/
|
|
2842
|
+
constructor(mediaUrn, value) {
|
|
2843
|
+
this.mediaUrn = mediaUrn;
|
|
2844
|
+
this.value = value instanceof Uint8Array ? value : new Uint8Array(value || []);
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
/**
|
|
2848
|
+
* Create a new CapArgumentValue from a string value
|
|
2849
|
+
* @param {string} mediaUrn - Semantic identifier
|
|
2850
|
+
* @param {string} value - String value (will be converted to UTF-8 bytes)
|
|
2851
|
+
* @returns {CapArgumentValue}
|
|
2852
|
+
*/
|
|
2853
|
+
static fromStr(mediaUrn, value) {
|
|
2854
|
+
const encoder = new TextEncoder();
|
|
2855
|
+
return new CapArgumentValue(mediaUrn, encoder.encode(value));
|
|
2856
|
+
}
|
|
2857
|
+
|
|
2858
|
+
/**
|
|
2859
|
+
* Get the value as a UTF-8 string
|
|
2860
|
+
* @returns {string} The value decoded as UTF-8
|
|
2861
|
+
*/
|
|
2862
|
+
valueAsStr() {
|
|
2863
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
2864
|
+
return decoder.decode(this.value);
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
// ============================================================================
|
|
2869
|
+
// CAP MATRIX - Registry for Capability Hosts
|
|
2870
|
+
// ============================================================================
|
|
2871
|
+
|
|
2872
|
+
/**
|
|
2873
|
+
* Error types for capability host registry operations
|
|
2874
|
+
*/
|
|
2875
|
+
class CapMatrixError extends Error {
|
|
2876
|
+
constructor(type, message) {
|
|
2877
|
+
super(message);
|
|
2878
|
+
this.name = 'CapMatrixError';
|
|
2879
|
+
this.type = type;
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
static noSetsFound(capability) {
|
|
2883
|
+
return new CapMatrixError('NoSetsFound', `No cap sets found for capability: ${capability}`);
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
static invalidUrn(urn, reason) {
|
|
2887
|
+
return new CapMatrixError('InvalidUrn', `Invalid capability URN: ${urn}: ${reason}`);
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
static registryError(message) {
|
|
2891
|
+
return new CapMatrixError('RegistryError', message);
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
/**
|
|
2896
|
+
* Internal entry for a registered capability host
|
|
2897
|
+
*/
|
|
2898
|
+
class CapSetEntry {
|
|
2899
|
+
constructor(name, host, capabilities) {
|
|
2900
|
+
this.name = name;
|
|
2901
|
+
this.host = host; // Object implementing executeCap(capUrn, arguments) -> Promise
|
|
2902
|
+
this.capabilities = capabilities; // Array<Cap>
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
/**
|
|
2907
|
+
* Unified registry for cap sets (providers and plugins)
|
|
2908
|
+
* Provides capability host discovery using subset matching.
|
|
2909
|
+
*/
|
|
2910
|
+
class CapMatrix {
|
|
2911
|
+
constructor() {
|
|
2912
|
+
this.sets = new Map(); // Map<string, CapSetEntry>
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
/**
|
|
2916
|
+
* Register a capability host with its supported capabilities
|
|
2917
|
+
* @param {string} name - Unique name for the capability host
|
|
2918
|
+
* @param {object} host - Object with executeCap method
|
|
2919
|
+
* @param {Cap[]} capabilities - Array of capabilities this host supports
|
|
2920
|
+
*/
|
|
2921
|
+
registerCapSet(name, host, capabilities) {
|
|
2922
|
+
const entry = new CapSetEntry(name, host, capabilities);
|
|
2923
|
+
this.sets.set(name, entry);
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
/**
|
|
2927
|
+
* Find cap sets that can handle the requested capability
|
|
2928
|
+
* Uses subset matching: host capabilities must be a subset of or match the request
|
|
2929
|
+
* @param {string} requestUrn - The capability URN to find sets for
|
|
2930
|
+
* @returns {object[]} Array of hosts that can handle the request
|
|
2931
|
+
* @throws {CapMatrixError} If URN is invalid or no sets found
|
|
2932
|
+
*/
|
|
2933
|
+
findCapSets(requestUrn) {
|
|
2934
|
+
let request;
|
|
2935
|
+
try {
|
|
2936
|
+
request = CapUrn.fromString(requestUrn);
|
|
2937
|
+
} catch (e) {
|
|
2938
|
+
throw CapMatrixError.invalidUrn(requestUrn, e.message);
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
const matchingHosts = [];
|
|
2942
|
+
|
|
2943
|
+
for (const entry of this.sets.values()) {
|
|
2944
|
+
for (const cap of entry.capabilities) {
|
|
2945
|
+
if (cap.urn.accepts(request)) {
|
|
2946
|
+
matchingHosts.push(entry.host);
|
|
2947
|
+
break; // Found a matching capability for this host
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
if (matchingHosts.length === 0) {
|
|
2953
|
+
throw CapMatrixError.noSetsFound(requestUrn);
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
return matchingHosts;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
/**
|
|
2960
|
+
* Find the best capability host for the request using specificity ranking
|
|
2961
|
+
* @param {string} requestUrn - The capability URN to find the best host for
|
|
2962
|
+
* @returns {{host: object, cap: Cap}} The best host and matching cap definition
|
|
2963
|
+
* @throws {CapMatrixError} If URN is invalid or no sets found
|
|
2964
|
+
*/
|
|
2965
|
+
findBestCapSet(requestUrn) {
|
|
2966
|
+
let request;
|
|
2967
|
+
try {
|
|
2968
|
+
request = CapUrn.fromString(requestUrn);
|
|
2969
|
+
} catch (e) {
|
|
2970
|
+
throw CapMatrixError.invalidUrn(requestUrn, e.message);
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
let bestHost = null;
|
|
2974
|
+
let bestCap = null;
|
|
2975
|
+
let bestSpecificity = -1;
|
|
2976
|
+
|
|
2977
|
+
for (const entry of this.sets.values()) {
|
|
2978
|
+
for (const cap of entry.capabilities) {
|
|
2979
|
+
if (cap.urn.accepts(request)) {
|
|
2980
|
+
const specificity = cap.urn.specificity();
|
|
2981
|
+
if (bestSpecificity === -1 || specificity > bestSpecificity) {
|
|
2982
|
+
bestHost = entry.host;
|
|
2983
|
+
bestCap = cap;
|
|
2984
|
+
bestSpecificity = specificity;
|
|
2985
|
+
}
|
|
2986
|
+
break; // Found match for this entry, check next
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
if (bestHost === null) {
|
|
2992
|
+
throw CapMatrixError.noSetsFound(requestUrn);
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
return { host: bestHost, cap: bestCap };
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
/**
|
|
2999
|
+
* Get all registered capability host names
|
|
3000
|
+
* @returns {string[]} Array of host names
|
|
3001
|
+
*/
|
|
3002
|
+
getHostNames() {
|
|
3003
|
+
return Array.from(this.sets.keys());
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
/**
|
|
3007
|
+
* Get all capabilities from all registered sets
|
|
3008
|
+
* @returns {Cap[]} Array of all capabilities
|
|
3009
|
+
*/
|
|
3010
|
+
getAllCapabilities() {
|
|
3011
|
+
const capabilities = [];
|
|
3012
|
+
for (const entry of this.sets.values()) {
|
|
3013
|
+
capabilities.push(...entry.capabilities);
|
|
3014
|
+
}
|
|
3015
|
+
return capabilities;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
/**
|
|
3019
|
+
* Check if any host accepts the specified capability request
|
|
3020
|
+
* @param {string} requestUrn - The capability URN to check
|
|
3021
|
+
* @returns {boolean} Whether the capability request is accepted
|
|
3022
|
+
*/
|
|
3023
|
+
acceptsRequest(requestUrn) {
|
|
3024
|
+
try {
|
|
3025
|
+
this.findCapSets(requestUrn);
|
|
3026
|
+
return true;
|
|
3027
|
+
} catch (e) {
|
|
3028
|
+
if (e instanceof CapMatrixError) {
|
|
3029
|
+
return false;
|
|
3030
|
+
}
|
|
3031
|
+
throw e;
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
/**
|
|
3036
|
+
* Unregister a capability host
|
|
3037
|
+
* @param {string} name - The name of the host to unregister
|
|
3038
|
+
* @returns {boolean} Whether the host was found and removed
|
|
3039
|
+
*/
|
|
3040
|
+
unregisterCapSet(name) {
|
|
3041
|
+
return this.sets.delete(name);
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
/**
|
|
3045
|
+
* Clear all registered sets
|
|
3046
|
+
*/
|
|
3047
|
+
clear() {
|
|
3048
|
+
this.sets.clear();
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
// ============================================================================
|
|
3053
|
+
// CAP BLOCK - Composite Registry
|
|
3054
|
+
// ============================================================================
|
|
3055
|
+
|
|
3056
|
+
/**
|
|
3057
|
+
* Result of finding the best match across registries
|
|
3058
|
+
*/
|
|
3059
|
+
class BestCapSetMatch {
|
|
3060
|
+
/**
|
|
3061
|
+
* @param {Cap} cap - The Cap definition that matched
|
|
3062
|
+
* @param {number} specificity - The specificity score of the match
|
|
3063
|
+
* @param {string} registryName - The name of the registry that provided this match
|
|
3064
|
+
*/
|
|
3065
|
+
constructor(cap, specificity, registryName) {
|
|
3066
|
+
this.cap = cap;
|
|
3067
|
+
this.specificity = specificity;
|
|
3068
|
+
this.registryName = registryName;
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
/**
|
|
3073
|
+
* Composite CapSet that wraps multiple registries
|
|
3074
|
+
* and delegates execution to the best matching one.
|
|
3075
|
+
*/
|
|
3076
|
+
class CompositeCapSet {
|
|
3077
|
+
/**
|
|
3078
|
+
* @param {Array<{name: string, registry: CapMatrix}>} registries
|
|
3079
|
+
*/
|
|
3080
|
+
constructor(registries) {
|
|
3081
|
+
this.registries = registries;
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
/**
|
|
3085
|
+
* Execute a capability by finding the best match and delegating
|
|
3086
|
+
* @param {string} capUrn - The capability URN to execute
|
|
3087
|
+
* @param {CapArgumentValue[]} args - Arguments identified by media_urn
|
|
3088
|
+
* @returns {Promise<{binaryOutput: Uint8Array|null, textOutput: string|null}>}
|
|
3089
|
+
*/
|
|
3090
|
+
async executeCap(capUrn, args) {
|
|
3091
|
+
let request;
|
|
3092
|
+
try {
|
|
3093
|
+
request = CapUrn.fromString(capUrn);
|
|
3094
|
+
} catch (e) {
|
|
3095
|
+
throw new Error(`Invalid cap URN '${capUrn}': ${e.message}`);
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
// Find the best matching host across all registries
|
|
3099
|
+
let bestHost = null;
|
|
3100
|
+
let bestSpecificity = -1;
|
|
3101
|
+
|
|
3102
|
+
for (const { registry } of this.registries) {
|
|
3103
|
+
for (const entry of registry.sets.values()) {
|
|
3104
|
+
for (const cap of entry.capabilities) {
|
|
3105
|
+
if (cap.urn.accepts(request)) {
|
|
3106
|
+
const specificity = cap.urn.specificity();
|
|
3107
|
+
if (bestSpecificity === -1 || specificity > bestSpecificity) {
|
|
3108
|
+
bestHost = entry.host;
|
|
3109
|
+
bestSpecificity = specificity;
|
|
3110
|
+
}
|
|
3111
|
+
break; // Found match for this entry
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
if (bestHost === null) {
|
|
3118
|
+
throw new Error(`No capability host found for '${capUrn}'`);
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
// Delegate execution to the best matching host
|
|
3122
|
+
return bestHost.executeCap(capUrn, args);
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
/**
|
|
3126
|
+
* Build a directed graph from all capabilities in the registries.
|
|
3127
|
+
* @returns {CapGraph}
|
|
3128
|
+
*/
|
|
3129
|
+
graph() {
|
|
3130
|
+
return CapGraph.buildFromRegistries(this.registries);
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
/**
|
|
3135
|
+
* Composite registry that wraps multiple CapMatrix instances
|
|
3136
|
+
* and finds the best match across all of them by specificity.
|
|
3137
|
+
*
|
|
3138
|
+
* When multiple registries can handle a request, this registry compares
|
|
3139
|
+
* specificity scores and returns the most specific match.
|
|
3140
|
+
* On tie, defaults to the first registry that was added (priority order).
|
|
3141
|
+
*/
|
|
3142
|
+
class CapBlock {
|
|
3143
|
+
constructor() {
|
|
3144
|
+
this.registries = []; // Array of {name: string, registry: CapMatrix}
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
/**
|
|
3148
|
+
* Add a child registry with a name.
|
|
3149
|
+
* Registries are checked in order of addition for tie-breaking.
|
|
3150
|
+
* @param {string} name - Unique name for this registry
|
|
3151
|
+
* @param {CapMatrix} registry - The CapMatrix to add
|
|
3152
|
+
*/
|
|
3153
|
+
addRegistry(name, registry) {
|
|
3154
|
+
this.registries.push({ name, registry });
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
/**
|
|
3158
|
+
* Remove a child registry by name
|
|
3159
|
+
* @param {string} name - The name of the registry to remove
|
|
3160
|
+
* @returns {CapMatrix|null} The removed registry, or null if not found
|
|
3161
|
+
*/
|
|
3162
|
+
removeRegistry(name) {
|
|
3163
|
+
const index = this.registries.findIndex(entry => entry.name === name);
|
|
3164
|
+
if (index !== -1) {
|
|
3165
|
+
const removed = this.registries[index].registry;
|
|
3166
|
+
this.registries.splice(index, 1);
|
|
3167
|
+
return removed;
|
|
3168
|
+
}
|
|
3169
|
+
return null;
|
|
3170
|
+
}
|
|
3171
|
+
|
|
3172
|
+
/**
|
|
3173
|
+
* Get a child registry by name
|
|
3174
|
+
* @param {string} name - The name of the registry
|
|
3175
|
+
* @returns {CapMatrix|null} The registry, or null if not found
|
|
3176
|
+
*/
|
|
3177
|
+
getRegistry(name) {
|
|
3178
|
+
const entry = this.registries.find(e => e.name === name);
|
|
3179
|
+
return entry ? entry.registry : null;
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
/**
|
|
3183
|
+
* Get names of all child registries
|
|
3184
|
+
* @returns {string[]} Array of registry names in priority order
|
|
3185
|
+
*/
|
|
3186
|
+
getRegistryNames() {
|
|
3187
|
+
return this.registries.map(entry => entry.name);
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
/**
|
|
3191
|
+
* Check if a capability is available and return execution info.
|
|
3192
|
+
* This is the main entry point for capability lookup.
|
|
3193
|
+
* @param {string} capUrn - The capability URN to look up
|
|
3194
|
+
* @returns {{cap: Cap, compositeHost: CompositeCapSet}} The cap and composite host for execution
|
|
3195
|
+
* @throws {CapMatrixError} If URN is invalid or no match found
|
|
3196
|
+
*/
|
|
3197
|
+
can(capUrn) {
|
|
3198
|
+
// Find the best match to get the cap definition
|
|
3199
|
+
const bestMatch = this.findBestCapSet(capUrn);
|
|
3200
|
+
|
|
3201
|
+
// Create a CompositeCapSet that will delegate execution
|
|
3202
|
+
const compositeHost = new CompositeCapSet([...this.registries]);
|
|
3203
|
+
|
|
3204
|
+
return {
|
|
3205
|
+
cap: bestMatch.cap,
|
|
3206
|
+
compositeHost: compositeHost
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
/**
|
|
3211
|
+
* Find the best capability host across ALL child registries.
|
|
3212
|
+
* Polls all registries and compares their best matches by specificity.
|
|
3213
|
+
* On specificity tie, returns the match from the first registry.
|
|
3214
|
+
* @param {string} requestUrn - The capability URN to find the best host for
|
|
3215
|
+
* @returns {BestCapSetMatch} The best match
|
|
3216
|
+
* @throws {CapMatrixError} If URN is invalid or no match found
|
|
3217
|
+
*/
|
|
3218
|
+
findBestCapSet(requestUrn) {
|
|
3219
|
+
let request;
|
|
3220
|
+
try {
|
|
3221
|
+
request = CapUrn.fromString(requestUrn);
|
|
3222
|
+
} catch (e) {
|
|
3223
|
+
throw CapMatrixError.invalidUrn(requestUrn, e.message);
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
let bestOverall = null;
|
|
3227
|
+
|
|
3228
|
+
for (const { name, registry } of this.registries) {
|
|
3229
|
+
// Find the best match within this registry
|
|
3230
|
+
const result = this._findBestInRegistry(registry, request);
|
|
3231
|
+
if (result) {
|
|
3232
|
+
const { cap, specificity } = result;
|
|
3233
|
+
const candidate = new BestCapSetMatch(cap, specificity, name);
|
|
3234
|
+
|
|
3235
|
+
if (bestOverall === null) {
|
|
3236
|
+
bestOverall = candidate;
|
|
3237
|
+
} else if (specificity > bestOverall.specificity) {
|
|
3238
|
+
// Only replace if strictly more specific
|
|
3239
|
+
// On tie, keep the first one (priority order)
|
|
3240
|
+
bestOverall = candidate;
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
if (bestOverall === null) {
|
|
3246
|
+
throw CapMatrixError.noSetsFound(requestUrn);
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
return bestOverall;
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
/**
|
|
3253
|
+
* Check if any registry accepts the specified capability request
|
|
3254
|
+
* @param {string} requestUrn - The capability URN to check
|
|
3255
|
+
* @returns {boolean} Whether the capability request is accepted
|
|
3256
|
+
*/
|
|
3257
|
+
acceptsRequest(requestUrn) {
|
|
3258
|
+
try {
|
|
3259
|
+
this.findBestCapSet(requestUrn);
|
|
3260
|
+
return true;
|
|
3261
|
+
} catch (e) {
|
|
3262
|
+
if (e instanceof CapMatrixError) {
|
|
3263
|
+
return false;
|
|
3264
|
+
}
|
|
3265
|
+
throw e;
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
/**
|
|
3270
|
+
* Find the best match within a single registry
|
|
3271
|
+
* @private
|
|
3272
|
+
* @param {CapMatrix} registry - The registry to search
|
|
3273
|
+
* @param {CapUrn} request - The parsed request URN
|
|
3274
|
+
* @returns {{cap: Cap, specificity: number}|null} The best match or null
|
|
3275
|
+
*/
|
|
3276
|
+
_findBestInRegistry(registry, request) {
|
|
3277
|
+
let bestCap = null;
|
|
3278
|
+
let bestSpecificity = -1;
|
|
3279
|
+
|
|
3280
|
+
for (const entry of registry.sets.values()) {
|
|
3281
|
+
for (const cap of entry.capabilities) {
|
|
3282
|
+
if (cap.urn.accepts(request)) {
|
|
3283
|
+
const specificity = cap.urn.specificity();
|
|
3284
|
+
if (bestSpecificity === -1 || specificity > bestSpecificity) {
|
|
3285
|
+
bestCap = cap;
|
|
3286
|
+
bestSpecificity = specificity;
|
|
3287
|
+
}
|
|
3288
|
+
break; // Found match for this entry
|
|
3289
|
+
}
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
if (bestCap === null) {
|
|
3294
|
+
return null;
|
|
3295
|
+
}
|
|
3296
|
+
return { cap: bestCap, specificity: bestSpecificity };
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
/**
|
|
3300
|
+
* Build a directed graph from all capabilities across all registries.
|
|
3301
|
+
* The graph represents all possible conversions where:
|
|
3302
|
+
* - Nodes are media URNs (e.g., "media:string", "media:binary")
|
|
3303
|
+
* - Edges are capabilities that convert from one media URN to another
|
|
3304
|
+
* @returns {CapGraph} The capability graph
|
|
3305
|
+
*/
|
|
3306
|
+
graph() {
|
|
3307
|
+
return CapGraph.buildFromRegistries(this.registries);
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
// ============================================================================
|
|
3312
|
+
// CAP GRAPH - Directed graph of capability conversions
|
|
3313
|
+
// ============================================================================
|
|
3314
|
+
|
|
3315
|
+
/**
|
|
3316
|
+
* An edge in the capability graph representing a conversion from one media URN to another.
|
|
3317
|
+
*/
|
|
3318
|
+
class CapGraphEdge {
|
|
3319
|
+
/**
|
|
3320
|
+
* @param {string} fromUrn - The input media URN
|
|
3321
|
+
* @param {string} toUrn - The output media URN
|
|
3322
|
+
* @param {Cap} cap - The capability that performs this conversion
|
|
3323
|
+
* @param {string} registryName - The registry that provided this capability
|
|
3324
|
+
* @param {number} specificity - Specificity score for ranking
|
|
3325
|
+
*/
|
|
3326
|
+
constructor(fromUrn, toUrn, cap, registryName, specificity) {
|
|
3327
|
+
this.fromUrn = fromUrn;
|
|
3328
|
+
this.toUrn = toUrn;
|
|
3329
|
+
this.cap = cap;
|
|
3330
|
+
this.registryName = registryName;
|
|
3331
|
+
this.specificity = specificity;
|
|
3332
|
+
}
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
/**
|
|
3336
|
+
* Statistics about a capability graph.
|
|
3337
|
+
*/
|
|
3338
|
+
class CapGraphStats {
|
|
3339
|
+
/**
|
|
3340
|
+
* @param {number} nodeCount - Number of unique media URN nodes
|
|
3341
|
+
* @param {number} edgeCount - Number of edges (capabilities)
|
|
3342
|
+
* @param {number} inputUrnCount - Number of URNs that serve as inputs
|
|
3343
|
+
* @param {number} outputUrnCount - Number of URNs that serve as outputs
|
|
3344
|
+
*/
|
|
3345
|
+
constructor(nodeCount, edgeCount, inputUrnCount, outputUrnCount) {
|
|
3346
|
+
this.nodeCount = nodeCount;
|
|
3347
|
+
this.edgeCount = edgeCount;
|
|
3348
|
+
this.inputUrnCount = inputUrnCount;
|
|
3349
|
+
this.outputUrnCount = outputUrnCount;
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
/**
|
|
3354
|
+
* A directed graph where nodes are media URNs and edges are capabilities.
|
|
3355
|
+
* This graph enables discovering conversion paths between different media formats.
|
|
3356
|
+
*/
|
|
3357
|
+
class CapGraph {
|
|
3358
|
+
constructor() {
|
|
3359
|
+
this.edges = [];
|
|
3360
|
+
this.outgoing = new Map(); // fromUrn -> edge indices
|
|
3361
|
+
this.incoming = new Map(); // toUrn -> edge indices
|
|
3362
|
+
this.nodes = new Set();
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
/**
|
|
3366
|
+
* Add a capability as an edge in the graph.
|
|
3367
|
+
* @param {Cap} cap - The capability to add
|
|
3368
|
+
* @param {string} registryName - The registry that provided this capability
|
|
3369
|
+
*/
|
|
3370
|
+
addCap(cap, registryName) {
|
|
3371
|
+
const fromUrn = cap.urn.getInSpec();
|
|
3372
|
+
const toUrn = cap.urn.getOutSpec();
|
|
3373
|
+
const specificity = cap.urn.specificity();
|
|
3374
|
+
|
|
3375
|
+
// Add nodes
|
|
3376
|
+
this.nodes.add(fromUrn);
|
|
3377
|
+
this.nodes.add(toUrn);
|
|
3378
|
+
|
|
3379
|
+
// Create edge
|
|
3380
|
+
const edgeIndex = this.edges.length;
|
|
3381
|
+
const edge = new CapGraphEdge(fromUrn, toUrn, cap, registryName, specificity);
|
|
3382
|
+
this.edges.push(edge);
|
|
3383
|
+
|
|
3384
|
+
// Update outgoing index
|
|
3385
|
+
if (!this.outgoing.has(fromUrn)) {
|
|
3386
|
+
this.outgoing.set(fromUrn, []);
|
|
3387
|
+
}
|
|
3388
|
+
this.outgoing.get(fromUrn).push(edgeIndex);
|
|
3389
|
+
|
|
3390
|
+
// Update incoming index
|
|
3391
|
+
if (!this.incoming.has(toUrn)) {
|
|
3392
|
+
this.incoming.set(toUrn, []);
|
|
3393
|
+
}
|
|
3394
|
+
this.incoming.get(toUrn).push(edgeIndex);
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
/**
|
|
3398
|
+
* Build a graph from multiple registries.
|
|
3399
|
+
* @param {Array<{name: string, registry: CapMatrix}>} registries
|
|
3400
|
+
* @returns {CapGraph}
|
|
3401
|
+
*/
|
|
3402
|
+
static buildFromRegistries(registries) {
|
|
3403
|
+
const graph = new CapGraph();
|
|
3404
|
+
|
|
3405
|
+
for (const { name, registry } of registries) {
|
|
3406
|
+
for (const entry of registry.sets.values()) {
|
|
3407
|
+
for (const cap of entry.capabilities) {
|
|
3408
|
+
graph.addCap(cap, name);
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
return graph;
|
|
3414
|
+
}
|
|
3415
|
+
|
|
3416
|
+
/**
|
|
3417
|
+
* Get all nodes (media URNs) in the graph.
|
|
3418
|
+
* @returns {Set<string>}
|
|
3419
|
+
*/
|
|
3420
|
+
getNodes() {
|
|
3421
|
+
return new Set(this.nodes);
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
/**
|
|
3425
|
+
* Get all edges in the graph.
|
|
3426
|
+
* @returns {CapGraphEdge[]}
|
|
3427
|
+
*/
|
|
3428
|
+
getEdges() {
|
|
3429
|
+
return [...this.edges];
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
/**
|
|
3433
|
+
* Get all edges where the provided URN satisfies the edge's input requirement.
|
|
3434
|
+
* Uses conformsTo-based matching instead of exact string matching.
|
|
3435
|
+
* @param {string} urn - The media URN
|
|
3436
|
+
* @returns {CapGraphEdge[]}
|
|
3437
|
+
*/
|
|
3438
|
+
getOutgoing(urn) {
|
|
3439
|
+
// Use TaggedUrn matching: find all edges where the provided URN (instance)
|
|
3440
|
+
// conforms to the edge's input requirement (pattern/fromUrn)
|
|
3441
|
+
const providedUrn = TaggedUrn.fromString(urn);
|
|
3442
|
+
|
|
3443
|
+
const edges = this.edges.filter(edge => {
|
|
3444
|
+
const requirementUrn = TaggedUrn.fromString(edge.fromUrn);
|
|
3445
|
+
return providedUrn.conformsTo(requirementUrn);
|
|
3446
|
+
});
|
|
3447
|
+
|
|
3448
|
+
// Sort by specificity (highest first) for consistent ordering
|
|
3449
|
+
edges.sort((a, b) => b.specificity - a.specificity);
|
|
3450
|
+
|
|
3451
|
+
return edges;
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
/**
|
|
3455
|
+
* Get all edges targeting a media URN.
|
|
3456
|
+
* @param {string} urn - The media URN
|
|
3457
|
+
* @returns {CapGraphEdge[]}
|
|
3458
|
+
*/
|
|
3459
|
+
getIncoming(urn) {
|
|
3460
|
+
const indices = this.incoming.get(urn) || [];
|
|
3461
|
+
return indices.map(i => this.edges[i]);
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
/**
|
|
3465
|
+
* Check if there's any direct edge from one URN to another.
|
|
3466
|
+
* @param {string} fromUrn - The source media URN
|
|
3467
|
+
* @param {string} toUrn - The target media URN
|
|
3468
|
+
* @returns {boolean}
|
|
3469
|
+
*/
|
|
3470
|
+
hasDirectEdge(fromUrn, toUrn) {
|
|
3471
|
+
return this.getOutgoing(fromUrn).some(edge => edge.toUrn === toUrn);
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
/**
|
|
3475
|
+
* Get all direct edges from one URN to another, sorted by specificity (highest first).
|
|
3476
|
+
* @param {string} fromUrn - The source media URN
|
|
3477
|
+
* @param {string} toUrn - The target media URN
|
|
3478
|
+
* @returns {CapGraphEdge[]}
|
|
3479
|
+
*/
|
|
3480
|
+
getDirectEdges(fromUrn, toUrn) {
|
|
3481
|
+
const edges = this.getOutgoing(fromUrn).filter(edge => edge.toUrn === toUrn);
|
|
3482
|
+
edges.sort((a, b) => b.specificity - a.specificity);
|
|
3483
|
+
return edges;
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
/**
|
|
3487
|
+
* Check if a conversion path exists from one URN to another.
|
|
3488
|
+
* Uses BFS to find if there's any path (direct or through intermediates).
|
|
3489
|
+
* @param {string} fromUrn - The source media URN
|
|
3490
|
+
* @param {string} toUrn - The target media URN
|
|
3491
|
+
* @returns {boolean}
|
|
3492
|
+
*/
|
|
3493
|
+
canConvert(fromUrn, toUrn) {
|
|
3494
|
+
if (fromUrn === toUrn) {
|
|
3495
|
+
return true;
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
if (!this.nodes.has(fromUrn) || !this.nodes.has(toUrn)) {
|
|
3499
|
+
return false;
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
const visited = new Set();
|
|
3503
|
+
const queue = [fromUrn];
|
|
3504
|
+
visited.add(fromUrn);
|
|
3505
|
+
|
|
3506
|
+
while (queue.length > 0) {
|
|
3507
|
+
const current = queue.shift();
|
|
3508
|
+
|
|
3509
|
+
for (const edge of this.getOutgoing(current)) {
|
|
3510
|
+
if (edge.toUrn === toUrn) {
|
|
3511
|
+
return true;
|
|
3512
|
+
}
|
|
3513
|
+
if (!visited.has(edge.toUrn)) {
|
|
3514
|
+
visited.add(edge.toUrn);
|
|
3515
|
+
queue.push(edge.toUrn);
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
return false;
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
/**
|
|
3524
|
+
* Find the shortest conversion path from one URN to another.
|
|
3525
|
+
* @param {string} fromUrn - The source media URN
|
|
3526
|
+
* @param {string} toUrn - The target media URN
|
|
3527
|
+
* @returns {CapGraphEdge[]|null} Array of edges representing the path, or null if no path exists
|
|
3528
|
+
*/
|
|
3529
|
+
findPath(fromUrn, toUrn) {
|
|
3530
|
+
if (fromUrn === toUrn) {
|
|
3531
|
+
return [];
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
if (!this.nodes.has(fromUrn) || !this.nodes.has(toUrn)) {
|
|
3535
|
+
return null;
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
// BFS to find shortest path
|
|
3539
|
+
// visited maps urn -> {prevUrn, edgeIdx} or null for start node
|
|
3540
|
+
const visited = new Map();
|
|
3541
|
+
const queue = [fromUrn];
|
|
3542
|
+
visited.set(fromUrn, null);
|
|
3543
|
+
|
|
3544
|
+
while (queue.length > 0) {
|
|
3545
|
+
const current = queue.shift();
|
|
3546
|
+
|
|
3547
|
+
const indices = this.outgoing.get(current) || [];
|
|
3548
|
+
for (const edgeIdx of indices) {
|
|
3549
|
+
const edge = this.edges[edgeIdx];
|
|
3550
|
+
|
|
3551
|
+
if (edge.toUrn === toUrn) {
|
|
3552
|
+
// Found the target - reconstruct path
|
|
3553
|
+
const path = [this.edges[edgeIdx]];
|
|
3554
|
+
|
|
3555
|
+
let backtrack = current;
|
|
3556
|
+
let backtrackInfo = visited.get(backtrack);
|
|
3557
|
+
while (backtrackInfo !== null && backtrackInfo !== undefined) {
|
|
3558
|
+
path.push(this.edges[backtrackInfo.edgeIdx]);
|
|
3559
|
+
backtrack = backtrackInfo.prevUrn;
|
|
3560
|
+
backtrackInfo = visited.get(backtrack);
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
path.reverse();
|
|
3564
|
+
return path;
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
if (!visited.has(edge.toUrn)) {
|
|
3568
|
+
visited.set(edge.toUrn, { prevUrn: current, edgeIdx });
|
|
3569
|
+
queue.push(edge.toUrn);
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
return null;
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
/**
|
|
3578
|
+
* Find all conversion paths from one URN to another (up to a maximum depth).
|
|
3579
|
+
* @param {string} fromUrn - The source media URN
|
|
3580
|
+
* @param {string} toUrn - The target media URN
|
|
3581
|
+
* @param {number} maxDepth - Maximum path length to search
|
|
3582
|
+
* @returns {CapGraphEdge[][]} Array of paths (each path is an array of edges)
|
|
3583
|
+
*/
|
|
3584
|
+
findAllPaths(fromUrn, toUrn, maxDepth) {
|
|
3585
|
+
if (!this.nodes.has(fromUrn) || !this.nodes.has(toUrn)) {
|
|
3586
|
+
return [];
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
const allPaths = [];
|
|
3590
|
+
const currentPath = [];
|
|
3591
|
+
const visited = new Set();
|
|
3592
|
+
|
|
3593
|
+
this._dfsFindPaths(fromUrn, toUrn, maxDepth, currentPath, visited, allPaths);
|
|
3594
|
+
|
|
3595
|
+
// Sort by path length (shortest first)
|
|
3596
|
+
allPaths.sort((a, b) => a.length - b.length);
|
|
3597
|
+
|
|
3598
|
+
// Convert indices to edge references
|
|
3599
|
+
return allPaths.map(indices => indices.map(i => this.edges[i]));
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
/**
|
|
3603
|
+
* DFS helper for finding all paths
|
|
3604
|
+
* @private
|
|
3605
|
+
*/
|
|
3606
|
+
_dfsFindPaths(current, target, remainingDepth, currentPath, visited, allPaths) {
|
|
3607
|
+
if (remainingDepth === 0) {
|
|
3608
|
+
return;
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
const indices = this.outgoing.get(current) || [];
|
|
3612
|
+
for (const edgeIdx of indices) {
|
|
3613
|
+
const edge = this.edges[edgeIdx];
|
|
3614
|
+
|
|
3615
|
+
if (edge.toUrn === target) {
|
|
3616
|
+
// Found a path
|
|
3617
|
+
allPaths.push([...currentPath, edgeIdx]);
|
|
3618
|
+
} else if (!visited.has(edge.toUrn)) {
|
|
3619
|
+
// Continue searching
|
|
3620
|
+
visited.add(edge.toUrn);
|
|
3621
|
+
currentPath.push(edgeIdx);
|
|
3622
|
+
|
|
3623
|
+
this._dfsFindPaths(edge.toUrn, target, remainingDepth - 1, currentPath, visited, allPaths);
|
|
3624
|
+
|
|
3625
|
+
currentPath.pop();
|
|
3626
|
+
visited.delete(edge.toUrn);
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
/**
|
|
3632
|
+
* Find the best (highest specificity) conversion path from one URN to another.
|
|
3633
|
+
* @param {string} fromUrn - The source media URN
|
|
3634
|
+
* @param {string} toUrn - The target media URN
|
|
3635
|
+
* @param {number} maxDepth - Maximum path length to search
|
|
3636
|
+
* @returns {CapGraphEdge[]|null} Array of edges representing the best path, or null if no path exists
|
|
3637
|
+
*/
|
|
3638
|
+
findBestPath(fromUrn, toUrn, maxDepth) {
|
|
3639
|
+
const allPaths = this.findAllPaths(fromUrn, toUrn, maxDepth);
|
|
3640
|
+
|
|
3641
|
+
if (allPaths.length === 0) {
|
|
3642
|
+
return null;
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3645
|
+
let bestPath = null;
|
|
3646
|
+
let bestScore = -1;
|
|
3647
|
+
|
|
3648
|
+
for (const path of allPaths) {
|
|
3649
|
+
const score = path.reduce((sum, edge) => sum + edge.specificity, 0);
|
|
3650
|
+
if (score > bestScore) {
|
|
3651
|
+
bestScore = score;
|
|
3652
|
+
bestPath = path;
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
return bestPath;
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3659
|
+
/**
|
|
3660
|
+
* Get all URNs that have at least one outgoing edge.
|
|
3661
|
+
* @returns {string[]}
|
|
3662
|
+
*/
|
|
3663
|
+
getInputUrns() {
|
|
3664
|
+
return Array.from(this.outgoing.keys());
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
/**
|
|
3668
|
+
* Get all URNs that have at least one incoming edge.
|
|
3669
|
+
* @returns {string[]}
|
|
3670
|
+
*/
|
|
3671
|
+
getOutputUrns() {
|
|
3672
|
+
return Array.from(this.incoming.keys());
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3675
|
+
/**
|
|
3676
|
+
* Get statistics about the graph.
|
|
3677
|
+
* @returns {CapGraphStats}
|
|
3678
|
+
*/
|
|
3679
|
+
stats() {
|
|
3680
|
+
return new CapGraphStats(
|
|
3681
|
+
this.nodes.size,
|
|
3682
|
+
this.edges.length,
|
|
3683
|
+
this.outgoing.size,
|
|
3684
|
+
this.incoming.size
|
|
3685
|
+
);
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
// ============================================================================
|
|
3690
|
+
// StdinSource - Represents stdin input source (data or file reference)
|
|
3691
|
+
// ============================================================================
|
|
3692
|
+
|
|
3693
|
+
/**
|
|
3694
|
+
* Stdin source kinds
|
|
3695
|
+
*/
|
|
3696
|
+
const StdinSourceKind = {
|
|
3697
|
+
DATA: 'data',
|
|
3698
|
+
FILE_REFERENCE: 'file_reference'
|
|
3699
|
+
};
|
|
3700
|
+
|
|
3701
|
+
/**
|
|
3702
|
+
* Represents the source for stdin data.
|
|
3703
|
+
* For plugins (via gRPC/XPC), using FileReference avoids size limits
|
|
3704
|
+
* by letting the receiving side read the file locally.
|
|
3705
|
+
*/
|
|
3706
|
+
class StdinSource {
|
|
3707
|
+
/**
|
|
3708
|
+
* Create a StdinSource (use static factory methods instead)
|
|
3709
|
+
* @param {string} kind - StdinSourceKind.DATA or StdinSourceKind.FILE_REFERENCE
|
|
3710
|
+
* @param {Object} options - Options for the source
|
|
3711
|
+
* @private
|
|
3712
|
+
*/
|
|
3713
|
+
constructor(kind, options = {}) {
|
|
3714
|
+
this.kind = kind;
|
|
3715
|
+
|
|
3716
|
+
if (kind === StdinSourceKind.DATA) {
|
|
3717
|
+
this.data = options.data || null;
|
|
3718
|
+
} else if (kind === StdinSourceKind.FILE_REFERENCE) {
|
|
3719
|
+
this.trackedFileId = options.trackedFileId || '';
|
|
3720
|
+
this.originalPath = options.originalPath || '';
|
|
3721
|
+
this.securityBookmark = options.securityBookmark || null;
|
|
3722
|
+
this.mediaUrn = options.mediaUrn || '';
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
|
|
3726
|
+
/**
|
|
3727
|
+
* Create a StdinSource from raw data bytes
|
|
3728
|
+
* @param {Uint8Array|Buffer|null} data - The raw bytes for stdin
|
|
3729
|
+
* @returns {StdinSource}
|
|
3730
|
+
*/
|
|
3731
|
+
static fromData(data) {
|
|
3732
|
+
return new StdinSource(StdinSourceKind.DATA, { data });
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3735
|
+
/**
|
|
3736
|
+
* Create a StdinSource from a file reference
|
|
3737
|
+
* Used for plugins to read files locally instead of sending bytes over the wire.
|
|
3738
|
+
* @param {string} trackedFileId - ID for lifecycle management
|
|
3739
|
+
* @param {string} originalPath - Original file path (for logging/debugging)
|
|
3740
|
+
* @param {Uint8Array|Buffer|null} securityBookmark - Security bookmark data
|
|
3741
|
+
* @param {string} mediaUrn - Media URN so receiver knows expected type
|
|
3742
|
+
* @returns {StdinSource}
|
|
3743
|
+
*/
|
|
3744
|
+
static fromFileReference(trackedFileId, originalPath, securityBookmark, mediaUrn) {
|
|
3745
|
+
return new StdinSource(StdinSourceKind.FILE_REFERENCE, {
|
|
3746
|
+
trackedFileId,
|
|
3747
|
+
originalPath,
|
|
3748
|
+
securityBookmark,
|
|
3749
|
+
mediaUrn
|
|
3750
|
+
});
|
|
3751
|
+
}
|
|
3752
|
+
|
|
3753
|
+
/**
|
|
3754
|
+
* Check if this is a data source
|
|
3755
|
+
* @returns {boolean}
|
|
3756
|
+
*/
|
|
3757
|
+
isData() {
|
|
3758
|
+
return this.kind === StdinSourceKind.DATA;
|
|
3759
|
+
}
|
|
3760
|
+
|
|
3761
|
+
/**
|
|
3762
|
+
* Check if this is a file reference source
|
|
3763
|
+
* @returns {boolean}
|
|
3764
|
+
*/
|
|
3765
|
+
isFileReference() {
|
|
3766
|
+
return this.kind === StdinSourceKind.FILE_REFERENCE;
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
// =============================================================================
|
|
3771
|
+
// Plugin Repository System
|
|
3772
|
+
// =============================================================================
|
|
3773
|
+
|
|
3774
|
+
/**
|
|
3775
|
+
* Plugin capability summary from registry
|
|
3776
|
+
*/
|
|
3777
|
+
class PluginCapSummary {
|
|
3778
|
+
constructor(urn, title, description = '') {
|
|
3779
|
+
this.urn = urn;
|
|
3780
|
+
this.title = title;
|
|
3781
|
+
this.description = description;
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
/**
|
|
3786
|
+
* Plugin information from registry
|
|
3787
|
+
*/
|
|
3788
|
+
class PluginInfo {
|
|
3789
|
+
constructor(data) {
|
|
3790
|
+
this.id = data.id;
|
|
3791
|
+
this.name = data.name;
|
|
3792
|
+
this.version = data.version || '';
|
|
3793
|
+
this.description = data.description || '';
|
|
3794
|
+
this.author = data.author || '';
|
|
3795
|
+
this.pageUrl = data.pageUrl || '';
|
|
3796
|
+
this.teamId = data.teamId || '';
|
|
3797
|
+
this.signedAt = data.signedAt || '';
|
|
3798
|
+
this.minAppVersion = data.minAppVersion || '';
|
|
3799
|
+
this.caps = (data.caps || []).map(c => new PluginCapSummary(c.urn, c.title, c.description || ''));
|
|
3800
|
+
this.categories = data.categories || [];
|
|
3801
|
+
this.tags = data.tags || [];
|
|
3802
|
+
this.changelog = data.changelog || {};
|
|
3803
|
+
// Distribution fields
|
|
3804
|
+
this.platform = data.platform || '';
|
|
3805
|
+
this.packageName = data.packageName || '';
|
|
3806
|
+
this.packageSha256 = data.packageSha256 || '';
|
|
3807
|
+
this.packageSize = data.packageSize || 0;
|
|
3808
|
+
this.binaryName = data.binaryName || '';
|
|
3809
|
+
this.binarySha256 = data.binarySha256 || '';
|
|
3810
|
+
this.binarySize = data.binarySize || 0;
|
|
3811
|
+
this.availableVersions = data.availableVersions || [];
|
|
3812
|
+
}
|
|
3813
|
+
|
|
3814
|
+
/**
|
|
3815
|
+
* Check if plugin is signed (has team_id and signed_at)
|
|
3816
|
+
*/
|
|
3817
|
+
isSigned() {
|
|
3818
|
+
return this.teamId.length > 0 && this.signedAt.length > 0;
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
/**
|
|
3822
|
+
* Check if binary download info is available
|
|
3823
|
+
*/
|
|
3824
|
+
hasBinary() {
|
|
3825
|
+
return this.binaryName.length > 0 && this.binarySha256.length > 0;
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
/**
|
|
3830
|
+
* Plugin suggestion for a missing cap
|
|
3831
|
+
*/
|
|
3832
|
+
class PluginSuggestion {
|
|
3833
|
+
constructor(data) {
|
|
3834
|
+
this.pluginId = data.pluginId;
|
|
3835
|
+
this.pluginName = data.pluginName;
|
|
3836
|
+
this.pluginDescription = data.pluginDescription;
|
|
3837
|
+
this.capUrn = data.capUrn;
|
|
3838
|
+
this.capTitle = data.capTitle;
|
|
3839
|
+
this.latestVersion = data.latestVersion;
|
|
3840
|
+
this.repoUrl = data.repoUrl;
|
|
3841
|
+
this.pageUrl = data.pageUrl;
|
|
3842
|
+
}
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
/**
|
|
3846
|
+
* Plugin registry cache entry
|
|
3847
|
+
*/
|
|
3848
|
+
class PluginRepoCache {
|
|
3849
|
+
constructor(repoUrl) {
|
|
3850
|
+
this.plugins = new Map(); // plugin_id -> PluginInfo
|
|
3851
|
+
this.capToPlugins = new Map(); // cap_urn -> [plugin_ids]
|
|
3852
|
+
this.lastUpdated = Date.now();
|
|
3853
|
+
this.repoUrl = repoUrl;
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
|
|
3857
|
+
/**
|
|
3858
|
+
* Plugin repository client - fetches and caches plugin registry
|
|
3859
|
+
*/
|
|
3860
|
+
class PluginRepoClient {
|
|
3861
|
+
constructor(cacheTtlSeconds = 3600) {
|
|
3862
|
+
this.caches = new Map(); // repo_url -> PluginRepoCache
|
|
3863
|
+
this.cacheTtl = cacheTtlSeconds * 1000; // Convert to milliseconds
|
|
3864
|
+
}
|
|
3865
|
+
|
|
3866
|
+
/**
|
|
3867
|
+
* Fetch registry from a URL
|
|
3868
|
+
*/
|
|
3869
|
+
async fetchRegistry(repoUrl) {
|
|
3870
|
+
const response = await fetch(repoUrl);
|
|
3871
|
+
|
|
3872
|
+
if (!response.ok) {
|
|
3873
|
+
throw new Error(`Plugin registry request failed: HTTP ${response.status} from ${repoUrl}`);
|
|
3874
|
+
}
|
|
3875
|
+
|
|
3876
|
+
const data = await response.json();
|
|
3877
|
+
|
|
3878
|
+
if (!data.plugins || !Array.isArray(data.plugins)) {
|
|
3879
|
+
throw new Error(`Invalid plugin registry response from ${repoUrl}: missing plugins array`);
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3882
|
+
return data.plugins.map(p => new PluginInfo(p));
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
/**
|
|
3886
|
+
* Update cache from registry data
|
|
3887
|
+
*/
|
|
3888
|
+
updateCache(repoUrl, plugins) {
|
|
3889
|
+
const cache = new PluginRepoCache(repoUrl);
|
|
3890
|
+
|
|
3891
|
+
for (const plugin of plugins) {
|
|
3892
|
+
cache.plugins.set(plugin.id, plugin);
|
|
3893
|
+
|
|
3894
|
+
for (const cap of plugin.caps) {
|
|
3895
|
+
if (!cache.capToPlugins.has(cap.urn)) {
|
|
3896
|
+
cache.capToPlugins.set(cap.urn, []);
|
|
3897
|
+
}
|
|
3898
|
+
cache.capToPlugins.get(cap.urn).push(plugin.id);
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
this.caches.set(repoUrl, cache);
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
/**
|
|
3906
|
+
* Check if cache is stale
|
|
3907
|
+
*/
|
|
3908
|
+
isCacheStale(cache) {
|
|
3909
|
+
return (Date.now() - cache.lastUpdated) > this.cacheTtl;
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
/**
|
|
3913
|
+
* Sync plugin data from repository URLs
|
|
3914
|
+
*/
|
|
3915
|
+
async syncRepos(repoUrls) {
|
|
3916
|
+
for (const repoUrl of repoUrls) {
|
|
3917
|
+
try {
|
|
3918
|
+
const plugins = await this.fetchRegistry(repoUrl);
|
|
3919
|
+
this.updateCache(repoUrl, plugins);
|
|
3920
|
+
} catch (e) {
|
|
3921
|
+
console.warn(`Failed to sync plugin repo ${repoUrl}: ${e.message}`);
|
|
3922
|
+
// Continue with other repos
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
/**
|
|
3928
|
+
* Check if any repo needs syncing
|
|
3929
|
+
*/
|
|
3930
|
+
needsSync(repoUrls) {
|
|
3931
|
+
for (const repoUrl of repoUrls) {
|
|
3932
|
+
const cache = this.caches.get(repoUrl);
|
|
3933
|
+
if (!cache || this.isCacheStale(cache)) {
|
|
3934
|
+
return true;
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
return false;
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
/**
|
|
3941
|
+
* Get plugin suggestions for a cap URN
|
|
3942
|
+
*/
|
|
3943
|
+
getSuggestionsForCap(capUrn) {
|
|
3944
|
+
const suggestions = [];
|
|
3945
|
+
|
|
3946
|
+
for (const cache of this.caches.values()) {
|
|
3947
|
+
const pluginIds = cache.capToPlugins.get(capUrn);
|
|
3948
|
+
if (!pluginIds) continue;
|
|
3949
|
+
|
|
3950
|
+
for (const pluginId of pluginIds) {
|
|
3951
|
+
const plugin = cache.plugins.get(pluginId);
|
|
3952
|
+
if (!plugin) continue;
|
|
3953
|
+
|
|
3954
|
+
const capInfo = plugin.caps.find(c => c.urn === capUrn);
|
|
3955
|
+
if (!capInfo) continue;
|
|
3956
|
+
|
|
3957
|
+
const pageUrl = plugin.pageUrl || cache.repoUrl;
|
|
3958
|
+
|
|
3959
|
+
suggestions.push(new PluginSuggestion({
|
|
3960
|
+
pluginId: plugin.id,
|
|
3961
|
+
pluginName: plugin.name,
|
|
3962
|
+
pluginDescription: plugin.description,
|
|
3963
|
+
capUrn: capUrn,
|
|
3964
|
+
capTitle: capInfo.title,
|
|
3965
|
+
latestVersion: plugin.version,
|
|
3966
|
+
repoUrl: cache.repoUrl,
|
|
3967
|
+
pageUrl: pageUrl
|
|
3968
|
+
}));
|
|
3969
|
+
}
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3972
|
+
return suggestions;
|
|
3973
|
+
}
|
|
3974
|
+
|
|
3975
|
+
/**
|
|
3976
|
+
* Get all available plugins from all repos
|
|
3977
|
+
*/
|
|
3978
|
+
getAllPlugins() {
|
|
3979
|
+
const plugins = [];
|
|
3980
|
+
for (const cache of this.caches.values()) {
|
|
3981
|
+
for (const [pluginId, pluginInfo] of cache.plugins) {
|
|
3982
|
+
plugins.push([pluginId, pluginInfo]);
|
|
3983
|
+
}
|
|
3984
|
+
}
|
|
3985
|
+
return plugins;
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3988
|
+
/**
|
|
3989
|
+
* Get all available cap URNs from plugins
|
|
3990
|
+
*/
|
|
3991
|
+
getAllAvailableCaps() {
|
|
3992
|
+
const caps = new Set();
|
|
3993
|
+
for (const cache of this.caches.values()) {
|
|
3994
|
+
for (const capUrn of cache.capToPlugins.keys()) {
|
|
3995
|
+
caps.add(capUrn);
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
return Array.from(caps).sort();
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
/**
|
|
4002
|
+
* Get plugin info by ID
|
|
4003
|
+
*/
|
|
4004
|
+
getPlugin(pluginId) {
|
|
4005
|
+
for (const cache of this.caches.values()) {
|
|
4006
|
+
const plugin = cache.plugins.get(pluginId);
|
|
4007
|
+
if (plugin) {
|
|
4008
|
+
return plugin;
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
return null;
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
/**
|
|
4015
|
+
* Get suggestions for missing caps
|
|
4016
|
+
*/
|
|
4017
|
+
getSuggestionsForMissingCaps(availableCaps, requestedCaps) {
|
|
4018
|
+
const availableSet = new Set(availableCaps);
|
|
4019
|
+
const suggestions = [];
|
|
4020
|
+
|
|
4021
|
+
for (const capUrn of requestedCaps) {
|
|
4022
|
+
if (!availableSet.has(capUrn)) {
|
|
4023
|
+
suggestions.push(...this.getSuggestionsForCap(capUrn));
|
|
4024
|
+
}
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
return suggestions;
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
|
|
4031
|
+
/**
|
|
4032
|
+
* Plugin repository server - serves registry data with queries
|
|
4033
|
+
*/
|
|
4034
|
+
class PluginRepoServer {
|
|
4035
|
+
constructor(registry) {
|
|
4036
|
+
this.registry = registry;
|
|
4037
|
+
this.validateRegistry();
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
/**
|
|
4041
|
+
* Validate registry schema
|
|
4042
|
+
*/
|
|
4043
|
+
validateRegistry() {
|
|
4044
|
+
if (!this.registry) {
|
|
4045
|
+
throw new Error('Registry is required');
|
|
4046
|
+
}
|
|
4047
|
+
if (this.registry.schemaVersion !== '3.0') {
|
|
4048
|
+
throw new Error(`Unsupported registry schema version: ${this.registry.schemaVersion}. Required: 3.0`);
|
|
4049
|
+
}
|
|
4050
|
+
if (!this.registry.plugins || typeof this.registry.plugins !== 'object') {
|
|
4051
|
+
throw new Error('Registry must have plugins object');
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
/**
|
|
4056
|
+
* Validate version data has all required fields
|
|
4057
|
+
*/
|
|
4058
|
+
validateVersionData(id, version, versionData) {
|
|
4059
|
+
if (!versionData.platform) {
|
|
4060
|
+
throw new Error(`Plugin ${id} v${version}: missing required field 'platform'`);
|
|
4061
|
+
}
|
|
4062
|
+
if (!versionData.package || !versionData.package.name) {
|
|
4063
|
+
throw new Error(`Plugin ${id} v${version}: missing required field 'package'`);
|
|
4064
|
+
}
|
|
4065
|
+
if (!versionData.binary || !versionData.binary.name) {
|
|
4066
|
+
throw new Error(`Plugin ${id} v${version}: missing required field 'binary'`);
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
/**
|
|
4071
|
+
* Compare version strings
|
|
4072
|
+
*/
|
|
4073
|
+
compareVersions(a, b) {
|
|
4074
|
+
const partsA = a.split('.').map(x => parseInt(x) || 0);
|
|
4075
|
+
const partsB = b.split('.').map(x => parseInt(x) || 0);
|
|
4076
|
+
|
|
4077
|
+
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
|
4078
|
+
const numA = partsA[i] || 0;
|
|
4079
|
+
const numB = partsB[i] || 0;
|
|
4080
|
+
if (numA !== numB) {
|
|
4081
|
+
return numA - numB;
|
|
4082
|
+
}
|
|
4083
|
+
}
|
|
4084
|
+
return 0;
|
|
4085
|
+
}
|
|
4086
|
+
|
|
4087
|
+
/**
|
|
4088
|
+
* Build changelog map from versions
|
|
4089
|
+
*/
|
|
4090
|
+
buildChangelogMap(versions) {
|
|
4091
|
+
const changelog = {};
|
|
4092
|
+
for (const [version, versionData] of Object.entries(versions)) {
|
|
4093
|
+
if (versionData.changelog && Array.isArray(versionData.changelog)) {
|
|
4094
|
+
changelog[version] = versionData.changelog;
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
4097
|
+
return changelog;
|
|
4098
|
+
}
|
|
4099
|
+
|
|
4100
|
+
/**
|
|
4101
|
+
* Transform registry to flat plugin array
|
|
4102
|
+
*/
|
|
4103
|
+
transformToPluginArray() {
|
|
4104
|
+
const pluginsObject = this.registry.plugins || {};
|
|
4105
|
+
const plugins = [];
|
|
4106
|
+
|
|
4107
|
+
for (const [id, plugin] of Object.entries(pluginsObject)) {
|
|
4108
|
+
const latestVersion = plugin.latestVersion;
|
|
4109
|
+
const versionData = plugin.versions[latestVersion];
|
|
4110
|
+
|
|
4111
|
+
if (!versionData) {
|
|
4112
|
+
throw new Error(`Plugin ${id}: latest version ${latestVersion} not found in versions`);
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
// Validate required fields - fail hard
|
|
4116
|
+
this.validateVersionData(id, latestVersion, versionData);
|
|
4117
|
+
|
|
4118
|
+
// Get all version numbers sorted descending
|
|
4119
|
+
const availableVersions = Object.keys(plugin.versions).sort((a, b) => {
|
|
4120
|
+
return this.compareVersions(b, a);
|
|
4121
|
+
});
|
|
4122
|
+
|
|
4123
|
+
// Build flat plugin object with latest version data
|
|
4124
|
+
const packageUrl = `https://machinefabric.com/plugins/packages/${versionData.package.name}`;
|
|
4125
|
+
plugins.push({
|
|
4126
|
+
id,
|
|
4127
|
+
name: plugin.name,
|
|
4128
|
+
version: latestVersion,
|
|
4129
|
+
description: plugin.description,
|
|
4130
|
+
author: plugin.author,
|
|
4131
|
+
pageUrl: plugin.pageUrl || packageUrl,
|
|
4132
|
+
teamId: plugin.teamId,
|
|
4133
|
+
signedAt: versionData.releaseDate,
|
|
4134
|
+
minAppVersion: versionData.minAppVersion || plugin.minAppVersion,
|
|
4135
|
+
caps: plugin.caps || [],
|
|
4136
|
+
categories: plugin.categories,
|
|
4137
|
+
tags: plugin.tags,
|
|
4138
|
+
changelog: this.buildChangelogMap(plugin.versions),
|
|
4139
|
+
// Distribution fields - ALL REQUIRED
|
|
4140
|
+
platform: versionData.platform,
|
|
4141
|
+
packageName: versionData.package.name,
|
|
4142
|
+
packageSha256: versionData.package.sha256,
|
|
4143
|
+
packageSize: versionData.package.size,
|
|
4144
|
+
binaryName: versionData.binary.name,
|
|
4145
|
+
binarySha256: versionData.binary.sha256,
|
|
4146
|
+
binarySize: versionData.binary.size,
|
|
4147
|
+
// All available versions
|
|
4148
|
+
availableVersions
|
|
4149
|
+
});
|
|
4150
|
+
}
|
|
4151
|
+
|
|
4152
|
+
return plugins;
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4155
|
+
/**
|
|
4156
|
+
* Get all plugins (API response format)
|
|
4157
|
+
*/
|
|
4158
|
+
getPlugins() {
|
|
4159
|
+
return {
|
|
4160
|
+
plugins: this.transformToPluginArray()
|
|
4161
|
+
};
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
/**
|
|
4165
|
+
* Get plugin by ID
|
|
4166
|
+
*/
|
|
4167
|
+
getPluginById(id) {
|
|
4168
|
+
const plugins = this.transformToPluginArray();
|
|
4169
|
+
return plugins.find(p => p.id === id);
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
/**
|
|
4173
|
+
* Search plugins by query
|
|
4174
|
+
*/
|
|
4175
|
+
searchPlugins(query) {
|
|
4176
|
+
const plugins = this.transformToPluginArray();
|
|
4177
|
+
const lowerQuery = query.toLowerCase();
|
|
4178
|
+
|
|
4179
|
+
return plugins.filter(p =>
|
|
4180
|
+
p.name.toLowerCase().includes(lowerQuery) ||
|
|
4181
|
+
p.description.toLowerCase().includes(lowerQuery) ||
|
|
4182
|
+
p.tags.some(t => t.toLowerCase().includes(lowerQuery)) ||
|
|
4183
|
+
p.caps.some(c => c.urn.toLowerCase().includes(lowerQuery) || c.title.toLowerCase().includes(lowerQuery))
|
|
4184
|
+
);
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4187
|
+
/**
|
|
4188
|
+
* Get plugins by category
|
|
4189
|
+
*/
|
|
4190
|
+
getPluginsByCategory(category) {
|
|
4191
|
+
const plugins = this.transformToPluginArray();
|
|
4192
|
+
return plugins.filter(p => p.categories.includes(category));
|
|
4193
|
+
}
|
|
4194
|
+
|
|
4195
|
+
/**
|
|
4196
|
+
* Get plugins that provide a specific cap
|
|
4197
|
+
*/
|
|
4198
|
+
getPluginsByCap(capUrn) {
|
|
4199
|
+
const plugins = this.transformToPluginArray();
|
|
4200
|
+
return plugins.filter(p => p.caps.some(c => c.urn === capUrn));
|
|
4201
|
+
}
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
// Export for CommonJS
|
|
4205
|
+
module.exports = {
|
|
4206
|
+
CapUrn,
|
|
4207
|
+
CapUrnBuilder,
|
|
4208
|
+
CapMatcher,
|
|
4209
|
+
CapUrnError,
|
|
4210
|
+
ErrorCodes,
|
|
4211
|
+
MediaUrn,
|
|
4212
|
+
MediaUrnError,
|
|
4213
|
+
MediaUrnErrorCodes,
|
|
4214
|
+
Cap,
|
|
4215
|
+
CapArg,
|
|
4216
|
+
ArgSource,
|
|
4217
|
+
RegisteredBy,
|
|
4218
|
+
createCap,
|
|
4219
|
+
createCapWithDescription,
|
|
4220
|
+
createCapWithMetadata,
|
|
4221
|
+
createCapWithDescriptionAndMetadata,
|
|
4222
|
+
ValidationError,
|
|
4223
|
+
InputValidator,
|
|
4224
|
+
OutputValidator,
|
|
4225
|
+
CapValidator,
|
|
4226
|
+
validateCapArgs,
|
|
4227
|
+
RESERVED_CLI_FLAGS,
|
|
4228
|
+
MediaSpec,
|
|
4229
|
+
MediaSpecError,
|
|
4230
|
+
MediaSpecErrorCodes,
|
|
4231
|
+
isBinaryCapUrn,
|
|
4232
|
+
isJSONCapUrn,
|
|
4233
|
+
isStructuredCapUrn,
|
|
4234
|
+
resolveMediaUrn,
|
|
4235
|
+
buildExtensionIndex,
|
|
4236
|
+
mediaUrnsForExtension,
|
|
4237
|
+
getExtensionMappings,
|
|
4238
|
+
validateNoMediaSpecRedefinition,
|
|
4239
|
+
validateNoMediaSpecRedefinitionSync,
|
|
4240
|
+
validateNoMediaSpecDuplicates,
|
|
4241
|
+
getSchemaBaseURL,
|
|
4242
|
+
getProfileURL,
|
|
4243
|
+
MEDIA_STRING,
|
|
4244
|
+
MEDIA_INTEGER,
|
|
4245
|
+
MEDIA_NUMBER,
|
|
4246
|
+
MEDIA_BOOLEAN,
|
|
4247
|
+
MEDIA_OBJECT,
|
|
4248
|
+
MEDIA_STRING_ARRAY,
|
|
4249
|
+
MEDIA_INTEGER_ARRAY,
|
|
4250
|
+
MEDIA_NUMBER_ARRAY,
|
|
4251
|
+
MEDIA_BOOLEAN_ARRAY,
|
|
4252
|
+
MEDIA_OBJECT_ARRAY,
|
|
4253
|
+
MEDIA_BINARY,
|
|
4254
|
+
MEDIA_VOID,
|
|
4255
|
+
MEDIA_PNG,
|
|
4256
|
+
MEDIA_AUDIO,
|
|
4257
|
+
MEDIA_VIDEO,
|
|
4258
|
+
MEDIA_AUDIO_SPEECH,
|
|
4259
|
+
MEDIA_IMAGE_THUMBNAIL,
|
|
4260
|
+
// Document types (PRIMARY naming)
|
|
4261
|
+
MEDIA_PDF,
|
|
4262
|
+
MEDIA_EPUB,
|
|
4263
|
+
// Text format types (PRIMARY naming)
|
|
4264
|
+
MEDIA_MD,
|
|
4265
|
+
MEDIA_TXT,
|
|
4266
|
+
MEDIA_RST,
|
|
4267
|
+
MEDIA_LOG,
|
|
4268
|
+
MEDIA_HTML,
|
|
4269
|
+
MEDIA_XML,
|
|
4270
|
+
MEDIA_JSON,
|
|
4271
|
+
MEDIA_JSON_SCHEMA,
|
|
4272
|
+
MEDIA_YAML,
|
|
4273
|
+
MEDIA_MODEL_SPEC,
|
|
4274
|
+
MEDIA_MODEL_REPO,
|
|
4275
|
+
MEDIA_MODEL_DIM,
|
|
4276
|
+
MEDIA_DECISION,
|
|
4277
|
+
MEDIA_DECISION_ARRAY,
|
|
4278
|
+
// Semantic output types - model management
|
|
4279
|
+
MEDIA_DOWNLOAD_OUTPUT,
|
|
4280
|
+
MEDIA_LIST_OUTPUT,
|
|
4281
|
+
MEDIA_STATUS_OUTPUT,
|
|
4282
|
+
MEDIA_CONTENTS_OUTPUT,
|
|
4283
|
+
MEDIA_AVAILABILITY_OUTPUT,
|
|
4284
|
+
MEDIA_PATH_OUTPUT,
|
|
4285
|
+
// Semantic output types - inference
|
|
4286
|
+
MEDIA_EMBEDDING_VECTOR,
|
|
4287
|
+
MEDIA_LLM_INFERENCE_OUTPUT,
|
|
4288
|
+
MEDIA_FILE_METADATA,
|
|
4289
|
+
MEDIA_DOCUMENT_OUTLINE,
|
|
4290
|
+
MEDIA_DISBOUND_PAGE,
|
|
4291
|
+
MEDIA_IMAGE_DESCRIPTION,
|
|
4292
|
+
MEDIA_TRANSCRIPTION_OUTPUT,
|
|
4293
|
+
// File path types
|
|
4294
|
+
MEDIA_FILE_PATH,
|
|
4295
|
+
MEDIA_FILE_PATH_ARRAY,
|
|
4296
|
+
// Semantic text input types
|
|
4297
|
+
MEDIA_FRONTMATTER_TEXT,
|
|
4298
|
+
MEDIA_MLX_MODEL_PATH,
|
|
4299
|
+
// Unified argument type
|
|
4300
|
+
CapArgumentValue,
|
|
4301
|
+
// Standard cap URN builders
|
|
4302
|
+
llmConversationUrn,
|
|
4303
|
+
modelAvailabilityUrn,
|
|
4304
|
+
modelPathUrn,
|
|
4305
|
+
CapMatrixError,
|
|
4306
|
+
CapMatrix,
|
|
4307
|
+
BestCapSetMatch,
|
|
4308
|
+
CompositeCapSet,
|
|
4309
|
+
CapBlock,
|
|
4310
|
+
CapGraphEdge,
|
|
4311
|
+
CapGraphStats,
|
|
4312
|
+
CapGraph,
|
|
4313
|
+
StdinSource,
|
|
4314
|
+
StdinSourceKind,
|
|
4315
|
+
// Plugin Repository
|
|
4316
|
+
PluginCapSummary,
|
|
4317
|
+
PluginInfo,
|
|
4318
|
+
PluginSuggestion,
|
|
4319
|
+
PluginRepoCache,
|
|
4320
|
+
PluginRepoClient,
|
|
4321
|
+
PluginRepoServer
|
|
4322
|
+
};
|