@odla-ai/cli 0.12.0 → 0.13.0

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.
@@ -786,6 +786,77 @@ function isRecord3(value) {
786
786
  import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
787
787
  import { dirname as dirname3, isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
788
788
  import { pathToFileURL } from "url";
789
+
790
+ // src/integration-validation.ts
791
+ function validateIntegrations(cfg, path, defaultServices) {
792
+ if (cfg.integrations === void 0) return;
793
+ if (!Array.isArray(cfg.integrations)) throw new Error(`${path}: integrations must be an array`);
794
+ const ids = /* @__PURE__ */ new Set();
795
+ for (const [index, integration] of cfg.integrations.entries()) {
796
+ const at = `${path}: integrations[${index}]`;
797
+ if (!isRecord4(integration)) throw new Error(`${at} must be an object`);
798
+ if (!validId(integration.id)) throw new Error(`${at}.id must be lowercase letters, numbers, and hyphens`);
799
+ if (ids.has(integration.id)) throw new Error(`${path}: duplicate integration id "${integration.id}"`);
800
+ ids.add(integration.id);
801
+ if (!safeText(integration.title, 200)) throw new Error(`${at}.title is required`);
802
+ if (!safeText(integration.npm, 200)) throw new Error(`${at}.npm is required`);
803
+ if (integration.schema !== void 0 && (!isRecord4(integration.schema) || !isRecord4(integration.schema.entities))) {
804
+ throw new Error(`${at}.schema must contain an entities object`);
805
+ }
806
+ if (integration.rules !== void 0 && !isRecord4(integration.rules)) throw new Error(`${at}.rules must be an object`);
807
+ validateSeeds(integration, at);
808
+ validateProbes(integration, at);
809
+ }
810
+ const needsDb = cfg.integrations.some((integration) => integration.schema || integration.rules || integration.seeds?.length);
811
+ const services = unique(cfg.services?.length ? cfg.services : defaultServices);
812
+ if (needsDb && !services.includes("db")) throw new Error(`${path}: schema/rules/seed integrations require the db service`);
813
+ }
814
+ function validateSeeds(integration, at) {
815
+ if (integration.seeds === void 0) return;
816
+ if (!Array.isArray(integration.seeds)) throw new Error(`${at}.seeds must be an array`);
817
+ const ids = /* @__PURE__ */ new Set();
818
+ for (const [index, seed] of integration.seeds.entries()) {
819
+ const sat = `${at}.seeds[${index}]`;
820
+ if (!isRecord4(seed) || !safeText(seed.id, 200) || !safeText(seed.ns, 200)) throw new Error(`${sat} requires id and ns`);
821
+ if (ids.has(seed.id)) throw new Error(`${at} has duplicate seed id "${seed.id}"`);
822
+ ids.add(seed.id);
823
+ if (!isRecord4(seed.key) || !safeText(seed.key.attr, 200) || !safeText(seed.key.value, 2048)) {
824
+ throw new Error(`${sat}.key requires string attr and value`);
825
+ }
826
+ if (!isRecord4(seed.attrs)) throw new Error(`${sat}.attrs must be an object`);
827
+ if (Object.hasOwn(seed.attrs, seed.key.attr) && seed.attrs[seed.key.attr] !== seed.key.value) {
828
+ throw new Error(`${sat}.attrs.${seed.key.attr} conflicts with its natural key`);
829
+ }
830
+ }
831
+ }
832
+ function validateProbes(integration, at) {
833
+ if (integration.probes === void 0) return;
834
+ if (!Array.isArray(integration.probes)) throw new Error(`${at}.probes must be an array`);
835
+ for (const [index, probe] of integration.probes.entries()) {
836
+ const pat = `${at}.probes[${index}]`;
837
+ if (!isRecord4(probe) || !safeProbePath(probe.path)) throw new Error(`${pat}.path must be an absolute path without query or fragment`);
838
+ if (!Number.isInteger(probe.expectedStatus) || probe.expectedStatus < 100 || probe.expectedStatus > 599) {
839
+ throw new Error(`${pat}.expectedStatus must be an HTTP status`);
840
+ }
841
+ }
842
+ }
843
+ function isRecord4(value) {
844
+ return value !== null && typeof value === "object" && !Array.isArray(value);
845
+ }
846
+ function safeText(value, max) {
847
+ return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
848
+ }
849
+ function safeProbePath(value) {
850
+ return typeof value === "string" && /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/.test(value);
851
+ }
852
+ function validId(value) {
853
+ return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
854
+ }
855
+ function unique(values) {
856
+ return [...new Set(values.filter(Boolean))];
857
+ }
858
+
859
+ // src/config.ts
789
860
  var DEFAULT_PLATFORM = "https://odla.ai";
790
861
  var DEFAULT_ENVS = ["dev"];
791
862
  var DEFAULT_SERVICES = ["db", "ai"];
@@ -800,8 +871,8 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
800
871
  validateRawConfig(raw, resolved);
801
872
  const platformUrl = trimSlash(process.env.ODLA_PLATFORM_URL || raw.platformUrl || DEFAULT_PLATFORM);
802
873
  const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
803
- const envs = unique(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
804
- const services = unique(raw.services?.length ? raw.services : DEFAULT_SERVICES);
874
+ const envs = unique2(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
875
+ const services = unique2(raw.services?.length ? raw.services : DEFAULT_SERVICES);
805
876
  validateCalendarConfig(raw, envs, services, resolved);
806
877
  const local = {
807
878
  tokenFile: resolve2(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
@@ -835,6 +906,8 @@ async function resolveDataExport(cfg, value, names) {
835
906
  throw new Error(`${value} did not export ${names.join(", ")} or default`);
836
907
  }
837
908
  function buildPlan(cfg) {
909
+ const integrationSchema = cfg.integrations?.some((integration) => integration.schema) ?? false;
910
+ const integrationRules = cfg.integrations?.some((integration) => integration.rules) ?? false;
838
911
  return {
839
912
  appId: cfg.app.id,
840
913
  appName: cfg.app.name,
@@ -842,8 +915,9 @@ function buildPlan(cfg) {
842
915
  dbEndpoint: cfg.dbEndpoint,
843
916
  envs: cfg.envs,
844
917
  services: cfg.services,
845
- hasSchema: !!cfg.db?.schema,
846
- hasRules: !!cfg.db?.rules || !!cfg.db?.schema && cfg.db.defaultRules !== false,
918
+ integrations: (cfg.integrations ?? []).map((integration) => integration.id),
919
+ hasSchema: !!cfg.db?.schema || integrationSchema,
920
+ hasRules: !!cfg.db?.rules || integrationRules || (!!cfg.db?.schema || integrationSchema) && cfg.db?.defaultRules !== false,
847
921
  aiProvider: cfg.ai?.provider
848
922
  };
849
923
  }
@@ -852,7 +926,7 @@ function calendarServiceConfig(cfg, env) {
852
926
  if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
853
927
  const google = cfg.calendar?.google;
854
928
  if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
855
- const availability = unique(
929
+ const availability = unique2(
856
930
  (google.availabilityCalendars?.[env] ?? google.calendars?.[env]).map((id) => id.trim())
857
931
  );
858
932
  return {
@@ -888,15 +962,16 @@ function validateRawConfig(raw, path) {
888
962
  if (!raw || typeof raw !== "object") throw new Error(`${path} must export an object`);
889
963
  const cfg = raw;
890
964
  if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
891
- if (!validId(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
965
+ if (!validId2(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
892
966
  if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
893
967
  if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
894
968
  throw new Error(`${path}: envs must be an array of names`);
895
969
  }
896
- if (cfg.envs?.some((env) => !validId(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
970
+ if (cfg.envs?.some((env) => !validId2(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
897
971
  if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
898
972
  throw new Error(`${path}: services must be an array of non-empty names`);
899
973
  }
974
+ validateIntegrations(cfg, path, DEFAULT_SERVICES);
900
975
  }
901
976
  function validateCalendarConfig(cfg, envs, services, path) {
902
977
  const enabled = services.includes("calendar");
@@ -904,9 +979,9 @@ function validateCalendarConfig(cfg, envs, services, path) {
904
979
  if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
905
980
  return;
906
981
  }
907
- if (!isRecord4(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
982
+ if (!isRecord5(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
908
983
  assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
909
- if (!isRecord4(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
984
+ if (!isRecord5(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
910
985
  const google = cfg.calendar.google;
911
986
  assertOnly(
912
987
  google,
@@ -918,7 +993,7 @@ function validateCalendarConfig(cfg, envs, services, path) {
918
993
  throw new Error(`${path}: calendar.google requires exactly one of availabilityCalendars or calendars (legacy)`);
919
994
  }
920
995
  const availability = google[availabilityKey];
921
- if (!isRecord4(availability)) throw new Error(`${path}: calendar.google.${availabilityKey} must map env names to calendar ids`);
996
+ if (!isRecord5(availability)) throw new Error(`${path}: calendar.google.${availabilityKey} must map env names to calendar ids`);
922
997
  const unknownEnv = Object.keys(availability).find((env) => !envs.includes(env));
923
998
  if (unknownEnv) throw new Error(`${path}: calendar.google.${availabilityKey}.${unknownEnv} is not in config envs`);
924
999
  for (const env of envs) {
@@ -929,22 +1004,22 @@ function validateCalendarConfig(cfg, envs, services, path) {
929
1004
  if (ids.length > 10) {
930
1005
  throw new Error(`${path}: calendar.google.${availabilityKey}.${env} must contain at most 10 calendar ids`);
931
1006
  }
932
- if (ids.some((id) => !safeText(id, 1024))) {
1007
+ if (ids.some((id) => !safeText2(id, 1024))) {
933
1008
  throw new Error(`${path}: calendar.google.${availabilityKey}.${env} contains an invalid calendar id`);
934
1009
  }
935
1010
  }
936
1011
  if (google.bookingCalendar !== void 0) {
937
- if (!isRecord4(google.bookingCalendar)) throw new Error(`${path}: calendar.google.bookingCalendar must map env names to one calendar id`);
1012
+ if (!isRecord5(google.bookingCalendar)) throw new Error(`${path}: calendar.google.bookingCalendar must map env names to one calendar id`);
938
1013
  const unknownBookingEnv = Object.keys(google.bookingCalendar).find((env) => !envs.includes(env));
939
1014
  if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingCalendar.${unknownBookingEnv} is not in config envs`);
940
1015
  for (const [env, value] of Object.entries(google.bookingCalendar)) {
941
- if (!safeText(value, 1024)) {
1016
+ if (!safeText2(value, 1024)) {
942
1017
  throw new Error(`${path}: calendar.google.bookingCalendar.${env} must be a calendar id`);
943
1018
  }
944
1019
  }
945
1020
  }
946
1021
  if (google.bookingPageUrl !== void 0) {
947
- if (!isRecord4(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
1022
+ if (!isRecord5(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
948
1023
  const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
949
1024
  if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
950
1025
  for (const [env, value] of Object.entries(google.bookingPageUrl)) {
@@ -959,10 +1034,10 @@ function assertOnly(value, allowed, label) {
959
1034
  const extra = Object.keys(value).find((key) => !allowed.includes(key));
960
1035
  if (extra) throw new Error(`${label}.${extra} is not supported`);
961
1036
  }
962
- function isRecord4(value) {
1037
+ function isRecord5(value) {
963
1038
  return value !== null && typeof value === "object" && !Array.isArray(value);
964
1039
  }
965
- function safeText(value, max) {
1040
+ function safeText2(value, max) {
966
1041
  return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
967
1042
  }
968
1043
  function safeHttpsUrl(value) {
@@ -974,7 +1049,7 @@ function safeHttpsUrl(value) {
974
1049
  return false;
975
1050
  }
976
1051
  }
977
- function validId(value) {
1052
+ function validId2(value) {
978
1053
  return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
979
1054
  }
980
1055
  async function loadConfigModule(path) {
@@ -987,7 +1062,7 @@ async function loadConfigModule(path) {
987
1062
  function trimSlash(value) {
988
1063
  return value.replace(/\/+$/, "");
989
1064
  }
990
- function unique(values) {
1065
+ function unique2(values) {
991
1066
  return [...new Set(values.filter(Boolean))];
992
1067
  }
993
1068
 
@@ -999,9 +1074,9 @@ var CAPABILITIES = {
999
1074
  "issue, persist, and explicitly rotate configured service credentials",
1000
1075
  "write local Worker values and push deployed Worker secrets through Wrangler stdin",
1001
1076
  "store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
1002
- "push db schema/rules and configure platform AI, auth, and deployment links",
1077
+ "compose declared app-capability schema/rules, create guarded seeds when absent, and configure platform AI, auth, and deployment links",
1003
1078
  "apply Google Calendar booking config, then drive state-bound consent, status, discovery, and disconnect flows (bookings run live through the platform proxy)",
1004
- "validate config offline and smoke-test a provisioned db environment",
1079
+ "validate integration contracts offline and smoke-test a provisioned db environment plus anonymous capability routes",
1005
1080
  "run app-attributed hosted security discovery and independent validation without provider keys",
1006
1081
  "connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
1007
1082
  "let admins manage system AI policy and credentials, inspect attributed usage, and read immutable admin changes through separate short-lived capability-only approvals"
@@ -1009,7 +1084,7 @@ var CAPABILITIES = {
1009
1084
  agent: [
1010
1085
  "install and import the selected odla SDKs",
1011
1086
  "wrap the Worker with withObservability and choose useful telemetry",
1012
- "make application-specific schema, rules, auth, UI, and migration decisions",
1087
+ "install capability packages, mount their runtime routes, and make application-specific schema, rules, auth, UI, and migration decisions",
1013
1088
  "wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
1014
1089
  ],
1015
1090
  human: [
@@ -1681,20 +1756,121 @@ function readPackageJson(rootDir) {
1681
1756
  }
1682
1757
  }
1683
1758
 
1759
+ // src/integrations.ts
1760
+ import { isDeepStrictEqual } from "util";
1761
+ async function resolveDatabaseConfig(cfg, options = {}) {
1762
+ const integrations = cfg.integrations ?? [];
1763
+ if (!cfg.services.includes("db")) return { schema: void 0, rules: void 0, integrations };
1764
+ const authoredSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
1765
+ const schema = mergeSchemas(authoredSchema, integrations);
1766
+ if (options.validateSeeds !== false) assertSeedContracts(integrations, schema);
1767
+ const configuredRules = await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]);
1768
+ const explicitRules = mergeRules(configuredRules, integrations);
1769
+ const rules = configuredRules === void 0 && schema && cfg.db?.defaultRules !== false ? { ...rulesFromSchema(schema), ...explicitRules ?? {} } : explicitRules;
1770
+ return { schema, rules, integrations };
1771
+ }
1772
+ function integrationWarnings(integrations, schema, rules) {
1773
+ const warnings = [];
1774
+ const entities = new Set(serializedEntities(schema));
1775
+ for (const integration of integrations) {
1776
+ const namespaces = Object.keys(integration.schema?.entities ?? {});
1777
+ for (const ns of namespaces) {
1778
+ if (!entities.has(ns)) warnings.push(`integration "${integration.id}" schema namespace "${ns}" is missing`);
1779
+ const rule = rules?.[ns];
1780
+ if (!rule) {
1781
+ warnings.push(`integration "${integration.id}" has no rules for "${ns}"`);
1782
+ continue;
1783
+ }
1784
+ for (const action of ["view", "create", "update", "delete"]) {
1785
+ if (rule[action] === void 0) warnings.push(`integration "${integration.id}" rule "${ns}.${action}" is missing`);
1786
+ }
1787
+ }
1788
+ for (const seed of integration.seeds ?? []) {
1789
+ if (!entities.has(seed.ns)) {
1790
+ warnings.push(`integration "${integration.id}" seed "${seed.id}" targets unknown namespace "${seed.ns}"`);
1791
+ } else if (!isUniqueAttr(schema, seed.ns, seed.key.attr)) {
1792
+ warnings.push(`integration "${integration.id}" seed "${seed.id}" key "${seed.ns}.${seed.key.attr}" is not declared unique`);
1793
+ }
1794
+ }
1795
+ }
1796
+ return warnings;
1797
+ }
1798
+ function mergeSchemas(base, integrations) {
1799
+ const fragments = integrations.filter((integration) => integration.schema);
1800
+ if (fragments.length === 0) return base;
1801
+ const merged = normalizeSchema(base);
1802
+ for (const integration of fragments) {
1803
+ const fragment = normalizeSchema(integration.schema);
1804
+ mergeMap(merged.entities, fragment.entities, `integration "${integration.id}" schema namespace`);
1805
+ mergeMap(merged.links, fragment.links, `integration "${integration.id}" schema link`);
1806
+ }
1807
+ return merged;
1808
+ }
1809
+ function mergeRules(base, integrations) {
1810
+ const fragments = integrations.filter((integration) => integration.rules);
1811
+ if (fragments.length === 0) return base;
1812
+ const merged = { ...base ?? {} };
1813
+ for (const integration of fragments) {
1814
+ mergeMap(merged, integration.rules ?? {}, `integration "${integration.id}" rule namespace`);
1815
+ }
1816
+ return merged;
1817
+ }
1818
+ function assertSeedContracts(integrations, schema) {
1819
+ const entities = new Set(serializedEntities(schema));
1820
+ for (const integration of integrations) {
1821
+ for (const seed of integration.seeds ?? []) {
1822
+ if (!entities.has(seed.ns)) {
1823
+ throw new Error(`integration "${integration.id}" seed "${seed.id}" targets unknown namespace "${seed.ns}"`);
1824
+ }
1825
+ if (!isUniqueAttr(schema, seed.ns, seed.key.attr)) {
1826
+ throw new Error(`integration "${integration.id}" seed "${seed.id}" key "${seed.ns}.${seed.key.attr}" must be declared unique`);
1827
+ }
1828
+ }
1829
+ }
1830
+ }
1831
+ function isUniqueAttr(schema, ns, attr) {
1832
+ if (!isRecord6(schema) || !isRecord6(schema.entities)) return false;
1833
+ const entity = schema.entities[ns];
1834
+ if (!isRecord6(entity) || !isRecord6(entity.attrs)) return false;
1835
+ const definition = entity.attrs[attr];
1836
+ return isRecord6(definition) && definition.unique === true;
1837
+ }
1838
+ function normalizeSchema(value) {
1839
+ if (value === void 0 || value === null) return { entities: {}, links: {} };
1840
+ if (!isRecord6(value) || !isRecord6(value.entities)) {
1841
+ throw new Error("db schema must be a serialized schema object with an entities map");
1842
+ }
1843
+ if (value.links !== void 0 && !isRecord6(value.links)) throw new Error("db schema links must be an object");
1844
+ return {
1845
+ entities: { ...value.entities },
1846
+ links: { ...value.links ?? {} }
1847
+ };
1848
+ }
1849
+ function mergeMap(target, fragment, label) {
1850
+ for (const [name, value] of Object.entries(fragment)) {
1851
+ if (Object.hasOwn(target, name) && !isDeepStrictEqual(target[name], value)) {
1852
+ throw new Error(`${label} "${name}" conflicts with an existing definition`);
1853
+ }
1854
+ target[name] = value;
1855
+ }
1856
+ }
1857
+ function isRecord6(value) {
1858
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1859
+ }
1860
+
1684
1861
  // src/doctor.ts
1685
1862
  async function doctor(options) {
1686
1863
  const out = options.stdout ?? console;
1687
1864
  const cfg = await loadProjectConfig(options.configPath);
1688
1865
  const plan = buildPlan(cfg);
1689
- const hasDb = cfg.services.includes("db");
1690
- const schema = hasDb ? await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]) : void 0;
1691
- const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
1692
- const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
1866
+ const database = await resolveDatabaseConfig(cfg, { validateSeeds: false });
1867
+ const { schema, rules } = database;
1693
1868
  const entities = serializedEntities(schema);
1694
1869
  out.log(`config: ${cfg.configPath}`);
1695
1870
  out.log(`app: ${plan.appName} (${plan.appId})`);
1696
1871
  out.log(`envs: ${plan.envs.join(", ")}`);
1697
1872
  out.log(`svc: ${plan.services.join(", ")}`);
1873
+ out.log(`integrations: ${plan.integrations.length ? plan.integrations.join(", ") : "none"}`);
1698
1874
  out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
1699
1875
  out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
1700
1876
  out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
@@ -1716,6 +1892,7 @@ async function doctor(options) {
1716
1892
  if (entities.length > 0 && !entities.includes(ns)) warnings.push(`rules include "${ns}" but schema has no matching entity`);
1717
1893
  }
1718
1894
  }
1895
+ warnings.push(...integrationWarnings(database.integrations, schema, rules));
1719
1896
  if (cfg.services.includes("ai") && !cfg.ai?.provider) warnings.push("ai service is enabled but ai.provider is not set");
1720
1897
  if (cfg.auth?.clerk) {
1721
1898
  for (const [env, value] of Object.entries(cfg.auth.clerk)) {
@@ -1806,6 +1983,9 @@ function configTemplate(input) {
1806
1983
  },
1807
1984
  envs: ${JSON.stringify(input.envs)},
1808
1985
  services: ${JSON.stringify(input.services)},
1986
+ // App capabilities are npm modules, not hosted services. Import their
1987
+ // data-only descriptor and add it here (for example createCrmIntegration()).
1988
+ integrations: [],
1809
1989
  db: {
1810
1990
  schema: "./src/odla/schema.mjs",
1811
1991
  rules: "./src/odla/rules.mjs",
@@ -1956,9 +2136,61 @@ function assertWranglerConfig(cfg) {
1956
2136
 
1957
2137
  // src/provision.ts
1958
2138
  import { createAppsClient, tenantIdFor as tenantIdFor2 } from "@odla-ai/apps";
1959
- import { DEFAULT_SECRET_NAMES, putSecret } from "@odla-ai/ai";
2139
+ import { putSecret } from "@odla-ai/ai";
1960
2140
  import process8 from "process";
1961
2141
 
2142
+ // src/integration-provision.ts
2143
+ import { uuidv7 } from "@odla-ai/db";
2144
+ async function provisionIntegrationSeeds(doFetch, endpoint, tenantId, dbKey, integrations, env, out) {
2145
+ const base = `${endpoint}/app/${encodeURIComponent(tenantId)}`;
2146
+ for (const integration of integrations) {
2147
+ for (const seed of integration.seeds ?? []) {
2148
+ const payload = await postJson(doFetch, `${base}/query`, dbKey, {
2149
+ query: { [seed.ns]: { $: { where: { [seed.key.attr]: seed.key.value }, limit: 1 } } }
2150
+ });
2151
+ const rows = isRecord7(payload) && isRecord7(payload.result) ? payload.result[seed.ns] : void 0;
2152
+ if (!Array.isArray(rows)) {
2153
+ throw new Error(`${env}: integration ${integration.id} seed ${seed.id} query returned an invalid response`);
2154
+ }
2155
+ if (rows.length > 0) {
2156
+ out.log(`${env}: integration ${integration.id} seed ${seed.id} already exists`);
2157
+ continue;
2158
+ }
2159
+ await postJson(doFetch, `${base}/transact`, dbKey, {
2160
+ mutationId: `integration:${integration.id}:seed:${seed.id}`,
2161
+ ops: [{
2162
+ t: "update",
2163
+ ns: seed.ns,
2164
+ // A fresh id plus the declared-unique natural key is create-only: a
2165
+ // concurrent creator gets a unique violation instead of being overwritten.
2166
+ id: uuidv7(),
2167
+ attrs: { ...seed.attrs, [seed.key.attr]: seed.key.value }
2168
+ }]
2169
+ });
2170
+ out.log(`${env}: integration ${integration.id} seed ${seed.id} created`);
2171
+ }
2172
+ }
2173
+ }
2174
+ async function postJson(doFetch, url, bearer, body) {
2175
+ const res = await doFetch(url, {
2176
+ method: "POST",
2177
+ headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
2178
+ body: JSON.stringify(body)
2179
+ });
2180
+ if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await responseText(res)}`);
2181
+ return res.json().catch(() => ({}));
2182
+ }
2183
+ function isRecord7(value) {
2184
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2185
+ }
2186
+ async function responseText(res) {
2187
+ try {
2188
+ return redactSecrets((await res.text()).slice(0, 500));
2189
+ } catch {
2190
+ return "";
2191
+ }
2192
+ }
2193
+
1962
2194
  // src/provision-credentials.ts
1963
2195
  import { tenantIdFor } from "@odla-ai/apps";
1964
2196
  async function provisionEnvCredentials(opts) {
@@ -2018,14 +2250,14 @@ async function mintDbKey(opts, tenantId) {
2018
2250
  appId: tenantId
2019
2251
  })
2020
2252
  });
2021
- if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText2(created)}`);
2253
+ if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText3(created)}`);
2022
2254
  res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
2023
2255
  method: "POST",
2024
2256
  headers,
2025
2257
  body: "{}"
2026
2258
  });
2027
2259
  }
2028
- if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText2(res)}`);
2260
+ if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText3(res)}`);
2029
2261
  const body = await res.json();
2030
2262
  if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
2031
2263
  return body.key;
@@ -2041,12 +2273,12 @@ async function issueO11yToken(opts) {
2041
2273
  `o11y token already exists for env "${opts.env}", but its shown-once value is not in the local credentials file; run "odla-ai provision --rotate-o11y-token --push-secrets" to replace it explicitly`
2042
2274
  );
2043
2275
  }
2044
- if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText2(res)}`);
2276
+ if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText3(res)}`);
2045
2277
  const body = await res.json();
2046
2278
  if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
2047
2279
  return body.token;
2048
2280
  }
2049
- async function safeText2(res) {
2281
+ async function safeText3(res) {
2050
2282
  try {
2051
2283
  return redactSecrets((await res.text()).slice(0, 500));
2052
2284
  } catch {
@@ -2054,6 +2286,18 @@ async function safeText2(res) {
2054
2286
  }
2055
2287
  }
2056
2288
 
2289
+ // src/provision-helpers.ts
2290
+ import { DEFAULT_SECRET_NAMES } from "@odla-ai/ai";
2291
+ function serviceRank(service) {
2292
+ if (service === "db") return 0;
2293
+ if (service === "calendar") return 2;
2294
+ return 1;
2295
+ }
2296
+ function defaultSecretName(provider) {
2297
+ const names = DEFAULT_SECRET_NAMES;
2298
+ return names[provider] ?? `${provider}_api_key`;
2299
+ }
2300
+
2057
2301
  // src/provision.ts
2058
2302
  async function provision(options) {
2059
2303
  const out = options.stdout ?? console;
@@ -2072,19 +2316,23 @@ async function provision(options) {
2072
2316
  out.log(` db: ${plan.dbEndpoint}`);
2073
2317
  out.log(` envs: ${plan.envs.join(", ")}`);
2074
2318
  out.log(` services: ${plan.services.join(", ")}`);
2319
+ out.log(` integrations: ${plan.integrations.length ? plan.integrations.join(", ") : "none"}`);
2075
2320
  const productionEnvs = plan.envs.filter((env) => env === "prod" || env === "production");
2076
2321
  if (!options.dryRun && productionEnvs.length > 0 && !options.yes) {
2077
2322
  throw new Error(
2078
2323
  `refusing to provision production env ${productionEnvs.join(", ")} without --yes; run --dry-run first`
2079
2324
  );
2080
2325
  }
2081
- const schema = hasDb ? await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]) : void 0;
2082
- const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
2083
- const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
2326
+ const database = await resolveDatabaseConfig(cfg);
2327
+ const { schema, rules } = database;
2084
2328
  if (options.dryRun) {
2085
2329
  out.log("dry run: no network calls or file writes");
2086
2330
  out.log(` schema: ${schema ? "yes" : "no"}`);
2087
2331
  out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
2332
+ for (const integration of database.integrations) {
2333
+ const namespaces = Object.keys(integration.schema?.entities ?? {}).length;
2334
+ out.log(` integration.${integration.id}: ${namespaces} namespaces, ${integration.seeds?.length ?? 0} seeds, ${integration.probes?.length ?? 0} smoke probes`);
2335
+ }
2088
2336
  out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
2089
2337
  if (cfg.services.includes("calendar")) {
2090
2338
  for (const env of cfg.envs) {
@@ -2194,13 +2442,16 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
2194
2442
  }
2195
2443
  }
2196
2444
  if (schema && dbKey) {
2197
- await postJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, dbKey, { schema });
2445
+ await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, dbKey, { schema });
2198
2446
  out.log(`${env}: schema pushed`);
2199
2447
  }
2200
2448
  if (rules) {
2201
- await postJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/admin/rules`, token, rules);
2449
+ await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/admin/rules`, token, rules);
2202
2450
  out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
2203
2451
  }
2452
+ if (dbKey) {
2453
+ await provisionIntegrationSeeds(doFetch, cfg.dbEndpoint, tenantId, dbKey, database.integrations, env, out);
2454
+ }
2204
2455
  if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
2205
2456
  const key = process8.env[cfg.ai.keyEnv];
2206
2457
  if (key) {
@@ -2241,11 +2492,6 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
2241
2492
  }
2242
2493
  }
2243
2494
  }
2244
- function serviceRank(service) {
2245
- if (service === "db") return 0;
2246
- if (service === "calendar") return 2;
2247
- return 1;
2248
- }
2249
2495
  async function readRegistryApp(cfg, token, doFetch) {
2250
2496
  const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
2251
2497
  headers: { authorization: `Bearer ${token}` }
@@ -2255,13 +2501,13 @@ async function readRegistryApp(cfg, token, doFetch) {
2255
2501
  const json = await res.json();
2256
2502
  return json.app ?? null;
2257
2503
  }
2258
- async function postJson(doFetch, url, bearer, body) {
2504
+ async function postJson2(doFetch, url, bearer, body) {
2259
2505
  const res = await doFetch(url, {
2260
2506
  method: "POST",
2261
2507
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
2262
2508
  body: JSON.stringify(body)
2263
2509
  });
2264
- if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText3(res)}`);
2510
+ if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText4(res)}`);
2265
2511
  }
2266
2512
  function normalizeClerkConfig(value) {
2267
2513
  if (!value) return null;
@@ -2274,11 +2520,7 @@ function normalizeClerkConfig(value) {
2274
2520
  const publishableKey = envValue(cfg.publishableKey);
2275
2521
  return publishableKey ? { publishableKey, ...cfg.audience ? { audience: cfg.audience } : {}, ...cfg.mode ? { mode: cfg.mode } : {} } : null;
2276
2522
  }
2277
- function defaultSecretName(provider) {
2278
- const names = DEFAULT_SECRET_NAMES;
2279
- return names[provider] ?? `${provider}_api_key`;
2280
- }
2281
- async function safeText3(res) {
2523
+ async function safeText4(res) {
2282
2524
  try {
2283
2525
  return redactSecrets((await res.text()).slice(0, 500));
2284
2526
  } catch {
@@ -3130,7 +3372,8 @@ async function smoke(options) {
3130
3372
  assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
3131
3373
  out.log(` calendar: ${status.status}, bookable (booking \u2192 ${status.bookingCalendarId ?? "primary"})`);
3132
3374
  }
3133
- const expectedSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
3375
+ const database = await resolveDatabaseConfig(cfg);
3376
+ const expectedSchema = database.schema;
3134
3377
  const expectedEntities = serializedEntities(expectedSchema);
3135
3378
  const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
3136
3379
  const liveSchema = liveSchemaPayload.schema ?? liveSchemaPayload;
@@ -3142,7 +3385,7 @@ async function smoke(options) {
3142
3385
  out.log(` schema: ${liveEntities.length} entities`);
3143
3386
  const aggregateEntity = expectedEntities[0] ?? liveEntities[0];
3144
3387
  if (aggregateEntity) {
3145
- const aggregate = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
3388
+ const aggregate = await postJson3(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
3146
3389
  ns: aggregateEntity,
3147
3390
  aggregate: { count: true }
3148
3391
  });
@@ -3151,6 +3394,21 @@ async function smoke(options) {
3151
3394
  } else {
3152
3395
  out.log(` aggregate: skipped (schema has no entities)`);
3153
3396
  }
3397
+ const probes = database.integrations.flatMap(
3398
+ (integration) => (integration.probes ?? []).map((probe) => ({ integration: integration.id, probe }))
3399
+ );
3400
+ if (probes.length) {
3401
+ const link = cfg.links?.[env];
3402
+ if (!link) throw new Error(`integration smoke probes require links.${env} in odla.config.mjs`);
3403
+ for (const { integration, probe } of probes) {
3404
+ const probeUrl = new URL(probe.path, link).toString();
3405
+ const res = await doFetch(probeUrl, { redirect: "manual" });
3406
+ if (res.status !== probe.expectedStatus) {
3407
+ throw new Error(`integration "${integration}" probe ${probe.path} returned ${res.status}; expected ${probe.expectedStatus}`);
3408
+ }
3409
+ out.log(` integration.${integration}: ${probe.path} \u2192 ${res.status}`);
3410
+ }
3411
+ }
3154
3412
  out.log("ok");
3155
3413
  }
3156
3414
  function assertCalendarHealthy(status, expected) {
@@ -3168,16 +3426,16 @@ async function getJson(doFetch, url, bearer) {
3168
3426
  const res = await doFetch(url, {
3169
3427
  headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
3170
3428
  });
3171
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
3429
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText5(res)}`);
3172
3430
  return res.json();
3173
3431
  }
3174
- async function postJson2(doFetch, url, bearer, body) {
3432
+ async function postJson3(doFetch, url, bearer, body) {
3175
3433
  const res = await doFetch(url, {
3176
3434
  method: "POST",
3177
3435
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
3178
3436
  body: JSON.stringify(body)
3179
3437
  });
3180
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
3438
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText5(res)}`);
3181
3439
  return res.json();
3182
3440
  }
3183
3441
  function publicConfigUrl(platformUrl, appId, env) {
@@ -3185,7 +3443,7 @@ function publicConfigUrl(platformUrl, appId, env) {
3185
3443
  url.searchParams.set("env", env);
3186
3444
  return url.toString();
3187
3445
  }
3188
- async function safeText4(res) {
3446
+ async function safeText5(res) {
3189
3447
  try {
3190
3448
  return (await res.text()).slice(0, 500);
3191
3449
  } catch {
@@ -3512,8 +3770,8 @@ Commands:
3512
3770
  capabilities Show what the CLI automates vs agent edits and human checkpoints.
3513
3771
  admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
3514
3772
  security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
3515
- provision Register services, configure them, persist credentials, optionally push secrets.
3516
- smoke Verify local credentials, public-config, live schema, and db aggregate.
3773
+ provision Register services, compose integrations, persist credentials, optionally push secrets.
3774
+ smoke Verify credentials, public-config, composed schema, db aggregate, and integration probes.
3517
3775
  skill Same installer; --agent accepts all, claude, codex, cursor,
3518
3776
  copilot, gemini, or agents (repeatable or comma-separated).
3519
3777
  secrets Push configured db/o11y secrets into the Worker via wrangler
@@ -4249,4 +4507,4 @@ export {
4249
4507
  exitCodeFor,
4250
4508
  runCli
4251
4509
  };
4252
- //# sourceMappingURL=chunk-Y4HWUEFF.js.map
4510
+ //# sourceMappingURL=chunk-I7XDJZB6.js.map