@x12i/catalox 3.6.0 → 3.7.1

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.
Files changed (30) hide show
  1. package/README.md +49 -9
  2. package/dist/src/catalox/catalox-bound.d.ts +6 -1
  3. package/dist/src/catalox/catalox-bound.d.ts.map +1 -1
  4. package/dist/src/catalox/catalox-bound.js +9 -0
  5. package/dist/src/catalox/catalox-bound.js.map +1 -1
  6. package/dist/src/catalox/catalox.d.ts +11 -2
  7. package/dist/src/catalox/catalox.d.ts.map +1 -1
  8. package/dist/src/catalox/catalox.js +278 -17
  9. package/dist/src/catalox/catalox.js.map +1 -1
  10. package/dist/src/contracts/descriptors.d.ts +6 -0
  11. package/dist/src/contracts/descriptors.d.ts.map +1 -1
  12. package/dist/src/contracts/index.d.ts +4 -4
  13. package/dist/src/contracts/index.d.ts.map +1 -1
  14. package/dist/src/contracts/index.js.map +1 -1
  15. package/dist/src/contracts/inputs.d.ts +18 -0
  16. package/dist/src/contracts/inputs.d.ts.map +1 -1
  17. package/dist/src/contracts/presentation.d.ts +35 -0
  18. package/dist/src/contracts/presentation.d.ts.map +1 -1
  19. package/dist/src/contracts/references.d.ts +40 -0
  20. package/dist/src/contracts/references.d.ts.map +1 -1
  21. package/dist/src/contracts/render-map.d.ts +12 -0
  22. package/dist/src/contracts/render-map.d.ts.map +1 -1
  23. package/dist/src/firebase/reference-store.d.ts +1 -0
  24. package/dist/src/firebase/reference-store.d.ts.map +1 -1
  25. package/dist/src/firebase/reference-store.js +7 -0
  26. package/dist/src/firebase/reference-store.js.map +1 -1
  27. package/dist/src/validation/ui-spec-schema.d.ts.map +1 -1
  28. package/dist/src/validation/ui-spec-schema.js +25 -0
  29. package/dist/src/validation/ui-spec-schema.js.map +1 -1
  30. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { compactCatalogFilter } from "../contracts/catalogs.js";
2
- import { CatalogAccessDeniedError, CatalogAdapterError, CatalogBindingError, CatalogNotFoundError, } from "../contracts/errors.js";
2
+ import { CatalogAccessDeniedError, CatalogAdapterError, CatalogBindingError, CatalogNotFoundError, CatalogValidationError, } from "../contracts/errors.js";
3
3
  import { validateMappingSpec } from "../mapping/validate-mapping.js";
4
4
  import { executeMapping } from "../mapping/execute-mapping.js";
5
5
  import { ApiCatalogAdapter } from "../adapters/api/api-adapter.js";
@@ -52,6 +52,124 @@ export class Catalox {
52
52
  constructor(deps) {
53
53
  this.deps = deps;
54
54
  }
55
+ encodeRefSegment(value) {
56
+ // Keep IDs stable and URL-safe for use as Firestore doc ids.
57
+ return encodeURIComponent(String(value));
58
+ }
59
+ buildReferenceId(input) {
60
+ const parts = [
61
+ "ref",
62
+ this.encodeRefSegment(input.fromCatalogId),
63
+ this.encodeRefSegment(input.fromItemId),
64
+ this.encodeRefSegment(input.relationType),
65
+ this.encodeRefSegment(input.toCatalogId),
66
+ this.encodeRefSegment(input.toItemId),
67
+ ];
68
+ return parts.join("|");
69
+ }
70
+ normalizeRelationWrites(value) {
71
+ if (!Array.isArray(value))
72
+ return [];
73
+ const out = [];
74
+ for (const v of value) {
75
+ if (!v || typeof v !== "object")
76
+ continue;
77
+ const r = v;
78
+ if (r.toCatalogId == null || r.toItemId == null || r.relationType == null)
79
+ continue;
80
+ out.push({
81
+ toCatalogId: String(r.toCatalogId),
82
+ toItemId: String(r.toItemId),
83
+ relationType: String(r.relationType),
84
+ ...(r.label != null ? { label: String(r.label) } : {}),
85
+ ...(r.metadata != null && typeof r.metadata === "object" ? { metadata: r.metadata } : {}),
86
+ });
87
+ }
88
+ return out;
89
+ }
90
+ async validateRelationsAgainstDescriptor(context, params) {
91
+ const rules = params.descriptor?.relationRules;
92
+ if (!rules?.length)
93
+ return { isValid: true, issues: [] };
94
+ const ruleByType = new Map();
95
+ for (const r of rules) {
96
+ if (r && typeof r === "object" && r.relationType != null) {
97
+ ruleByType.set(String(r.relationType), r);
98
+ }
99
+ }
100
+ const issues = [];
101
+ const counts = new Map();
102
+ for (const ref of params.refs) {
103
+ const rt = String(ref.relationType);
104
+ counts.set(rt, (counts.get(rt) ?? 0) + 1);
105
+ const rule = ruleByType.get(rt);
106
+ if (!rule) {
107
+ issues.push({
108
+ code: "relation.disallowed_type",
109
+ severity: "error",
110
+ message: `relationType "${rt}" is not allowed by descriptor for catalog "${String(params.catalogId)}".`,
111
+ path: "relations",
112
+ });
113
+ continue;
114
+ }
115
+ if (Array.isArray(rule.targetCatalogIds) && rule.targetCatalogIds.length) {
116
+ const allowed = new Set(rule.targetCatalogIds.map((x) => String(x)));
117
+ if (!allowed.has(String(ref.toCatalogId))) {
118
+ issues.push({
119
+ code: "relation.disallowed_target_catalog",
120
+ severity: "error",
121
+ message: `relationType "${rt}" cannot target catalog "${String(ref.toCatalogId)}".`,
122
+ path: "relations",
123
+ });
124
+ }
125
+ }
126
+ if (Array.isArray(rule.targetCatalogTypes) && rule.targetCatalogTypes.length) {
127
+ try {
128
+ const targetDescriptor = await this.getCatalogDescriptor(context, ref.toCatalogId);
129
+ const targetType = targetDescriptor?.catalogType ? String(targetDescriptor.catalogType) : undefined;
130
+ const allowedTypes = new Set(rule.targetCatalogTypes.map((x) => String(x)));
131
+ if (!targetType || !allowedTypes.has(targetType)) {
132
+ issues.push({
133
+ code: "relation.disallowed_target_type",
134
+ severity: "error",
135
+ message: `relationType "${rt}" cannot target catalogType "${String(targetType ?? "unknown")}".`,
136
+ path: "relations",
137
+ });
138
+ }
139
+ }
140
+ catch {
141
+ issues.push({
142
+ code: "relation.target_type_unverifiable",
143
+ severity: "warning",
144
+ message: `relationType "${rt}" target catalogType could not be verified for "${String(ref.toCatalogId)}".`,
145
+ path: "relations",
146
+ });
147
+ }
148
+ }
149
+ }
150
+ for (const [rt, rule] of ruleByType.entries()) {
151
+ const required = Boolean(rule.required);
152
+ const multiple = rule.multiple !== false;
153
+ const count = counts.get(rt) ?? 0;
154
+ if (required && count === 0) {
155
+ issues.push({
156
+ code: "relation.missing_required",
157
+ severity: "error",
158
+ message: `Missing required relationType "${rt}".`,
159
+ path: "relations",
160
+ });
161
+ }
162
+ if (!multiple && count > 1) {
163
+ issues.push({
164
+ code: "relation.too_many",
165
+ severity: "error",
166
+ message: `relationType "${rt}" allows only one relation, but found ${count}.`,
167
+ path: "relations",
168
+ });
169
+ }
170
+ }
171
+ return { isValid: issues.every((i) => i.severity !== "error"), issues };
172
+ }
55
173
  async validateCatalogTypeForContext(context, catalogType) {
56
174
  const registry = this.deps.catalogTypes
57
175
  ? await this.deps.catalogTypes.resolveForContext({
@@ -431,6 +549,15 @@ export class Catalox {
431
549
  const refs = options?.includeReferences
432
550
  ? await this.getCatalogItemReferences(context, catalogId, itemId)
433
551
  : [];
552
+ const referenceViews = refs.length
553
+ ? refs.map((r) => ({
554
+ toCatalogId: String(r.toCatalogId),
555
+ toItemId: String(r.toItemId),
556
+ relationType: String(r.relationType),
557
+ ...(r.label != null ? { label: r.label } : {}),
558
+ ...(r.metadata != null ? { metadata: r.metadata } : {}),
559
+ }))
560
+ : [];
434
561
  return {
435
562
  catalogId: String(catalogId),
436
563
  itemId: String(itemId),
@@ -444,17 +571,7 @@ export class Catalox {
444
571
  ...(item.metadata?.lastUpdate != null ? { updatedAt: String(item.metadata.lastUpdate) } : {}),
445
572
  },
446
573
  resolvedSources,
447
- ...(refs.length
448
- ? {
449
- references: refs.map((r) => ({
450
- toCatalogId: String(r.toCatalogId),
451
- toItemId: String(r.toItemId),
452
- relationType: String(r.relationType),
453
- ...(r.label != null ? { label: r.label } : {}),
454
- ...(r.metadata != null ? { metadata: r.metadata } : {}),
455
- })),
456
- }
457
- : {}),
574
+ ...(referenceViews.length ? { relations: referenceViews, references: referenceViews } : {}),
458
575
  ...(descriptor?.presentationSpec ? { presentation: descriptor.presentationSpec } : {}),
459
576
  capabilities: descriptor?.capabilities ?? {
460
577
  canList: true,
@@ -479,12 +596,14 @@ export class Catalox {
479
596
  return Object.keys(out).length ? out : undefined;
480
597
  }
481
598
  stripReservedWriteFields(input) {
482
- const { indexed, scope, ...rest } = input;
599
+ const { indexed, scope, relations, references, ...rest } = input;
483
600
  const parsedScope = scope != null ? parseWriteScopeInput(scope) : undefined;
601
+ const parsedRelations = this.normalizeRelationWrites(relations ?? references);
484
602
  return {
485
603
  data: rest,
486
604
  ...(indexed != null ? { indexed: indexed } : {}),
487
605
  ...(parsedScope != null ? { scope: parsedScope } : {}),
606
+ ...(parsedRelations.length ? { relations: parsedRelations } : {}),
488
607
  };
489
608
  }
490
609
  normalizeListFetchScope(scope) {
@@ -978,8 +1097,12 @@ export class Catalox {
978
1097
  async validateCatalog(_context, _catalogId) {
979
1098
  return { isValid: true, issues: [] };
980
1099
  }
981
- async validateCatalogItem(_context, _catalogId, _itemId) {
982
- return { isValid: true, issues: [] };
1100
+ async validateCatalogItem(context, catalogId, itemId) {
1101
+ const descriptor = await this.getCatalogDescriptor(context, catalogId);
1102
+ if (!descriptor)
1103
+ return { isValid: true, issues: [] };
1104
+ const refs = await this.getCatalogItemReferences(context, catalogId, itemId);
1105
+ return this.validateRelationsAgainstDescriptor(context, { catalogId, itemId, descriptor, refs });
983
1106
  }
984
1107
  async getCatalogItemReferences(context, catalogId, itemId) {
985
1108
  const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "read" });
@@ -993,6 +1116,59 @@ export class Catalox {
993
1116
  const refs = await this.deps.references.listByCatalog(catalogId);
994
1117
  return refs;
995
1118
  }
1119
+ async upsertCatalogItemRelation(context, input) {
1120
+ const fromCatalogId = input.fromCatalogId;
1121
+ const toCatalogId = input.toCatalogId;
1122
+ const fromAppId = await this.resolveAppIdForCatalogAccess({ context, catalogId: fromCatalogId, required: "write" });
1123
+ await this.deps.authz.requireBindingAccess(context, fromAppId, fromCatalogId, "write");
1124
+ // Ensure target catalog is at least readable by the actor (prevents creating opaque links).
1125
+ const toAppId = await this.resolveAppIdForCatalogAccess({ context, catalogId: toCatalogId, required: "read" });
1126
+ await this.deps.authz.requireBindingAccess(context, toAppId, toCatalogId, "read");
1127
+ const now = new Date().toISOString();
1128
+ const referenceId = this.buildReferenceId({
1129
+ fromCatalogId: String(input.fromCatalogId),
1130
+ fromItemId: String(input.fromItemId),
1131
+ relationType: String(input.relationType),
1132
+ toCatalogId: String(input.toCatalogId),
1133
+ toItemId: String(input.toItemId),
1134
+ });
1135
+ await this.deps.references.upsert({
1136
+ referenceId,
1137
+ fromCatalogId,
1138
+ fromItemId: input.fromItemId,
1139
+ toCatalogId,
1140
+ toItemId: input.toItemId,
1141
+ relationType: String(input.relationType),
1142
+ ...(input.label != null ? { label: input.label } : {}),
1143
+ ...(input.metadata != null ? { metadata: input.metadata } : {}),
1144
+ createdAt: now,
1145
+ updatedAt: now,
1146
+ });
1147
+ return { referenceId };
1148
+ }
1149
+ async deleteCatalogItemRelation(context, input) {
1150
+ const referenceId = "referenceId" in input
1151
+ ? String(input.referenceId)
1152
+ : this.buildReferenceId({
1153
+ fromCatalogId: String(input.fromCatalogId),
1154
+ fromItemId: String(input.fromItemId),
1155
+ relationType: String(input.relationType),
1156
+ toCatalogId: String(input.toCatalogId),
1157
+ toItemId: String(input.toItemId),
1158
+ });
1159
+ // Best-effort access check: if the caller provides endpoints, enforce write on fromCatalog.
1160
+ if (!("referenceId" in input)) {
1161
+ const fromCatalogId = input.fromCatalogId;
1162
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId: fromCatalogId, required: "write" });
1163
+ await this.deps.authz.requireBindingAccess(context, appId, fromCatalogId, "write");
1164
+ }
1165
+ await this.deps.references.delete(referenceId);
1166
+ }
1167
+ async listCatalogItemRelationsToItem(context, catalogId, itemId) {
1168
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "read" });
1169
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "read");
1170
+ return this.deps.references.listToItem(catalogId, itemId);
1171
+ }
996
1172
  // Spec methods below are stubs for now; filled in by later todos.
997
1173
  async getApp(_context, appId) {
998
1174
  const resolved = appId ?? _context.appId;
@@ -1557,12 +1733,43 @@ export class Catalox {
1557
1733
  }
1558
1734
  if (!existing)
1559
1735
  throw new CatalogNotFoundError({ catalogId: _catalogId, itemId: _itemId });
1560
- const { data: patchData, indexed: patchIndexed, scope: patchScope } = this.stripReservedWriteFields(_patch);
1736
+ const { data: patchData, indexed: patchIndexed, scope: patchScope, relations: patchRelations } = this.stripReservedWriteFields(_patch);
1561
1737
  let nextScope = normalizeStoredScope(scopeFromRecordField(existing.scope));
1562
1738
  if (patchScope != null) {
1563
1739
  nextScope = normalizeStoredScope(patchScope);
1564
1740
  assertSuperAdminForNonGlobalScope(_context.superAdmin, nextScope);
1565
1741
  }
1742
+ // Validate relation rules using existing + provided relations (if any).
1743
+ const descriptorRec = await this.deps.descriptors.get(_catalogId);
1744
+ if (descriptorRec?.descriptor?.relationRules?.length) {
1745
+ const existingRefs = await this.deps.references.listByItem(_catalogId, _itemId);
1746
+ const nextRefs = [
1747
+ ...existingRefs,
1748
+ ...(patchRelations ?? []).map((r) => ({
1749
+ fromCatalogId: _catalogId,
1750
+ fromItemId: _itemId,
1751
+ toCatalogId: r.toCatalogId,
1752
+ toItemId: r.toItemId,
1753
+ relationType: r.relationType,
1754
+ ...(r.label != null ? { label: r.label } : {}),
1755
+ ...(r.metadata != null ? { metadata: r.metadata } : {}),
1756
+ })),
1757
+ ];
1758
+ const report = await this.validateRelationsAgainstDescriptor(_context, {
1759
+ catalogId: _catalogId,
1760
+ itemId: _itemId,
1761
+ descriptor: descriptorRec.descriptor,
1762
+ refs: nextRefs,
1763
+ });
1764
+ if (!report.isValid) {
1765
+ throw new CatalogValidationError({
1766
+ reason: "invalid_relations",
1767
+ catalogId: String(_catalogId),
1768
+ itemId: String(_itemId),
1769
+ report,
1770
+ });
1771
+ }
1772
+ }
1566
1773
  const updatedAt = new Date().toISOString();
1567
1774
  const mergedData = { ...(existing.data ?? {}), ...patchData };
1568
1775
  const idx = {
@@ -1614,6 +1821,18 @@ export class Catalox {
1614
1821
  agentIds: Array.isArray(existing.metadata?.agentIds) ? existing.metadata.agentIds : [],
1615
1822
  },
1616
1823
  };
1824
+ // Upsert any provided relations after the item is persisted.
1825
+ for (const r of patchRelations ?? []) {
1826
+ await this.upsertCatalogItemRelation(_context, {
1827
+ fromCatalogId: String(_catalogId),
1828
+ fromItemId: String(_itemId),
1829
+ toCatalogId: r.toCatalogId,
1830
+ toItemId: r.toItemId,
1831
+ relationType: r.relationType,
1832
+ ...(r.label != null ? { label: r.label } : {}),
1833
+ ...(r.metadata != null ? { metadata: r.metadata } : {}),
1834
+ });
1835
+ }
1617
1836
  return this.decorateItem(_catalogId, out);
1618
1837
  }
1619
1838
  async deleteNativeCatalogItem(_context, _catalogId, _itemId, _options) {
@@ -1684,7 +1903,7 @@ export class Catalox {
1684
1903
  const descriptor = await this.deps.descriptors.get(catalogId);
1685
1904
  if (!descriptor)
1686
1905
  throw new CatalogAdapterError({ catalogId, reason: "missing_descriptor" });
1687
- const { data, indexed: callerIndexed, scope: inputScope } = this.stripReservedWriteFields(input);
1906
+ const { data, indexed: callerIndexed, scope: inputScope, relations: inputRelations } = this.stripReservedWriteFields(input);
1688
1907
  const storedScope = normalizeStoredScope(inputScope ?? { kind: "global" });
1689
1908
  assertSuperAdminForNonGlobalScope(context.superAdmin, storedScope);
1690
1909
  const derived = this.deriveIndexed(descriptor.descriptor, data);
@@ -1695,6 +1914,36 @@ export class Catalox {
1695
1914
  const now = new Date().toISOString();
1696
1915
  const existing = await this.deps.nativeItems.get(catalogId, storageDocId);
1697
1916
  const actorId = this.resolveActorId(context);
1917
+ // Enforce relation rules on create/upsert when relationRules are declared.
1918
+ if (descriptor.descriptor?.relationRules?.length) {
1919
+ const existingRefs = await this.deps.references.listByItem(catalogId, logicalItemId);
1920
+ const nextRefs = [
1921
+ ...existingRefs,
1922
+ ...(inputRelations ?? []).map((r) => ({
1923
+ fromCatalogId: catalogId,
1924
+ fromItemId: logicalItemId,
1925
+ toCatalogId: r.toCatalogId,
1926
+ toItemId: r.toItemId,
1927
+ relationType: r.relationType,
1928
+ ...(r.label != null ? { label: r.label } : {}),
1929
+ ...(r.metadata != null ? { metadata: r.metadata } : {}),
1930
+ })),
1931
+ ];
1932
+ const report = await this.validateRelationsAgainstDescriptor(context, {
1933
+ catalogId,
1934
+ itemId: logicalItemId,
1935
+ descriptor: descriptor.descriptor,
1936
+ refs: nextRefs,
1937
+ });
1938
+ if (!report.isValid) {
1939
+ throw new CatalogValidationError({
1940
+ reason: "invalid_relations",
1941
+ catalogId: String(catalogId),
1942
+ itemId: String(logicalItemId),
1943
+ report,
1944
+ });
1945
+ }
1946
+ }
1698
1947
  await this.deps.nativeItems.upsert(catalogId, {
1699
1948
  itemId: logicalItemId,
1700
1949
  catalogId,
@@ -1721,6 +1970,18 @@ export class Catalox {
1721
1970
  before: existing ?? null,
1722
1971
  after: persisted,
1723
1972
  });
1973
+ // Upsert any provided relations after the item is persisted.
1974
+ for (const r of inputRelations ?? []) {
1975
+ await this.upsertCatalogItemRelation(context, {
1976
+ fromCatalogId: String(catalogId),
1977
+ fromItemId: String(logicalItemId),
1978
+ toCatalogId: r.toCatalogId,
1979
+ toItemId: r.toItemId,
1980
+ relationType: r.relationType,
1981
+ ...(r.label != null ? { label: r.label } : {}),
1982
+ ...(r.metadata != null ? { metadata: r.metadata } : {}),
1983
+ });
1984
+ }
1724
1985
  return {
1725
1986
  itemId: logicalItemId,
1726
1987
  catalogId,