@ekanos/integration-schema 0.1.1 → 0.1.3
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 +19 -12
- package/dist/capability-context.d.ts +1 -1
- package/dist/capability-context.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/integration-definition.d.ts +72 -12
- package/dist/integration-definition.js +239 -60
- package/dist/integration-definition.js.map +1 -1
- package/dist/product-id.d.ts +1 -1
- package/dist/product-id.js +1 -1
- package/dist/product-id.js.map +1 -1
- package/dist/workspace-target.d.ts +1 -1
- package/dist/workspace-target.js +1 -1
- package/dist/workspace-target.js.map +1 -1
- package/package.json +5 -5
|
@@ -30,7 +30,7 @@ function componentRefSchema(what) {
|
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
32
|
const zodSchemaRef = z.custom((value) => typeof (value === null || value === void 0 ? void 0 : value.safeParse) === 'function', {
|
|
33
|
-
message: 'Every storage key must declare a zod schema (e.g. z.object({ … })) — ctx.storage validates reads and writes against it
|
|
33
|
+
message: 'Every storage key must declare a zod schema (e.g. z.object({ … })) — ctx.storage validates reads and writes against it.',
|
|
34
34
|
});
|
|
35
35
|
const nonEmpty = (what) => z.string().min(1, { message: `${what} must be a non-empty string.` });
|
|
36
36
|
const slugSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
|
@@ -39,9 +39,90 @@ const slugSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
|
|
39
39
|
const widgetIdSchema = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
|
40
40
|
message: 'Widget ids are kebab-case and globally unique, e.g. "acme-crm-pipeline" — prefix with the integration slug to stay collision-free.',
|
|
41
41
|
});
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The `data_type` values each storage scope permits.
|
|
44
|
+
*
|
|
45
|
+
* THIS IS THE SOURCE OF TRUTH, and it lives here rather than in the host
|
|
46
|
+
* because this package is the one a partner's `defineIntegration()` validates
|
|
47
|
+
* against — a declaration naming an impossible data_type should fail at
|
|
48
|
+
* authoring time, not on the first `ctx.storage` call in production. The host
|
|
49
|
+
* (`apps/web/lib/server/integration-context.ts`) imports these same arrays for
|
|
50
|
+
* its runtime check, so the two cannot drift.
|
|
51
|
+
*
|
|
52
|
+
* They mirror the DB CHECK constraints on `account_product_data` and
|
|
53
|
+
* `user_product_data` (`apps/web/supabase/schemas/26-integrations.sql`), with
|
|
54
|
+
* one deliberate subtraction: see `RESERVED_STORAGE_DATA_TYPES`.
|
|
55
|
+
*/
|
|
56
|
+
export const ACCOUNT_STORAGE_DATA_TYPES = [
|
|
57
|
+
'activation',
|
|
58
|
+
'settings',
|
|
59
|
+
'metrics_summary',
|
|
60
|
+
'sync_state',
|
|
61
|
+
'cache',
|
|
62
|
+
];
|
|
63
|
+
export const USER_STORAGE_DATA_TYPES = [
|
|
64
|
+
'config',
|
|
65
|
+
'preferences',
|
|
66
|
+
'cache',
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* In the DB CHECK but NEVER addressable through `ctx.storage`.
|
|
70
|
+
*
|
|
71
|
+
* `secret` rows are host-managed per-name credential rows whose values live in
|
|
72
|
+
* Vault; partner code reaches them only through `ctx.secrets`, which never
|
|
73
|
+
* exposes a value or a vault id. Letting a partner DECLARE `secret` storage
|
|
74
|
+
* would hand them a key that collides with host-managed secret rows, so it is
|
|
75
|
+
* rejected here with its own message rather than the generic "not an allowed
|
|
76
|
+
* data_type" — an author who wrote `secret` meant something specific and needs
|
|
77
|
+
* to be pointed at `ctx.secrets`.
|
|
78
|
+
*
|
|
79
|
+
* Kept as its own list rather than simply omitted from the arrays above so the
|
|
80
|
+
* reason survives: `secret` is a real column value, not a typo.
|
|
81
|
+
*/
|
|
82
|
+
export const RESERVED_STORAGE_DATA_TYPES = ['secret'];
|
|
83
|
+
/** VARCHAR(100) on both the data_type and data_subtype columns. */
|
|
84
|
+
const MAX_STORAGE_SEGMENT_LENGTH = 100;
|
|
85
|
+
const STORAGE_KEY_SHAPE = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)?$/;
|
|
86
|
+
/**
|
|
87
|
+
* A storage key is `"<dataType>"` or `"<dataType>/<subtype>"`, mapped onto the
|
|
88
|
+
* `(data_type, data_subtype)` columns. Scope-specific because the two tables
|
|
89
|
+
* carry different CHECK constraints — `settings` is an account data_type and
|
|
90
|
+
* `preferences` a user one, and neither is valid in the other's scope.
|
|
91
|
+
*/
|
|
92
|
+
function storageKeySchemaFor(scope) {
|
|
93
|
+
const allowed = scope === 'account' ? ACCOUNT_STORAGE_DATA_TYPES : USER_STORAGE_DATA_TYPES;
|
|
94
|
+
return z.string().superRefine((key, ctx) => {
|
|
95
|
+
const fail = (message) => ctx.addIssue({ code: z.ZodIssueCode.custom, message });
|
|
96
|
+
if (!STORAGE_KEY_SHAPE.test(key)) {
|
|
97
|
+
fail(`Storage key "${key}" is malformed. Keys are "<dataType>" or ` +
|
|
98
|
+
`"<dataType>/<subtype>" in lowercase [a-z0-9_-], with at most one ` +
|
|
99
|
+
`"/" and no empty segment.`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const slashIndex = key.indexOf('/');
|
|
103
|
+
const dataType = slashIndex === -1 ? key : key.slice(0, slashIndex);
|
|
104
|
+
const dataSubtype = slashIndex === -1 ? undefined : key.slice(slashIndex + 1);
|
|
105
|
+
if (RESERVED_STORAGE_DATA_TYPES.includes(dataType)) {
|
|
106
|
+
fail(`Storage key "${key}" uses the reserved "${dataType}" data_type, ` +
|
|
107
|
+
`which ctx.storage can never read or write. Integration secrets are ` +
|
|
108
|
+
`host-managed — declare nothing here and use ctx.secrets.get/set/names ` +
|
|
109
|
+
`instead; a secret's value and its vault id are never reachable ` +
|
|
110
|
+
`through ctx.storage.`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (!allowed.includes(dataType)) {
|
|
114
|
+
fail(`Storage key "${key}" is invalid for the ${scope} scope: ` +
|
|
115
|
+
`"${dataType}" is not an allowed ${scope} data_type. Use one of ` +
|
|
116
|
+
`[${allowed.join(', ')}] (the database CHECK constraint).`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (dataSubtype !== undefined &&
|
|
120
|
+
dataSubtype.length > MAX_STORAGE_SEGMENT_LENGTH) {
|
|
121
|
+
fail(`Storage key "${key}" has a subtype longer than ` +
|
|
122
|
+
`${MAX_STORAGE_SEGMENT_LENGTH} characters (the column width).`);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
45
126
|
const toolNameSchema = z.string().regex(/^[a-z][a-z0-9_]*$/, {
|
|
46
127
|
message: 'Tool names are lowercase snake_case starting with a letter, e.g. "list_invoices" — the model calls them by this exact string.',
|
|
47
128
|
});
|
|
@@ -312,7 +393,7 @@ const storageKeyDeclarationRef = z
|
|
|
312
393
|
!isZodSchemaLike(value.schema)) {
|
|
313
394
|
ctx.addIssue({
|
|
314
395
|
code: z.ZodIssueCode.custom,
|
|
315
|
-
message: 'Every storage key must declare either a zod schema (e.g. z.object({ … })) or a { schema, clientReadable } descriptor — ctx.storage validates reads and writes against the schema
|
|
396
|
+
message: 'Every storage key must declare either a zod schema (e.g. z.object({ … })) or a { schema, clientReadable } descriptor — ctx.storage validates reads and writes against the schema, and clientReadable (default false) is what opts the key in to the browser-readable storage route.',
|
|
316
397
|
});
|
|
317
398
|
return;
|
|
318
399
|
}
|
|
@@ -330,7 +411,7 @@ const storageKeyDeclarationRef = z
|
|
|
330
411
|
});
|
|
331
412
|
}
|
|
332
413
|
});
|
|
333
|
-
const
|
|
414
|
+
const storageScopeSchemaFor = (scope) => z.record(storageKeySchemaFor(scope), storageKeyDeclarationRef);
|
|
334
415
|
const componentsSchema = z
|
|
335
416
|
.object({
|
|
336
417
|
activationForm: componentRefSchema('components.activationForm').optional(),
|
|
@@ -361,8 +442,8 @@ export const IntegrationDefinitionSchema = z
|
|
|
361
442
|
tools: z.array(toolSchema).optional(),
|
|
362
443
|
storage: z
|
|
363
444
|
.object({
|
|
364
|
-
account:
|
|
365
|
-
user:
|
|
445
|
+
account: storageScopeSchemaFor('account').optional(),
|
|
446
|
+
user: storageScopeSchemaFor('user').optional(),
|
|
366
447
|
})
|
|
367
448
|
.strict()
|
|
368
449
|
.optional(),
|
|
@@ -429,14 +510,6 @@ function findDuplicates(values) {
|
|
|
429
510
|
}
|
|
430
511
|
return [...duplicates];
|
|
431
512
|
}
|
|
432
|
-
function formatIssues(issues) {
|
|
433
|
-
return issues
|
|
434
|
-
.map((issue) => {
|
|
435
|
-
const path = issue.path.length > 0 ? issue.path.join('.') : '(root)';
|
|
436
|
-
return ` - ${path}: ${issue.message}`;
|
|
437
|
-
})
|
|
438
|
-
.join('\n');
|
|
439
|
-
}
|
|
440
513
|
const HOST_ASSIGNED_REMINDER = 'Host-assigned fields are never partner-authorable: productId, kind, trust ' +
|
|
441
514
|
'tier, and machine exposure (credentialModel) do not exist on ' +
|
|
442
515
|
'IntegrationDefinition, per-tool effect/sensitivity belong in the ' +
|
|
@@ -444,6 +517,70 @@ const HOST_ASSIGNED_REMINDER = 'Host-assigned fields are never partner-authorabl
|
|
|
444
517
|
'widgetConfigId, workspaceId, collapsed, isPinned, health, ' +
|
|
445
518
|
'integrationMetadata) are populated by the platform at runtime — remove ' +
|
|
446
519
|
'them from the definition.';
|
|
520
|
+
const PLAIN_DATA_HINT = 'Declarations must be finite plain data. Remove the cycle, getter/setter, ' +
|
|
521
|
+
'or class/exotic instance the message names so the value cannot change ' +
|
|
522
|
+
'after validation, then re-run validate.';
|
|
523
|
+
/** Per-issue remediation. Unrecognized keys get the host-assigned reminder. */
|
|
524
|
+
function hintForIssue(issue) {
|
|
525
|
+
if (issue.code === z.ZodIssueCode.unrecognized_keys) {
|
|
526
|
+
return HOST_ASSIGNED_REMINDER;
|
|
527
|
+
}
|
|
528
|
+
const path = issue.path.length > 0 ? issue.path.join('.') : '(root)';
|
|
529
|
+
return (`Correct the value at "${path}" so it satisfies ` +
|
|
530
|
+
`@ekanos/integration-schema's IntegrationDefinitionSchema, then re-run ` +
|
|
531
|
+
`validate.`);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* The single, shared implementation behind BOTH the non-throwing collector and
|
|
535
|
+
* the throwing `parseIntegrationDefinition`. Runs the plain-data structural
|
|
536
|
+
* check first (short-circuiting exactly as the throwing path always has), then
|
|
537
|
+
* the canonical zod parse. Returns the findings, the sanitized `data` on
|
|
538
|
+
* success, and whether an unrecognized key was among the failures (the
|
|
539
|
+
* throwing path appends the host-assigned reminder only in that case).
|
|
540
|
+
*/
|
|
541
|
+
function collectDefinitionResult(input, options = {}) {
|
|
542
|
+
const { file } = options;
|
|
543
|
+
// Structural pre-check: the same rule assertPlainDeclaration throws on, but
|
|
544
|
+
// captured as a finding. If it fails we stop here, matching the throwing
|
|
545
|
+
// path which never reaches safeParse once the pre-check throws.
|
|
546
|
+
try {
|
|
547
|
+
assertPlainDeclaration(input);
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
return {
|
|
551
|
+
findings: [
|
|
552
|
+
Object.assign(Object.assign({ check: 'definition.plain-data', severity: 'error' }, (file ? { file } : {})), { message: error instanceof Error ? error.message : String(error), hint: PLAIN_DATA_HINT }),
|
|
553
|
+
],
|
|
554
|
+
hasUnrecognizedKey: false,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
const result = IntegrationDefinitionSchema.safeParse(input);
|
|
558
|
+
if (result.success) {
|
|
559
|
+
deepFreezeDefinition(result.data);
|
|
560
|
+
return {
|
|
561
|
+
findings: [],
|
|
562
|
+
data: result.data,
|
|
563
|
+
hasUnrecognizedKey: false,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
const hasUnrecognizedKey = result.error.issues.some((issue) => issue.code === z.ZodIssueCode.unrecognized_keys);
|
|
567
|
+
const findings = result.error.issues.map((issue) => {
|
|
568
|
+
const path = issue.path.length > 0 ? issue.path.join('.') : '(root)';
|
|
569
|
+
return Object.assign(Object.assign({ check: 'definition.schema', severity: 'error' }, (file ? { file } : {})), {
|
|
570
|
+
// The zod path, not a source line — see the Finding doc comment.
|
|
571
|
+
message: `${path}: ${issue.message}`, hint: hintForIssue(issue) });
|
|
572
|
+
});
|
|
573
|
+
return { findings, hasUnrecognizedKey };
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Non-throwing sibling of `parseIntegrationDefinition`: validates a single
|
|
577
|
+
* integration definition against the canonical schema and returns structured
|
|
578
|
+
* findings instead of throwing a pre-formatted string. An empty array means
|
|
579
|
+
* the definition is valid. Used by `@ekanos/cli validate`.
|
|
580
|
+
*/
|
|
581
|
+
export function collectDefinitionFindings(input, options = {}) {
|
|
582
|
+
return collectDefinitionResult(input, options).findings;
|
|
583
|
+
}
|
|
447
584
|
/**
|
|
448
585
|
* A zod schema (storage leaf) or a React exotic (`memo`/`forwardRef`, an
|
|
449
586
|
* object tagged with `$$typeof`). NOTHING here reads a property value:
|
|
@@ -559,32 +696,37 @@ export function deepFreezeDefinition(value) {
|
|
|
559
696
|
* whose message is a remediation instruction.
|
|
560
697
|
*/
|
|
561
698
|
export function parseIntegrationDefinition(input) {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
// The
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
|
|
699
|
+
// Built ON TOP of the collector so there is exactly one rule set. The
|
|
700
|
+
// collector runs assertPlainDeclaration first (short-circuiting), then the
|
|
701
|
+
// canonical parse, and freezes `data` on success (F4) — so a successful
|
|
702
|
+
// result here is already the deep-frozen, sanitized definition.
|
|
703
|
+
const { findings, data, hasUnrecognizedKey } = collectDefinitionResult(input);
|
|
704
|
+
if (data) {
|
|
705
|
+
// The schema's inferred output matches IntegrationDefinition in every
|
|
706
|
+
// non-generic field; the storage/tool generics default to the permissive
|
|
707
|
+
// base, which is exactly right for the loose registration boundary.
|
|
708
|
+
return data;
|
|
709
|
+
}
|
|
710
|
+
// A plain-data structural failure is thrown verbatim (its message is already
|
|
711
|
+
// a remediation: "…contains a cycle…", "…is a getter/setter…").
|
|
712
|
+
const plainDataFinding = findings.find((finding) => finding.check === 'definition.plain-data');
|
|
713
|
+
if (plainDataFinding) {
|
|
714
|
+
throw new Error(plainDataFinding.message);
|
|
715
|
+
}
|
|
716
|
+
// Schema failures reproduce the historical message shape exactly: each
|
|
717
|
+
// finding's message is already `${path}: ${issue.message}`, so re-prefixing
|
|
718
|
+
// with ` - ` reconstructs formatIssues() verbatim.
|
|
719
|
+
const slug = typeof (input === null || input === void 0 ? void 0 : input.slug) === 'string'
|
|
720
|
+
? ` for "${input.slug}"`
|
|
721
|
+
: '';
|
|
722
|
+
// The reminder is six lines about fields the author may not have written.
|
|
723
|
+
// Appending it to EVERY failure buries the one line that matters — a bad
|
|
724
|
+
// semver or a malformed cron arrives under a paragraph about productId and
|
|
725
|
+
// trust tiers. Show it only when an unrecognized key is what failed, which
|
|
726
|
+
// is the case it was written for.
|
|
727
|
+
throw new Error(`Invalid integration definition${slug}:\n` +
|
|
728
|
+
findings.map((finding) => ` - ${finding.message}`).join('\n') +
|
|
729
|
+
(hasUnrecognizedKey ? `\n${HOST_ASSIGNED_REMINDER}` : ''));
|
|
588
730
|
}
|
|
589
731
|
// ---- Cross-definition collision detection (F5) -----------------------------
|
|
590
732
|
/**
|
|
@@ -595,24 +737,19 @@ export function parseIntegrationDefinition(input) {
|
|
|
595
737
|
* to the same EFFECTIVE name (`{slug:"foo",tool:"bar_baz"}` and
|
|
596
738
|
* `{slug:"foo-bar",tool:"baz"}` both become `foo_bar_baz`), so collision
|
|
597
739
|
* checking MUST compare effective names, and runtime discovery MUST throw on a
|
|
598
|
-
* duplicate assignment.
|
|
599
|
-
*
|
|
740
|
+
* duplicate assignment. Host-side tool discovery calls this same helper, so
|
|
741
|
+
* there is one definition of the effective name.
|
|
600
742
|
*/
|
|
601
743
|
export function getDiscoveredToolName(slug, rawName) {
|
|
602
744
|
const slugPrefix = slug.replace(/-/g, '_');
|
|
603
745
|
return rawName.startsWith(slugPrefix) ? rawName : `${slugPrefix}_${rawName}`;
|
|
604
746
|
}
|
|
605
747
|
/**
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
* This is the build-time gate the host registry deliberately lacks:
|
|
611
|
-
* `integrationRegistry.register()` keys by slug via `Map.set` and silently
|
|
612
|
-
* OVERWRITES, and duplicate widget/tool ids resolve last- or
|
|
613
|
-
* first-registration-wins by import order.
|
|
748
|
+
* THE shared collision rule set, returning structured records. Both the
|
|
749
|
+
* throwing `validateIntegrationDefinitions` and the non-throwing
|
|
750
|
+
* `collectCollisionFindings` are built on this, so there is one rule set.
|
|
614
751
|
*/
|
|
615
|
-
|
|
752
|
+
function computeCollisionRecords(definitions, firstParty = {}) {
|
|
616
753
|
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
617
754
|
const slugCounts = new Map();
|
|
618
755
|
const widgetOwners = new Map();
|
|
@@ -635,20 +772,32 @@ export function validateIntegrationDefinitions(definitions, firstParty = {}) {
|
|
|
635
772
|
]);
|
|
636
773
|
}
|
|
637
774
|
}
|
|
638
|
-
const
|
|
775
|
+
const records = [];
|
|
639
776
|
for (const [slug, count] of slugCounts) {
|
|
640
777
|
if (count > 1) {
|
|
641
|
-
|
|
778
|
+
records.push({
|
|
779
|
+
check: 'collision.slug',
|
|
780
|
+
message: `slug "${slug}" is declared by ${count} partner definitions — slugs are the registry key and must be globally unique.`,
|
|
781
|
+
hint: `Rename all but one of the definitions declaring slug "${slug}" so every slug is globally unique.`,
|
|
782
|
+
});
|
|
642
783
|
}
|
|
643
784
|
}
|
|
644
785
|
for (const [id, owners] of widgetOwners) {
|
|
645
786
|
if (owners.length > 1) {
|
|
646
|
-
|
|
787
|
+
records.push({
|
|
788
|
+
check: 'collision.widget-id',
|
|
789
|
+
message: `widget id "${id}" is declared by [${owners.join(', ')}] — widget ids are global (widget_config rows key on them); prefix yours with the integration slug.`,
|
|
790
|
+
hint: `Prefix widget id "${id}" with your integration slug so it is globally unique.`,
|
|
791
|
+
});
|
|
647
792
|
}
|
|
648
793
|
}
|
|
649
794
|
for (const [name, owners] of toolOwners) {
|
|
650
795
|
if (owners.length > 1) {
|
|
651
|
-
|
|
796
|
+
records.push({
|
|
797
|
+
check: 'collision.tool-name',
|
|
798
|
+
message: `effective tool name "${name}" is declared by [${owners.join(', ')}] — discovery namespaces tool names by slug, so these collapse to one flat key and overwrite each other. Rename so the slug-prefixed names differ.`,
|
|
799
|
+
hint: `Rename the colliding tools so their slug-prefixed effective names differ from "${name}".`,
|
|
800
|
+
});
|
|
652
801
|
}
|
|
653
802
|
}
|
|
654
803
|
const reservedSlugs = new Set((_g = firstParty.slugs) !== null && _g !== void 0 ? _g : []);
|
|
@@ -656,19 +805,49 @@ export function validateIntegrationDefinitions(definitions, firstParty = {}) {
|
|
|
656
805
|
const reservedTools = new Set((_j = firstParty.toolNames) !== null && _j !== void 0 ? _j : []);
|
|
657
806
|
for (const [slug, owners] of groupOwners(definitions, (d) => [d.slug])) {
|
|
658
807
|
if (reservedSlugs.has(slug)) {
|
|
659
|
-
|
|
808
|
+
records.push({
|
|
809
|
+
check: 'collision.reserved-slug',
|
|
810
|
+
message: `slug "${slug}" (declared by [${owners.join(', ')}]) collides with a first-party integration — pick a slug no built-in product uses.`,
|
|
811
|
+
hint: `Choose a different slug than "${slug}" — it is reserved by a first-party integration.`,
|
|
812
|
+
});
|
|
660
813
|
}
|
|
661
814
|
}
|
|
662
815
|
for (const [id, owners] of widgetOwners) {
|
|
663
816
|
if (reservedWidgets.has(id)) {
|
|
664
|
-
|
|
817
|
+
records.push({
|
|
818
|
+
check: 'collision.reserved-widget-id',
|
|
819
|
+
message: `widget id "${id}" (declared by [${owners.join(', ')}]) collides with a first-party widget — the dashboard resolves widgets by id, so this would hijack it. Prefix with the integration slug.`,
|
|
820
|
+
hint: `Prefix widget id "${id}" with your integration slug — it is reserved by a first-party widget.`,
|
|
821
|
+
});
|
|
665
822
|
}
|
|
666
823
|
}
|
|
667
824
|
for (const [name, owners] of toolOwners) {
|
|
668
825
|
if (reservedTools.has(name)) {
|
|
669
|
-
|
|
826
|
+
records.push({
|
|
827
|
+
check: 'collision.reserved-tool-name',
|
|
828
|
+
message: `effective tool name "${name}" (declared by [${owners.join(', ')}]) collides with a first-party tool — the flat, slug-namespaced tool registry would overwrite one with the other. Rename it.`,
|
|
829
|
+
hint: `Rename the tool so its effective name is not "${name}" — that name is reserved by a first-party tool.`,
|
|
830
|
+
});
|
|
670
831
|
}
|
|
671
832
|
}
|
|
833
|
+
return records;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Non-throwing sibling of `validateIntegrationDefinitions`: returns structured
|
|
837
|
+
* collision findings across the partner set (and against the first-party
|
|
838
|
+
* inventory) instead of throwing. An empty array means no collisions. Used by
|
|
839
|
+
* `@ekanos/cli validate`.
|
|
840
|
+
*/
|
|
841
|
+
export function collectCollisionFindings(definitions, firstParty = {}) {
|
|
842
|
+
return computeCollisionRecords(definitions, firstParty).map((record) => ({
|
|
843
|
+
check: record.check,
|
|
844
|
+
severity: 'error',
|
|
845
|
+
message: record.message,
|
|
846
|
+
hint: record.hint,
|
|
847
|
+
}));
|
|
848
|
+
}
|
|
849
|
+
export function validateIntegrationDefinitions(definitions, firstParty = {}) {
|
|
850
|
+
const collisions = computeCollisionRecords(definitions, firstParty).map((record) => record.message);
|
|
672
851
|
if (collisions.length > 0) {
|
|
673
852
|
throw new Error(`Integration definitions collide (${collisions.length} collision${collisions.length === 1 ? '' : 's'}):\n` +
|
|
674
853
|
collisions.map((line) => ` - ${line}`).join('\n') +
|