@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.
@@ -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 (capability-context ruling 1).',
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
- const storageKeySchema = z.string().regex(/^[a-z0-9_-]+(?:\/[a-z0-9_-]+)?$/, {
43
- message: 'Storage keys are "<dataType>" or "<dataType>/<subtype>" in lowercase [a-z0-9_-] — they map onto the account/user product-data columns (capability-context ruling 6).',
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 (capability-context ruling 1), and clientReadable (default false) is what opts the key in to the browser-readable storage route.',
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 storageScopeSchema = z.record(storageKeySchema, storageKeyDeclarationRef);
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: storageScopeSchema.optional(),
365
- user: storageScopeSchema.optional(),
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
- assertPlainDeclaration(input);
563
- const result = IntegrationDefinitionSchema.safeParse(input);
564
- if (!result.success) {
565
- const slug = typeof (input === null || input === void 0 ? void 0 : input.slug) === 'string'
566
- ? ` for "${input.slug}"`
567
- : '';
568
- // The reminder is six lines about fields the author may not have written.
569
- // Appending it to EVERY failure buries the one line that matters a bad
570
- // semver or a malformed cron arrives under a paragraph about productId and
571
- // trust tiers. Show it only when an unrecognized key is what failed, which
572
- // is the case it was written for.
573
- const hasUnrecognizedKey = result.error.issues.some((issue) => issue.code === z.ZodIssueCode.unrecognized_keys);
574
- throw new Error(`Invalid integration definition${slug}:\n` +
575
- formatIssues(result.error.issues) +
576
- (hasUnrecognizedKey ? `\n${HOST_ASSIGNED_REMINDER}` : ''));
577
- }
578
- // F4: freeze inside the canonical parser so BOTH defineIntegration() and
579
- // registerPartnerIntegration() register immutable output the host boundary
580
- // no longer hands back mutable arrays. Function/zod leaves stay executable
581
- // by design (the T1 model), so this is structural immutability, not
582
- // behavioral.
583
- deepFreezeDefinition(result.data);
584
- // The schema's inferred output matches IntegrationDefinition in every
585
- // non-generic field; the storage/tool generics default to the permissive
586
- // base, which is exactly right for the loose registration boundary.
587
- return result.data;
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. Both call this one helper
599
- * (`packages/agents/src/tools/tool-discovery.ts`).
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
- * Detects cross-definition collisions (duplicate slugs, widget ids, tool
607
- * names across the partner set) AND collisions against the first-party
608
- * inventory, throwing one error listing EVERY collision.
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
- export function validateIntegrationDefinitions(definitions, firstParty = {}) {
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 collisions = [];
775
+ const records = [];
639
776
  for (const [slug, count] of slugCounts) {
640
777
  if (count > 1) {
641
- collisions.push(`slug "${slug}" is declared by ${count} partner definitions — slugs are the registry key and must be globally unique.`);
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
- collisions.push(`widget id "${id}" is declared by [${owners.join(', ')}] — widget ids are global (widget_config rows key on them); prefix yours with the integration slug.`);
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
- collisions.push(`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.`);
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
- collisions.push(`slug "${slug}" (declared by [${owners.join(', ')}]) collides with a first-party integration — pick a slug no built-in product uses.`);
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
- collisions.push(`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.`);
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
- collisions.push(`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.`);
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') +