@x12i/catalox 3.4.0 → 3.4.6

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 (45) hide show
  1. package/README.md +69 -2
  2. package/dist/src/adapters/api/api-adapter.d.ts.map +1 -1
  3. package/dist/src/adapters/api/api-adapter.js +3 -0
  4. package/dist/src/adapters/api/api-adapter.js.map +1 -1
  5. package/dist/src/adapters/mongo/mongo-adapter.d.ts.map +1 -1
  6. package/dist/src/adapters/mongo/mongo-adapter.js +3 -0
  7. package/dist/src/adapters/mongo/mongo-adapter.js.map +1 -1
  8. package/dist/src/catalox/ai-functions-loader.d.ts +6 -0
  9. package/dist/src/catalox/ai-functions-loader.d.ts.map +1 -0
  10. package/dist/src/catalox/ai-functions-loader.js +19 -0
  11. package/dist/src/catalox/ai-functions-loader.js.map +1 -0
  12. package/dist/src/catalox/catalog-discovery.d.ts +32 -0
  13. package/dist/src/catalox/catalog-discovery.d.ts.map +1 -0
  14. package/dist/src/catalox/catalog-discovery.js +150 -0
  15. package/dist/src/catalox/catalog-discovery.js.map +1 -0
  16. package/dist/src/catalox/catalog-lifecycle.d.ts.map +1 -1
  17. package/dist/src/catalox/catalog-lifecycle.js +15 -0
  18. package/dist/src/catalox/catalog-lifecycle.js.map +1 -1
  19. package/dist/src/catalox/catalox-bound.d.ts +8 -1
  20. package/dist/src/catalox/catalox-bound.d.ts.map +1 -1
  21. package/dist/src/catalox/catalox-bound.js +21 -0
  22. package/dist/src/catalox/catalox-bound.js.map +1 -1
  23. package/dist/src/catalox/catalox.d.ts +13 -1
  24. package/dist/src/catalox/catalox.d.ts.map +1 -1
  25. package/dist/src/catalox/catalox.js +329 -37
  26. package/dist/src/catalox/catalox.js.map +1 -1
  27. package/dist/src/contracts/context.d.ts +8 -1
  28. package/dist/src/contracts/context.d.ts.map +1 -1
  29. package/dist/src/contracts/descriptor-rules.d.ts +76 -0
  30. package/dist/src/contracts/descriptor-rules.d.ts.map +1 -0
  31. package/dist/src/contracts/descriptor-rules.js +2 -0
  32. package/dist/src/contracts/descriptor-rules.js.map +1 -0
  33. package/dist/src/contracts/descriptors.d.ts +5 -0
  34. package/dist/src/contracts/descriptors.d.ts.map +1 -1
  35. package/dist/src/contracts/discovery.d.ts +70 -0
  36. package/dist/src/contracts/discovery.d.ts.map +1 -1
  37. package/dist/src/contracts/index.d.ts +3 -1
  38. package/dist/src/contracts/index.d.ts.map +1 -1
  39. package/dist/src/contracts/index.js.map +1 -1
  40. package/dist/src/contracts/search.d.ts +127 -0
  41. package/dist/src/contracts/search.d.ts.map +1 -0
  42. package/dist/src/contracts/search.js +2 -0
  43. package/dist/src/contracts/search.js.map +1 -0
  44. package/docs/catalox-ui-contract.md +6 -1
  45. package/package.json +2 -1
@@ -1,5 +1,5 @@
1
1
  import { compactCatalogFilter } from "../contracts/catalogs.js";
2
- import { CatalogAccessDeniedError, CatalogAdapterError, CatalogNotFoundError } from "../contracts/errors.js";
2
+ import { CatalogAccessDeniedError, CatalogAdapterError, CatalogBindingError, CatalogNotFoundError, } 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";
@@ -28,6 +28,8 @@ import { SnapshotStore } from "../firebase/snapshot-store.js";
28
28
  import { StoreAppBindingStore } from "../firebase/store-app-binding-store.js";
29
29
  import { IdentityBindingStore } from "../firebase/identity-binding-store.js";
30
30
  import { nativeItemsCollectionId } from "../firebase/catalog-data-paths.js";
31
+ import { applyCatalogVisibilityFilter, filterCatalogsByText, findCatalogsByAi as findCatalogsByAiHelper, summarizeCatalog, } from "./catalog-discovery.js";
32
+ import { loadAiFunctions } from "./ai-functions-loader.js";
31
33
  import { runBackupData, pruneGcsBackupRuns } from "./backup-data.js";
32
34
  import { runUndoFirestoreRestore } from "./restore-firestore-backup.js";
33
35
  import { runRestoreFirestoreBackupFromGcs, runDeleteCataloxGcsBackupRun } from "./restore-firestore-backup-from-gcs.js";
@@ -108,8 +110,41 @@ export class Catalox {
108
110
  if (active.length)
109
111
  return { storeId, appIds: active };
110
112
  }
113
+ if (!context.appId) {
114
+ throw new CatalogBindingError({
115
+ reason: "missing_appId",
116
+ message: "App id is required unless storeId/appIds are provided.",
117
+ });
118
+ }
111
119
  return { ...(storeId ? { storeId } : {}), appIds: [context.appId] };
112
120
  }
121
+ async resolveAppIdForCatalogAccess(params) {
122
+ const { context, catalogId, required } = params;
123
+ if (context.appId)
124
+ return context.appId;
125
+ // Super-admin callers may omit appId; access is still evaluated with explicit elevation.
126
+ if (context.superAdmin)
127
+ return "_super";
128
+ // CatalogId-first: infer appId via active bindings if there is exactly one viable binding.
129
+ const bindings = await this.deps.bindings.listByCatalog(catalogId);
130
+ const active = bindings.filter((b) => b.status === "active");
131
+ const viable = active.filter((b) => {
132
+ if (required === "read")
133
+ return Boolean(b.access.canRead);
134
+ if (required === "write")
135
+ return Boolean(b.access.canWrite);
136
+ return Boolean(b.access.canAdmin);
137
+ });
138
+ const distinctApps = [...new Set(viable.map((b) => String(b.appId)))];
139
+ if (distinctApps.length === 1)
140
+ return viable[0].appId;
141
+ throw new CatalogBindingError({
142
+ reason: "cannot_resolve_appId_for_catalog",
143
+ catalogId: String(catalogId),
144
+ required,
145
+ matchingAppIds: distinctApps,
146
+ });
147
+ }
113
148
  readPath(obj, path) {
114
149
  if (!path)
115
150
  return undefined;
@@ -160,7 +195,12 @@ export class Catalox {
160
195
  }
161
196
  if (source.type === "catalog") {
162
197
  // Enforce authz: safest default is to require binding (unless god-mode).
163
- await this.deps.authz.requireBindingAccess(params.context, params.context.appId, source.catalogId, "read");
198
+ const appId = await this.resolveAppIdForCatalogAccess({
199
+ context: params.context,
200
+ catalogId: source.catalogId,
201
+ required: "read",
202
+ });
203
+ await this.deps.authz.requireBindingAccess(params.context, appId, source.catalogId, "read");
164
204
  const filterEq = resolveFilterBy(source.filterBy, params.currentItemData, (o, p) => this.readPath(o, p));
165
205
  const sort = source.sortBy
166
206
  ? { [source.sortBy.field]: source.sortBy.direction === "asc" ? 1 : -1 }
@@ -419,6 +459,9 @@ export class Catalox {
419
459
  }
420
460
  async listAppCatalogs(context, input) {
421
461
  const appId = input?.appId ?? context.appId;
462
+ if (!appId) {
463
+ throw new CatalogBindingError({ reason: "missing_appId", message: "listAppCatalogs requires an appId." });
464
+ }
422
465
  const bindings = await this.deps.bindings.listByApp(appId);
423
466
  const catalogs = await this.deps.catalogs.listAll();
424
467
  const byId = new Map(catalogs.map((c) => [c.catalogId, c]));
@@ -517,13 +560,42 @@ export class Catalox {
517
560
  }
518
561
  return out.filter((e) => (input.includeHidden ? true : e.visibility !== "hidden"));
519
562
  }
563
+ /**
564
+ * App-agnostic discovery: list catalog metadata across all apps.
565
+ * Visibility default: hide hidden catalogs unless `context.superAdmin`.
566
+ */
567
+ async listAllCatalogs(context, input) {
568
+ const catalogs = await this.deps.catalogs.listAll();
569
+ const out = [];
570
+ for (const c of catalogs) {
571
+ const d = await this.deps.descriptors.get(c.catalogId);
572
+ out.push(summarizeCatalog(c, d));
573
+ }
574
+ return applyCatalogVisibilityFilter(out, context, input);
575
+ }
576
+ async findCatalogs(context, input) {
577
+ const all = await this.listAllCatalogs(context, {
578
+ ...(input.includeDisabled != null ? { includeDisabled: input.includeDisabled } : {}),
579
+ ...(input.includeHidden != null ? { includeHidden: input.includeHidden } : {}),
580
+ });
581
+ return filterCatalogsByText(all, input);
582
+ }
583
+ async findCatalogsByAi(context, input) {
584
+ const all = await this.listAllCatalogs(context, {
585
+ ...(input.includeDisabled != null ? { includeDisabled: input.includeDisabled } : {}),
586
+ ...(input.includeHidden != null ? { includeHidden: input.includeHidden } : {}),
587
+ });
588
+ return findCatalogsByAiHelper({ query: input.query, catalogs: all, input });
589
+ }
520
590
  async getCatalogDescriptor(context, catalogId) {
521
- await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "read");
591
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "read" });
592
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "read");
522
593
  const rec = await this.deps.descriptors.get(catalogId);
523
594
  return rec?.descriptor ?? null;
524
595
  }
525
596
  async getCatalogRendererSnippet(context, catalogId, role, mode) {
526
- await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "read");
597
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "read" });
598
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "read");
527
599
  if (!this.deps.rendererSnippets) {
528
600
  throw new Error("rendererSnippets dependency is not configured");
529
601
  }
@@ -531,6 +603,9 @@ export class Catalox {
531
603
  }
532
604
  async getAppCatalogBootstrap(context, appId) {
533
605
  const resolved = appId ?? context.appId;
606
+ if (!resolved) {
607
+ throw new CatalogBindingError({ reason: "missing_appId", message: "getAppCatalogBootstrap requires an appId." });
608
+ }
534
609
  const entries = await this.listAppCatalogs(context, { appId: resolved });
535
610
  const descriptors = [];
536
611
  for (const e of entries) {
@@ -553,7 +628,9 @@ export class Catalox {
553
628
  return out;
554
629
  }
555
630
  async listCatalogItems(context, catalogId, options) {
556
- await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "read");
631
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "read" });
632
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "read");
633
+ const appCtx = context.appId ? context : { ...context, appId };
557
634
  const catalog = await this.deps.catalogs.get(catalogId);
558
635
  if (!catalog)
559
636
  throw new CatalogNotFoundError({ catalogId });
@@ -590,7 +667,7 @@ export class Catalox {
590
667
  else {
591
668
  physical = await this.fetchScopedPhysicalRows(catalogId, rawScope, baseListOpts);
592
669
  }
593
- const merged = mergeNativePhysicalRows(physical, descRec.descriptor.identity, String(context.appId), wantsSuperList);
670
+ const merged = mergeNativePhysicalRows(physical, descRec.descriptor.identity, String(appId), wantsSuperList);
594
671
  const off = listQueryOptions?.offset ?? 0;
595
672
  const lim = listQueryOptions?.limit ?? 100;
596
673
  const sliced = merged.slice(off, off + lim);
@@ -623,7 +700,7 @@ export class Catalox {
623
700
  const adapterConfig = await this.deps.adapters.get(def.adapterId);
624
701
  if (!adapterConfig)
625
702
  throw new CatalogAdapterError({ catalogId, reason: "missing_adapter" });
626
- const result = await this.deps.mongoAdapter.listItems(context, catalogId, adapterConfig, { mapping: mapping.mapping, ...(mapping.options ? { options: mapping.options } : {}) }, mappedListQueryOptions);
703
+ const result = await this.deps.mongoAdapter.listItems(appCtx, catalogId, adapterConfig, { mapping: mapping.mapping, ...(mapping.options ? { options: mapping.options } : {}) }, mappedListQueryOptions);
627
704
  return result.issues?.length
628
705
  ? { listOutcome: "ok", items: result.items, issues: result.issues }
629
706
  : { listOutcome: "ok", items: result.items };
@@ -634,7 +711,7 @@ export class Catalox {
634
711
  const adapterConfig = await this.deps.adapters.get(def.adapterId);
635
712
  if (!adapterConfig)
636
713
  throw new CatalogAdapterError({ catalogId, reason: "missing_adapter" });
637
- const result = await this.deps.apiAdapter.listItems(context, catalogId, adapterConfig, { responseMapping: mapping.mapping, ...(mapping.options ? { options: mapping.options } : {}) }, mappedListQueryOptions);
714
+ const result = await this.deps.apiAdapter.listItems(appCtx, catalogId, adapterConfig, { responseMapping: mapping.mapping, ...(mapping.options ? { options: mapping.options } : {}) }, mappedListQueryOptions);
638
715
  return {
639
716
  listOutcome: "ok",
640
717
  items: result.items,
@@ -668,7 +745,8 @@ export class Catalox {
668
745
  }
669
746
  }
670
747
  async getCatalogItem(context, catalogId, itemId, options) {
671
- await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "read");
748
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "read" });
749
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "read");
672
750
  const catalog = await this.deps.catalogs.get(catalogId);
673
751
  if (!catalog)
674
752
  throw new CatalogNotFoundError({ catalogId });
@@ -684,7 +762,7 @@ export class Catalox {
684
762
  const base = {
685
763
  itemId: rec.itemId,
686
764
  catalogId: rec.catalogId,
687
- appId: context.appId,
765
+ appId,
688
766
  sourceMode: "native",
689
767
  sourceType: "firebase",
690
768
  data: rec.data,
@@ -709,7 +787,7 @@ export class Catalox {
709
787
  const filtered = scope?.accountId || scope?.groupId || scope?.userId || scope?.channelId || scope?.visitorId || scope?.domainId || scope?.agentId
710
788
  ? filterPhysicalForTenantFetch(rows, scope)
711
789
  : rows.filter((r) => isGlobalPhysicalRow(r));
712
- const merged = mergeNativePhysicalRows(filtered, descRec.descriptor.identity, String(context.appId), false);
790
+ const merged = mergeNativePhysicalRows(filtered, descRec.descriptor.identity, String(appId), false);
713
791
  const hit = merged.find((m) => m.itemId === itemId) ?? merged[0];
714
792
  if (!hit)
715
793
  return { outcome: "not_found" };
@@ -755,18 +833,23 @@ export class Catalox {
755
833
  return { isValid: true, issues: [] };
756
834
  }
757
835
  async getCatalogItemReferences(context, catalogId, itemId) {
758
- await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "read");
836
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "read" });
837
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "read");
759
838
  const refs = await this.deps.references.listByItem(catalogId, itemId);
760
839
  return refs;
761
840
  }
762
841
  async listCatalogReferences(context, catalogId) {
763
- await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "read");
842
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "read" });
843
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "read");
764
844
  const refs = await this.deps.references.listByCatalog(catalogId);
765
845
  return refs;
766
846
  }
767
847
  // Spec methods below are stubs for now; filled in by later todos.
768
848
  async getApp(_context, appId) {
769
- return appId ? this.deps.apps.get(appId) : this.deps.apps.get(_context.appId);
849
+ const resolved = appId ?? _context.appId;
850
+ if (!resolved)
851
+ throw new CatalogBindingError({ reason: "missing_appId" });
852
+ return this.deps.apps.get(resolved);
770
853
  }
771
854
  async createCatalog(_context, _input) {
772
855
  const now = new Date().toISOString();
@@ -891,6 +974,199 @@ export class Catalox {
891
974
  async listCatalogs(_context, _options) {
892
975
  return this.deps.catalogs.listAll();
893
976
  }
977
+ async searchCatalogItems(context, catalogId, input) {
978
+ const poolLimit = input.poolLimit ?? 200;
979
+ const limit = input.limit ?? 50;
980
+ const base = await this.listCatalogItems(context, catalogId, {
981
+ limit: poolLimit,
982
+ ...(input.scope ? { scope: input.scope } : {}),
983
+ ...(input.filter ? { filter: input.filter } : {}),
984
+ ...(input.sort ? { sort: input.sort } : {}),
985
+ });
986
+ if (base.listOutcome !== "ok")
987
+ return base;
988
+ const q = String(input.text ?? "").toLowerCase().trim();
989
+ if (!q)
990
+ return { ...base, items: [] };
991
+ const textFields = input.textFields ?? [];
992
+ const filtered = base.items.filter((it) => {
993
+ const parts = [];
994
+ if (it.title)
995
+ parts.push(String(it.title));
996
+ if (it.subtitle)
997
+ parts.push(String(it.subtitle));
998
+ const data = it.data;
999
+ for (const p of textFields) {
1000
+ const v = this.readPath(data, p);
1001
+ if (v == null)
1002
+ continue;
1003
+ parts.push(typeof v === "string" || typeof v === "number" ? String(v) : JSON.stringify(v));
1004
+ }
1005
+ return parts.join(" ").toLowerCase().includes(q);
1006
+ });
1007
+ return { ...base, items: filtered.slice(0, limit) };
1008
+ }
1009
+ async findCatalogItemsByAi(context, catalogId, input) {
1010
+ const poolLimit = input.poolLimit ?? 300;
1011
+ const base = await this.listCatalogItems(context, catalogId, {
1012
+ limit: poolLimit,
1013
+ ...(input.scope ? { scope: input.scope } : {}),
1014
+ ...(input.filter ? { filter: input.filter } : {}),
1015
+ ...(input.sort ? { sort: input.sort } : {}),
1016
+ });
1017
+ if (base.listOutcome !== "ok")
1018
+ return { query: input.query, hits: [], noMatch: true };
1019
+ const items = base.items;
1020
+ const candidates = items.map((it) => {
1021
+ const title = it.title ? String(it.title) : "";
1022
+ const subtitle = it.subtitle ? String(it.subtitle) : "";
1023
+ const label = [title, subtitle].filter(Boolean).join(" — ") || String(it.itemId);
1024
+ return {
1025
+ id: String(it.itemId),
1026
+ label,
1027
+ metadata: { catalogId: String(catalogId) },
1028
+ };
1029
+ });
1030
+ const { match } = await loadAiFunctions();
1031
+ const res = await match({
1032
+ query: input.query,
1033
+ candidates,
1034
+ ...(input.guidance ? { guidance: input.guidance } : {}),
1035
+ ...(input.maxResults != null ? { maxResults: input.maxResults } : {}),
1036
+ ...(input.minScore != null ? { minScore: input.minScore } : {}),
1037
+ ...(input.allowNoMatch != null ? { allowNoMatch: input.allowNoMatch } : {}),
1038
+ ...(input.returnReasons != null ? { returnReasons: input.returnReasons } : {}),
1039
+ ...(input.additionalInstructions ? { additionalInstructions: input.additionalInstructions } : {}),
1040
+ ...(input.maxCandidates != null ? { maxCandidates: input.maxCandidates } : {}),
1041
+ ...(input.mode ? { mode: input.mode } : {}),
1042
+ ...(input.client ? { client: input.client } : {}),
1043
+ ...(input.model ? { model: input.model } : {}),
1044
+ ...(input.temperature != null ? { temperature: input.temperature } : {}),
1045
+ ...(input.maxTokens != null ? { maxTokens: input.maxTokens } : {}),
1046
+ ...(input.timeoutMs != null ? { timeoutMs: input.timeoutMs } : {}),
1047
+ ...(input.vendor != null ? { vendor: input.vendor } : {}),
1048
+ });
1049
+ const byId = new Map(items.map((it) => [String(it.itemId), it]));
1050
+ const hits = res.matches
1051
+ .map((m) => {
1052
+ const it = byId.get(String(m.id));
1053
+ if (!it)
1054
+ return null;
1055
+ return { itemId: it.itemId, score: m.score, ...(m.reason ? { reason: m.reason } : {}), item: it };
1056
+ })
1057
+ .filter(Boolean);
1058
+ return { query: input.query, hits, noMatch: Boolean(res.noMatch) };
1059
+ }
1060
+ async createCatalogItemByAi(context, catalogId, input) {
1061
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "write" });
1062
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "write");
1063
+ const descRec = await this.deps.descriptors.get(catalogId);
1064
+ if (!descRec)
1065
+ throw new CatalogAdapterError({ catalogId, reason: "missing_descriptor" });
1066
+ const rules = descRec.descriptor.creationRules;
1067
+ if (!rules)
1068
+ throw new CatalogAdapterError({ catalogId, reason: "creation_rules_missing" });
1069
+ if (rules.enabled === false)
1070
+ throw new CatalogAdapterError({ catalogId, reason: "creation_rules_disabled" });
1071
+ const catalog = await this.deps.catalogs.get(catalogId);
1072
+ if (!catalog)
1073
+ throw new CatalogNotFoundError({ catalogId });
1074
+ const existingLimit = input.existingItemsSampleLimit ?? 0;
1075
+ const existingItemsSample = existingLimit > 0
1076
+ ? (await this.listCatalogItems(context, catalogId, { limit: existingLimit })).items.map((i) => i.data)
1077
+ : undefined;
1078
+ const { createItem } = await loadAiFunctions();
1079
+ const result = await createItem({
1080
+ ...(descRec.descriptor.itemLabel ? { itemLabel: descRec.descriptor.itemLabel } : {}),
1081
+ creationRules: rules,
1082
+ provided: input.provided,
1083
+ fieldSchema: (descRec.descriptor.queryableFields ?? []),
1084
+ ...(existingItemsSample ? { existingItemsSample } : {}),
1085
+ ...(input.additionalInstructions ? { additionalInstructions: input.additionalInstructions } : {}),
1086
+ ...(input.mode ? { mode: input.mode } : {}),
1087
+ ...(input.model ? { model: input.model } : {}),
1088
+ ...(input.client ? { client: input.client } : {}),
1089
+ ...(input.temperature != null ? { temperature: input.temperature } : {}),
1090
+ ...(input.maxTokens != null ? { maxTokens: input.maxTokens } : {}),
1091
+ ...(input.timeoutMs != null ? { timeoutMs: input.timeoutMs } : {}),
1092
+ ...(input.vendor != null ? { vendor: input.vendor } : {}),
1093
+ }, undefined);
1094
+ const out = {
1095
+ catalogId,
1096
+ proposed: result.item ?? {},
1097
+ issues: (result.issues ?? []),
1098
+ ...(result.missingInputs ? { missingInputs: result.missingInputs } : {}),
1099
+ noCreate: Boolean(result.noCreate),
1100
+ ...(result.reason ? { reason: result.reason } : {}),
1101
+ };
1102
+ const autoPersist = input.autoPersist === true;
1103
+ const hasErrors = (result.issues ?? []).some((i) => i?.severity === "error");
1104
+ if (autoPersist && !out.noCreate && !hasErrors) {
1105
+ if (catalog.sourceMode !== "native") {
1106
+ throw new CatalogAdapterError({ catalogId, reason: "autopersist_mapped_unsupported" });
1107
+ }
1108
+ const writePayload = input.scope != null ? { ...out.proposed, scope: input.scope } : out.proposed;
1109
+ out.item = await this.upsertNativeCatalogItem(context, catalogId, writePayload);
1110
+ }
1111
+ return out;
1112
+ }
1113
+ async modifyCatalogItemByAi(context, catalogId, itemId, input) {
1114
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "write" });
1115
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "write");
1116
+ const descRec = await this.deps.descriptors.get(catalogId);
1117
+ if (!descRec)
1118
+ throw new CatalogAdapterError({ catalogId, reason: "missing_descriptor" });
1119
+ const rules = descRec.descriptor.modificationRules;
1120
+ if (!rules)
1121
+ throw new CatalogAdapterError({ catalogId, reason: "modification_rules_missing" });
1122
+ if (rules.enabled === false)
1123
+ throw new CatalogAdapterError({ catalogId, reason: "modification_rules_disabled" });
1124
+ const catalog = await this.deps.catalogs.get(catalogId);
1125
+ if (!catalog)
1126
+ throw new CatalogNotFoundError({ catalogId });
1127
+ const got = await this.getCatalogItem(context, catalogId, itemId, input.nativeItemOptions);
1128
+ if (got.outcome === "mapping_blocked") {
1129
+ throw new CatalogAdapterError({ catalogId, reason: "mapping_validation_failed", issues: got.issues });
1130
+ }
1131
+ if (got.outcome === "not_found")
1132
+ throw new CatalogNotFoundError({ catalogId, itemId });
1133
+ const currentItem = (got.item.data ?? {});
1134
+ const { modifyItem } = await loadAiFunctions();
1135
+ const result = await modifyItem({
1136
+ ...(descRec.descriptor.itemLabel ? { itemLabel: descRec.descriptor.itemLabel } : {}),
1137
+ currentItem: currentItem,
1138
+ modificationRules: rules,
1139
+ patch: input.patch,
1140
+ fieldSchema: (descRec.descriptor.queryableFields ?? []),
1141
+ ...(input.additionalInstructions ? { additionalInstructions: input.additionalInstructions } : {}),
1142
+ ...(input.mode ? { mode: input.mode } : {}),
1143
+ ...(input.model ? { model: input.model } : {}),
1144
+ ...(input.client ? { client: input.client } : {}),
1145
+ ...(input.temperature != null ? { temperature: input.temperature } : {}),
1146
+ ...(input.maxTokens != null ? { maxTokens: input.maxTokens } : {}),
1147
+ ...(input.timeoutMs != null ? { timeoutMs: input.timeoutMs } : {}),
1148
+ ...(input.vendor != null ? { vendor: input.vendor } : {}),
1149
+ }, undefined);
1150
+ const out = {
1151
+ catalogId,
1152
+ itemId,
1153
+ proposed: result.item ?? {},
1154
+ diff: (result.diff ?? []),
1155
+ issues: (result.issues ?? []),
1156
+ ...(result.violatedRules ? { violatedRules: result.violatedRules } : {}),
1157
+ noChange: Boolean(result.noChange),
1158
+ ...(result.reason ? { reason: result.reason } : {}),
1159
+ };
1160
+ const autoPersist = input.autoPersist === true;
1161
+ const hasErrors = (result.issues ?? []).some((i) => i?.severity === "error");
1162
+ if (autoPersist && !out.noChange && !hasErrors) {
1163
+ if (catalog.sourceMode !== "native") {
1164
+ throw new CatalogAdapterError({ catalogId, reason: "autopersist_mapped_unsupported" });
1165
+ }
1166
+ out.item = await this.updateNativeCatalogItem(context, catalogId, itemId, out.proposed, input.nativeItemOptions);
1167
+ }
1168
+ return out;
1169
+ }
894
1170
  async bindCatalogToApp(_context, _input) {
895
1171
  const existing = await this.deps.bindings.findByAppCatalog(_input.appId, _input.catalogId);
896
1172
  if (existing)
@@ -926,7 +1202,7 @@ export class Catalox {
926
1202
  async bindAppToStore(context, input) {
927
1203
  if (!this.deps.storeAppBindings)
928
1204
  throw new Error("storeAppBindings dependency is not configured");
929
- if (!context.superAdmin && input.appId !== context.appId) {
1205
+ if (!context.superAdmin && (!context.appId || input.appId !== context.appId)) {
930
1206
  throw new CatalogAccessDeniedError({ reason: "not_super_admin" });
931
1207
  }
932
1208
  const existing = await this.deps.storeAppBindings.findByStoreApp(input.storeId, input.appId);
@@ -950,7 +1226,7 @@ export class Catalox {
950
1226
  async unbindAppFromStore(context, storeId, appId) {
951
1227
  if (!this.deps.storeAppBindings)
952
1228
  throw new Error("storeAppBindings dependency is not configured");
953
- if (!context.superAdmin && appId !== context.appId) {
1229
+ if (!context.superAdmin && (!context.appId || appId !== context.appId)) {
954
1230
  throw new CatalogAccessDeniedError({ reason: "not_super_admin" });
955
1231
  }
956
1232
  const existing = await this.deps.storeAppBindings.findByStoreApp(storeId, appId);
@@ -972,10 +1248,15 @@ export class Catalox {
972
1248
  if (context.superAdmin)
973
1249
  return records;
974
1250
  // non-god: only reveal memberships that include the caller's own appId
975
- return records.filter((r) => r.appId === context.appId);
1251
+ return context.appId ? records.filter((r) => r.appId === context.appId) : [];
976
1252
  }
977
1253
  async ensureCatalog(context, catalog) {
978
- await this.deps.authz.requireBindingAccess(context, context.appId, catalog.catalogId, "admin");
1254
+ const appId = await this.resolveAppIdForCatalogAccess({
1255
+ context,
1256
+ catalogId: catalog.catalogId,
1257
+ required: "admin",
1258
+ });
1259
+ await this.deps.authz.requireBindingAccess(context, appId, catalog.catalogId, "admin");
979
1260
  const existing = await this.deps.catalogs.get(catalog.catalogId);
980
1261
  const now = new Date().toISOString();
981
1262
  if (existing)
@@ -991,7 +1272,7 @@ export class Catalox {
991
1272
  }
992
1273
  async ensureBinding(context, input) {
993
1274
  // only super-admin apps can provision cross-app bindings.
994
- if (!context.superAdmin && input.appId !== context.appId) {
1275
+ if (!context.superAdmin && (!context.appId || input.appId !== context.appId)) {
995
1276
  throw new CatalogAccessDeniedError({ reason: "not_super_admin" });
996
1277
  }
997
1278
  const existing = await this.deps.bindings.findByAppCatalog(input.appId, input.catalogId);
@@ -1016,7 +1297,8 @@ export class Catalox {
1016
1297
  return this.upsertNativeCatalogItem(_context, _catalogId, _input);
1017
1298
  }
1018
1299
  async updateNativeCatalogItem(_context, _catalogId, _itemId, _patch, _options) {
1019
- await this.deps.authz.requireBindingAccess(_context, _context.appId, _catalogId, "write");
1300
+ const appId = await this.resolveAppIdForCatalogAccess({ context: _context, catalogId: _catalogId, required: "write" });
1301
+ await this.deps.authz.requireBindingAccess(_context, appId, _catalogId, "write");
1020
1302
  let existing = null;
1021
1303
  if (_options?.storageDocId) {
1022
1304
  existing = await this.deps.nativeItems.get(_catalogId, _options.storageDocId);
@@ -1072,7 +1354,7 @@ export class Catalox {
1072
1354
  const out = {
1073
1355
  itemId: nextRec.itemId,
1074
1356
  catalogId: _catalogId,
1075
- appId: _context.appId,
1357
+ appId,
1076
1358
  sourceMode: "native",
1077
1359
  sourceType: "firebase",
1078
1360
  data: mergedData,
@@ -1082,7 +1364,8 @@ export class Catalox {
1082
1364
  return this.decorateItem(_catalogId, out);
1083
1365
  }
1084
1366
  async deleteNativeCatalogItem(_context, _catalogId, _itemId, _options) {
1085
- await this.deps.authz.requireBindingAccess(_context, _context.appId, _catalogId, "write");
1367
+ const appId = await this.resolveAppIdForCatalogAccess({ context: _context, catalogId: _catalogId, required: "write" });
1368
+ await this.deps.authz.requireBindingAccess(_context, appId, _catalogId, "write");
1086
1369
  let docId = _options?.storageDocId;
1087
1370
  if (!docId) {
1088
1371
  let rows = await this.deps.nativeItems.findByLogicalItemId(_catalogId, _itemId);
@@ -1113,7 +1396,8 @@ export class Catalox {
1113
1396
  if (!context.superAdmin) {
1114
1397
  throw new CatalogAccessDeniedError({ reason: "super_admin_required" });
1115
1398
  }
1116
- await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "write");
1399
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "write" });
1400
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "write");
1117
1401
  const fromDoc = input.fromStorageDocId ??
1118
1402
  encodeNativeItemStorageDocId(String(itemId), normalizeStoredScope(input.fromScope ?? { kind: "global" }));
1119
1403
  const rec = await this.deps.nativeItems.get(catalogId, fromDoc);
@@ -1142,7 +1426,8 @@ export class Catalox {
1142
1426
  });
1143
1427
  }
1144
1428
  async upsertNativeCatalogItem(context, catalogId, input) {
1145
- await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "write");
1429
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "write" });
1430
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "write");
1146
1431
  const descriptor = await this.deps.descriptors.get(catalogId);
1147
1432
  if (!descriptor)
1148
1433
  throw new CatalogAdapterError({ catalogId, reason: "missing_descriptor" });
@@ -1161,7 +1446,7 @@ export class Catalox {
1161
1446
  itemId: logicalItemId,
1162
1447
  catalogId,
1163
1448
  ...(storedScope.kind !== "global" ? { scope: storedScope } : {}),
1164
- appScopedOwnerId: context.appId,
1449
+ appScopedOwnerId: appId,
1165
1450
  ...(indexed != null ? { indexed } : {}),
1166
1451
  data,
1167
1452
  version: (existing?.version ?? 0) + 1,
@@ -1182,7 +1467,7 @@ export class Catalox {
1182
1467
  return {
1183
1468
  itemId: logicalItemId,
1184
1469
  catalogId,
1185
- appId: context.appId,
1470
+ appId,
1186
1471
  sourceMode: "native",
1187
1472
  sourceType: "firebase",
1188
1473
  data,
@@ -1191,7 +1476,8 @@ export class Catalox {
1191
1476
  };
1192
1477
  }
1193
1478
  async batchUpsertNativeCatalogItems(context, catalogId, items) {
1194
- await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "write");
1479
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "write" });
1480
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "write");
1195
1481
  const descriptor = await this.deps.descriptors.get(catalogId);
1196
1482
  if (!descriptor)
1197
1483
  throw new CatalogAdapterError({ catalogId, reason: "missing_descriptor" });
@@ -1212,7 +1498,7 @@ export class Catalox {
1212
1498
  itemId: logicalItemId,
1213
1499
  catalogId,
1214
1500
  ...(storedScope.kind !== "global" ? { scope: storedScope } : {}),
1215
- appScopedOwnerId: context.appId,
1501
+ appScopedOwnerId: appId,
1216
1502
  ...(indexed != null ? { indexed } : {}),
1217
1503
  data: stripped.data,
1218
1504
  createdAt: now,
@@ -1583,7 +1869,8 @@ export class Catalox {
1583
1869
  });
1584
1870
  }
1585
1871
  async listCatalogItemHistory(context, catalogId, input) {
1586
- await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "read");
1872
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "read" });
1873
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "read");
1587
1874
  const rh = this.deps.recordHistory;
1588
1875
  if (!rh)
1589
1876
  return { events: [] };
@@ -1596,7 +1883,8 @@ export class Catalox {
1596
1883
  const row = await rh.store.get(eventId);
1597
1884
  if (!row)
1598
1885
  return null;
1599
- await this.deps.authz.requireBindingAccess(context, context.appId, row.catalogId, "read");
1886
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId: row.catalogId, required: "read" });
1887
+ await this.deps.authz.requireBindingAccess(context, appId, row.catalogId, "read");
1600
1888
  const payload = await readRecordHistoryEventPayload(rh, row);
1601
1889
  return { index: row, payload };
1602
1890
  }
@@ -1607,7 +1895,8 @@ export class Catalox {
1607
1895
  const row = await rh.store.get(eventId);
1608
1896
  if (!row)
1609
1897
  throw new Error(`catalogItemHistory event not found: ${eventId}`);
1610
- await this.deps.authz.requireBindingAccess(context, context.appId, row.catalogId, "write");
1898
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId: row.catalogId, required: "write" });
1899
+ await this.deps.authz.requireBindingAccess(context, appId, row.catalogId, "write");
1611
1900
  const payload = await readRecordHistoryEventPayload(rh, row);
1612
1901
  const rec = input.mode === "before" ? payload.before ?? undefined : payload.after ?? undefined;
1613
1902
  if (!rec)
@@ -1627,7 +1916,7 @@ export class Catalox {
1627
1916
  return this.decorateItem(row.catalogId, {
1628
1917
  itemId: rec.itemId,
1629
1918
  catalogId: row.catalogId,
1630
- appId: context.appId,
1919
+ appId,
1631
1920
  sourceMode: "native",
1632
1921
  sourceType: "firebase",
1633
1922
  data: liveAfter.data,
@@ -1636,7 +1925,8 @@ export class Catalox {
1636
1925
  });
1637
1926
  }
1638
1927
  async replayCatalogToPointInTime(context, catalogId, input) {
1639
- await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "write");
1928
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "write" });
1929
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "write");
1640
1930
  const rh = this.deps.recordHistory;
1641
1931
  if (!rh)
1642
1932
  throw new Error("recordHistory is not configured on Catalox");
@@ -1688,8 +1978,9 @@ export class Catalox {
1688
1978
  return { upserted, deleted };
1689
1979
  }
1690
1980
  async deleteCatalog(context, catalogId, input) {
1691
- await this.deps.authz.requireBindingAccess(context, context.appId, catalogId, "admin");
1692
- return runDeleteCatalog(this.catalogLifecycleDeps(), this.deps.recordHistory, context, catalogId, input);
1981
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId, required: "admin" });
1982
+ await this.deps.authz.requireBindingAccess(context, appId, catalogId, "admin");
1983
+ return runDeleteCatalog(this.catalogLifecycleDeps(), this.deps.recordHistory, { ...context, appId }, catalogId, input);
1693
1984
  }
1694
1985
  async restoreDeletedCatalog(context, input) {
1695
1986
  if (!context.superAdmin) {
@@ -1706,8 +1997,9 @@ export class Catalox {
1706
1997
  return runRestoreDeletedCatalog(this.catalogLifecycleDeps(), rh, input);
1707
1998
  }
1708
1999
  async renameCatalog(context, fromCatalogId, toCatalogId, input = {}) {
1709
- await this.deps.authz.requireBindingAccess(context, context.appId, fromCatalogId, "admin");
1710
- return runRenameCatalog(this.catalogLifecycleDeps(), this.deps.recordHistory, context, fromCatalogId, toCatalogId, input);
2000
+ const appId = await this.resolveAppIdForCatalogAccess({ context, catalogId: fromCatalogId, required: "admin" });
2001
+ await this.deps.authz.requireBindingAccess(context, appId, fromCatalogId, "admin");
2002
+ return runRenameCatalog(this.catalogLifecycleDeps(), this.deps.recordHistory, { ...context, appId }, fromCatalogId, toCatalogId, input);
1711
2003
  }
1712
2004
  /**
1713
2005
  * Fix {@link CataloxContext} for subsequent calls so embedders omit it on each method (no globals).