@odla-ai/cli 0.12.0 → 0.13.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.
package/dist/bin.cjs CHANGED
@@ -954,6 +954,77 @@ var import_promises = require("stream/promises");
954
954
  var import_node_fs4 = require("fs");
955
955
  var import_node_path3 = require("path");
956
956
  var import_node_url = require("url");
957
+
958
+ // src/integration-validation.ts
959
+ function validateIntegrations(cfg, path, defaultServices) {
960
+ if (cfg.integrations === void 0) return;
961
+ if (!Array.isArray(cfg.integrations)) throw new Error(`${path}: integrations must be an array`);
962
+ const ids = /* @__PURE__ */ new Set();
963
+ for (const [index, integration] of cfg.integrations.entries()) {
964
+ const at = `${path}: integrations[${index}]`;
965
+ if (!isRecord4(integration)) throw new Error(`${at} must be an object`);
966
+ if (!validId(integration.id)) throw new Error(`${at}.id must be lowercase letters, numbers, and hyphens`);
967
+ if (ids.has(integration.id)) throw new Error(`${path}: duplicate integration id "${integration.id}"`);
968
+ ids.add(integration.id);
969
+ if (!safeText(integration.title, 200)) throw new Error(`${at}.title is required`);
970
+ if (!safeText(integration.npm, 200)) throw new Error(`${at}.npm is required`);
971
+ if (integration.schema !== void 0 && (!isRecord4(integration.schema) || !isRecord4(integration.schema.entities))) {
972
+ throw new Error(`${at}.schema must contain an entities object`);
973
+ }
974
+ if (integration.rules !== void 0 && !isRecord4(integration.rules)) throw new Error(`${at}.rules must be an object`);
975
+ validateSeeds(integration, at);
976
+ validateProbes(integration, at);
977
+ }
978
+ const needsDb = cfg.integrations.some((integration) => integration.schema || integration.rules || integration.seeds?.length);
979
+ const services = unique(cfg.services?.length ? cfg.services : defaultServices);
980
+ if (needsDb && !services.includes("db")) throw new Error(`${path}: schema/rules/seed integrations require the db service`);
981
+ }
982
+ function validateSeeds(integration, at) {
983
+ if (integration.seeds === void 0) return;
984
+ if (!Array.isArray(integration.seeds)) throw new Error(`${at}.seeds must be an array`);
985
+ const ids = /* @__PURE__ */ new Set();
986
+ for (const [index, seed] of integration.seeds.entries()) {
987
+ const sat = `${at}.seeds[${index}]`;
988
+ if (!isRecord4(seed) || !safeText(seed.id, 200) || !safeText(seed.ns, 200)) throw new Error(`${sat} requires id and ns`);
989
+ if (ids.has(seed.id)) throw new Error(`${at} has duplicate seed id "${seed.id}"`);
990
+ ids.add(seed.id);
991
+ if (!isRecord4(seed.key) || !safeText(seed.key.attr, 200) || !safeText(seed.key.value, 2048)) {
992
+ throw new Error(`${sat}.key requires string attr and value`);
993
+ }
994
+ if (!isRecord4(seed.attrs)) throw new Error(`${sat}.attrs must be an object`);
995
+ if (Object.hasOwn(seed.attrs, seed.key.attr) && seed.attrs[seed.key.attr] !== seed.key.value) {
996
+ throw new Error(`${sat}.attrs.${seed.key.attr} conflicts with its natural key`);
997
+ }
998
+ }
999
+ }
1000
+ function validateProbes(integration, at) {
1001
+ if (integration.probes === void 0) return;
1002
+ if (!Array.isArray(integration.probes)) throw new Error(`${at}.probes must be an array`);
1003
+ for (const [index, probe] of integration.probes.entries()) {
1004
+ const pat = `${at}.probes[${index}]`;
1005
+ if (!isRecord4(probe) || !safeProbePath(probe.path)) throw new Error(`${pat}.path must be an absolute path without query or fragment`);
1006
+ if (!Number.isInteger(probe.expectedStatus) || probe.expectedStatus < 100 || probe.expectedStatus > 599) {
1007
+ throw new Error(`${pat}.expectedStatus must be an HTTP status`);
1008
+ }
1009
+ }
1010
+ }
1011
+ function isRecord4(value) {
1012
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1013
+ }
1014
+ function safeText(value, max) {
1015
+ return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
1016
+ }
1017
+ function safeProbePath(value) {
1018
+ return typeof value === "string" && /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/.test(value);
1019
+ }
1020
+ function validId(value) {
1021
+ return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
1022
+ }
1023
+ function unique(values) {
1024
+ return [...new Set(values.filter(Boolean))];
1025
+ }
1026
+
1027
+ // src/config.ts
957
1028
  var DEFAULT_PLATFORM = "https://odla.ai";
958
1029
  var DEFAULT_ENVS = ["dev"];
959
1030
  var DEFAULT_SERVICES = ["db", "ai"];
@@ -968,8 +1039,8 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
968
1039
  validateRawConfig(raw, resolved);
969
1040
  const platformUrl = trimSlash(process.env.ODLA_PLATFORM_URL || raw.platformUrl || DEFAULT_PLATFORM);
970
1041
  const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
971
- const envs = unique(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
972
- const services = unique(raw.services?.length ? raw.services : DEFAULT_SERVICES);
1042
+ const envs = unique2(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
1043
+ const services = unique2(raw.services?.length ? raw.services : DEFAULT_SERVICES);
973
1044
  validateCalendarConfig(raw, envs, services, resolved);
974
1045
  const local = {
975
1046
  tokenFile: (0, import_node_path3.resolve)(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
@@ -1003,6 +1074,8 @@ async function resolveDataExport(cfg, value, names) {
1003
1074
  throw new Error(`${value} did not export ${names.join(", ")} or default`);
1004
1075
  }
1005
1076
  function buildPlan(cfg) {
1077
+ const integrationSchema = cfg.integrations?.some((integration) => integration.schema) ?? false;
1078
+ const integrationRules = cfg.integrations?.some((integration) => integration.rules) ?? false;
1006
1079
  return {
1007
1080
  appId: cfg.app.id,
1008
1081
  appName: cfg.app.name,
@@ -1010,8 +1083,9 @@ function buildPlan(cfg) {
1010
1083
  dbEndpoint: cfg.dbEndpoint,
1011
1084
  envs: cfg.envs,
1012
1085
  services: cfg.services,
1013
- hasSchema: !!cfg.db?.schema,
1014
- hasRules: !!cfg.db?.rules || !!cfg.db?.schema && cfg.db.defaultRules !== false,
1086
+ integrations: (cfg.integrations ?? []).map((integration) => integration.id),
1087
+ hasSchema: !!cfg.db?.schema || integrationSchema,
1088
+ hasRules: !!cfg.db?.rules || integrationRules || (!!cfg.db?.schema || integrationSchema) && cfg.db?.defaultRules !== false,
1015
1089
  aiProvider: cfg.ai?.provider
1016
1090
  };
1017
1091
  }
@@ -1020,7 +1094,7 @@ function calendarServiceConfig(cfg, env) {
1020
1094
  if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
1021
1095
  const google = cfg.calendar?.google;
1022
1096
  if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
1023
- const availability = unique(
1097
+ const availability = unique2(
1024
1098
  (google.availabilityCalendars?.[env] ?? google.calendars?.[env]).map((id) => id.trim())
1025
1099
  );
1026
1100
  return {
@@ -1056,15 +1130,16 @@ function validateRawConfig(raw, path) {
1056
1130
  if (!raw || typeof raw !== "object") throw new Error(`${path} must export an object`);
1057
1131
  const cfg = raw;
1058
1132
  if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
1059
- if (!validId(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
1133
+ if (!validId2(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
1060
1134
  if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
1061
1135
  if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
1062
1136
  throw new Error(`${path}: envs must be an array of names`);
1063
1137
  }
1064
- if (cfg.envs?.some((env) => !validId(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
1138
+ if (cfg.envs?.some((env) => !validId2(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
1065
1139
  if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
1066
1140
  throw new Error(`${path}: services must be an array of non-empty names`);
1067
1141
  }
1142
+ validateIntegrations(cfg, path, DEFAULT_SERVICES);
1068
1143
  }
1069
1144
  function validateCalendarConfig(cfg, envs, services, path) {
1070
1145
  const enabled = services.includes("calendar");
@@ -1072,9 +1147,9 @@ function validateCalendarConfig(cfg, envs, services, path) {
1072
1147
  if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
1073
1148
  return;
1074
1149
  }
1075
- if (!isRecord4(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
1150
+ if (!isRecord5(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
1076
1151
  assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
1077
- if (!isRecord4(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
1152
+ if (!isRecord5(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
1078
1153
  const google = cfg.calendar.google;
1079
1154
  assertOnly(
1080
1155
  google,
@@ -1086,7 +1161,7 @@ function validateCalendarConfig(cfg, envs, services, path) {
1086
1161
  throw new Error(`${path}: calendar.google requires exactly one of availabilityCalendars or calendars (legacy)`);
1087
1162
  }
1088
1163
  const availability = google[availabilityKey];
1089
- if (!isRecord4(availability)) throw new Error(`${path}: calendar.google.${availabilityKey} must map env names to calendar ids`);
1164
+ if (!isRecord5(availability)) throw new Error(`${path}: calendar.google.${availabilityKey} must map env names to calendar ids`);
1090
1165
  const unknownEnv = Object.keys(availability).find((env) => !envs.includes(env));
1091
1166
  if (unknownEnv) throw new Error(`${path}: calendar.google.${availabilityKey}.${unknownEnv} is not in config envs`);
1092
1167
  for (const env of envs) {
@@ -1097,22 +1172,22 @@ function validateCalendarConfig(cfg, envs, services, path) {
1097
1172
  if (ids.length > 10) {
1098
1173
  throw new Error(`${path}: calendar.google.${availabilityKey}.${env} must contain at most 10 calendar ids`);
1099
1174
  }
1100
- if (ids.some((id) => !safeText(id, 1024))) {
1175
+ if (ids.some((id) => !safeText2(id, 1024))) {
1101
1176
  throw new Error(`${path}: calendar.google.${availabilityKey}.${env} contains an invalid calendar id`);
1102
1177
  }
1103
1178
  }
1104
1179
  if (google.bookingCalendar !== void 0) {
1105
- if (!isRecord4(google.bookingCalendar)) throw new Error(`${path}: calendar.google.bookingCalendar must map env names to one calendar id`);
1180
+ if (!isRecord5(google.bookingCalendar)) throw new Error(`${path}: calendar.google.bookingCalendar must map env names to one calendar id`);
1106
1181
  const unknownBookingEnv = Object.keys(google.bookingCalendar).find((env) => !envs.includes(env));
1107
1182
  if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingCalendar.${unknownBookingEnv} is not in config envs`);
1108
1183
  for (const [env, value] of Object.entries(google.bookingCalendar)) {
1109
- if (!safeText(value, 1024)) {
1184
+ if (!safeText2(value, 1024)) {
1110
1185
  throw new Error(`${path}: calendar.google.bookingCalendar.${env} must be a calendar id`);
1111
1186
  }
1112
1187
  }
1113
1188
  }
1114
1189
  if (google.bookingPageUrl !== void 0) {
1115
- if (!isRecord4(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
1190
+ if (!isRecord5(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
1116
1191
  const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
1117
1192
  if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
1118
1193
  for (const [env, value] of Object.entries(google.bookingPageUrl)) {
@@ -1127,10 +1202,10 @@ function assertOnly(value, allowed, label) {
1127
1202
  const extra = Object.keys(value).find((key) => !allowed.includes(key));
1128
1203
  if (extra) throw new Error(`${label}.${extra} is not supported`);
1129
1204
  }
1130
- function isRecord4(value) {
1205
+ function isRecord5(value) {
1131
1206
  return value !== null && typeof value === "object" && !Array.isArray(value);
1132
1207
  }
1133
- function safeText(value, max) {
1208
+ function safeText2(value, max) {
1134
1209
  return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
1135
1210
  }
1136
1211
  function safeHttpsUrl(value) {
@@ -1142,7 +1217,7 @@ function safeHttpsUrl(value) {
1142
1217
  return false;
1143
1218
  }
1144
1219
  }
1145
- function validId(value) {
1220
+ function validId2(value) {
1146
1221
  return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
1147
1222
  }
1148
1223
  async function loadConfigModule(path) {
@@ -1155,7 +1230,7 @@ async function loadConfigModule(path) {
1155
1230
  function trimSlash(value) {
1156
1231
  return value.replace(/\/+$/, "");
1157
1232
  }
1158
- function unique(values) {
1233
+ function unique2(values) {
1159
1234
  return [...new Set(values.filter(Boolean))];
1160
1235
  }
1161
1236
 
@@ -1285,9 +1360,9 @@ var CAPABILITIES = {
1285
1360
  "issue, persist, and explicitly rotate configured service credentials",
1286
1361
  "write local Worker values and push deployed Worker secrets through Wrangler stdin",
1287
1362
  "store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
1288
- "push db schema/rules and configure platform AI, auth, and deployment links",
1363
+ "compose declared app-capability schema/rules, create guarded seeds when absent, and configure platform AI, auth, and deployment links",
1289
1364
  "apply Google Calendar booking config, then drive state-bound consent, status, discovery, and disconnect flows (bookings run live through the platform proxy)",
1290
- "validate config offline and smoke-test a provisioned db environment",
1365
+ "validate integration contracts offline and smoke-test a provisioned db environment plus anonymous capability routes",
1291
1366
  "run app-attributed hosted security discovery and independent validation without provider keys",
1292
1367
  "connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
1293
1368
  "let admins manage system AI policy and credentials, inspect attributed usage, and read immutable admin changes through separate short-lived capability-only approvals"
@@ -1295,7 +1370,7 @@ var CAPABILITIES = {
1295
1370
  agent: [
1296
1371
  "install and import the selected odla SDKs",
1297
1372
  "wrap the Worker with withObservability and choose useful telemetry",
1298
- "make application-specific schema, rules, auth, UI, and migration decisions",
1373
+ "install capability packages, mount their runtime routes, and make application-specific schema, rules, auth, UI, and migration decisions",
1299
1374
  "wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
1300
1375
  ],
1301
1376
  human: [
@@ -1967,20 +2042,121 @@ function readPackageJson(rootDir) {
1967
2042
  }
1968
2043
  }
1969
2044
 
2045
+ // src/integrations.ts
2046
+ var import_node_util = require("util");
2047
+ async function resolveDatabaseConfig(cfg, options = {}) {
2048
+ const integrations = cfg.integrations ?? [];
2049
+ if (!cfg.services.includes("db")) return { schema: void 0, rules: void 0, integrations };
2050
+ const authoredSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
2051
+ const schema = mergeSchemas(authoredSchema, integrations);
2052
+ if (options.validateSeeds !== false) assertSeedContracts(integrations, schema);
2053
+ const configuredRules = await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]);
2054
+ const explicitRules = mergeRules(configuredRules, integrations);
2055
+ const rules = configuredRules === void 0 && schema && cfg.db?.defaultRules !== false ? { ...rulesFromSchema(schema), ...explicitRules ?? {} } : explicitRules;
2056
+ return { schema, rules, integrations };
2057
+ }
2058
+ function integrationWarnings(integrations, schema, rules) {
2059
+ const warnings = [];
2060
+ const entities = new Set(serializedEntities(schema));
2061
+ for (const integration of integrations) {
2062
+ const namespaces = Object.keys(integration.schema?.entities ?? {});
2063
+ for (const ns of namespaces) {
2064
+ if (!entities.has(ns)) warnings.push(`integration "${integration.id}" schema namespace "${ns}" is missing`);
2065
+ const rule = rules?.[ns];
2066
+ if (!rule) {
2067
+ warnings.push(`integration "${integration.id}" has no rules for "${ns}"`);
2068
+ continue;
2069
+ }
2070
+ for (const action of ["view", "create", "update", "delete"]) {
2071
+ if (rule[action] === void 0) warnings.push(`integration "${integration.id}" rule "${ns}.${action}" is missing`);
2072
+ }
2073
+ }
2074
+ for (const seed of integration.seeds ?? []) {
2075
+ if (!entities.has(seed.ns)) {
2076
+ warnings.push(`integration "${integration.id}" seed "${seed.id}" targets unknown namespace "${seed.ns}"`);
2077
+ } else if (!isUniqueAttr(schema, seed.ns, seed.key.attr)) {
2078
+ warnings.push(`integration "${integration.id}" seed "${seed.id}" key "${seed.ns}.${seed.key.attr}" is not declared unique`);
2079
+ }
2080
+ }
2081
+ }
2082
+ return warnings;
2083
+ }
2084
+ function mergeSchemas(base, integrations) {
2085
+ const fragments = integrations.filter((integration) => integration.schema);
2086
+ if (fragments.length === 0) return base;
2087
+ const merged = normalizeSchema(base);
2088
+ for (const integration of fragments) {
2089
+ const fragment = normalizeSchema(integration.schema);
2090
+ mergeMap(merged.entities, fragment.entities, `integration "${integration.id}" schema namespace`);
2091
+ mergeMap(merged.links, fragment.links, `integration "${integration.id}" schema link`);
2092
+ }
2093
+ return merged;
2094
+ }
2095
+ function mergeRules(base, integrations) {
2096
+ const fragments = integrations.filter((integration) => integration.rules);
2097
+ if (fragments.length === 0) return base;
2098
+ const merged = { ...base ?? {} };
2099
+ for (const integration of fragments) {
2100
+ mergeMap(merged, integration.rules ?? {}, `integration "${integration.id}" rule namespace`);
2101
+ }
2102
+ return merged;
2103
+ }
2104
+ function assertSeedContracts(integrations, schema) {
2105
+ const entities = new Set(serializedEntities(schema));
2106
+ for (const integration of integrations) {
2107
+ for (const seed of integration.seeds ?? []) {
2108
+ if (!entities.has(seed.ns)) {
2109
+ throw new Error(`integration "${integration.id}" seed "${seed.id}" targets unknown namespace "${seed.ns}"`);
2110
+ }
2111
+ if (!isUniqueAttr(schema, seed.ns, seed.key.attr)) {
2112
+ throw new Error(`integration "${integration.id}" seed "${seed.id}" key "${seed.ns}.${seed.key.attr}" must be declared unique`);
2113
+ }
2114
+ }
2115
+ }
2116
+ }
2117
+ function isUniqueAttr(schema, ns, attr) {
2118
+ if (!isRecord6(schema) || !isRecord6(schema.entities)) return false;
2119
+ const entity = schema.entities[ns];
2120
+ if (!isRecord6(entity) || !isRecord6(entity.attrs)) return false;
2121
+ const definition = entity.attrs[attr];
2122
+ return isRecord6(definition) && definition.unique === true;
2123
+ }
2124
+ function normalizeSchema(value) {
2125
+ if (value === void 0 || value === null) return { entities: {}, links: {} };
2126
+ if (!isRecord6(value) || !isRecord6(value.entities)) {
2127
+ throw new Error("db schema must be a serialized schema object with an entities map");
2128
+ }
2129
+ if (value.links !== void 0 && !isRecord6(value.links)) throw new Error("db schema links must be an object");
2130
+ return {
2131
+ entities: { ...value.entities },
2132
+ links: { ...value.links ?? {} }
2133
+ };
2134
+ }
2135
+ function mergeMap(target, fragment, label) {
2136
+ for (const [name, value] of Object.entries(fragment)) {
2137
+ if (Object.hasOwn(target, name) && !(0, import_node_util.isDeepStrictEqual)(target[name], value)) {
2138
+ throw new Error(`${label} "${name}" conflicts with an existing definition`);
2139
+ }
2140
+ target[name] = value;
2141
+ }
2142
+ }
2143
+ function isRecord6(value) {
2144
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2145
+ }
2146
+
1970
2147
  // src/doctor.ts
1971
2148
  async function doctor(options) {
1972
2149
  const out = options.stdout ?? console;
1973
2150
  const cfg = await loadProjectConfig(options.configPath);
1974
2151
  const plan = buildPlan(cfg);
1975
- const hasDb = cfg.services.includes("db");
1976
- const schema = hasDb ? await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]) : void 0;
1977
- const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
1978
- const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
2152
+ const database = await resolveDatabaseConfig(cfg, { validateSeeds: false });
2153
+ const { schema, rules } = database;
1979
2154
  const entities = serializedEntities(schema);
1980
2155
  out.log(`config: ${cfg.configPath}`);
1981
2156
  out.log(`app: ${plan.appName} (${plan.appId})`);
1982
2157
  out.log(`envs: ${plan.envs.join(", ")}`);
1983
2158
  out.log(`svc: ${plan.services.join(", ")}`);
2159
+ out.log(`integrations: ${plan.integrations.length ? plan.integrations.join(", ") : "none"}`);
1984
2160
  out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
1985
2161
  out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
1986
2162
  out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
@@ -2002,6 +2178,7 @@ async function doctor(options) {
2002
2178
  if (entities.length > 0 && !entities.includes(ns)) warnings.push(`rules include "${ns}" but schema has no matching entity`);
2003
2179
  }
2004
2180
  }
2181
+ warnings.push(...integrationWarnings(database.integrations, schema, rules));
2005
2182
  if (cfg.services.includes("ai") && !cfg.ai?.provider) warnings.push("ai service is enabled but ai.provider is not set");
2006
2183
  if (cfg.auth?.clerk) {
2007
2184
  for (const [env, value] of Object.entries(cfg.auth.clerk)) {
@@ -2102,8 +2279,8 @@ Commands:
2102
2279
  capabilities Show what the CLI automates vs agent edits and human checkpoints.
2103
2280
  admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
2104
2281
  security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
2105
- provision Register services, configure them, persist credentials, optionally push secrets.
2106
- smoke Verify local credentials, public-config, live schema, and db aggregate.
2282
+ provision Register services, compose integrations, persist credentials, optionally push secrets.
2283
+ smoke Verify credentials, public-config, composed schema, db aggregate, and integration probes.
2107
2284
  skill Same installer; --agent accepts all, claude, codex, cursor,
2108
2285
  copilot, gemini, or agents (repeatable or comma-separated).
2109
2286
  secrets Push configured db/o11y secrets into the Worker via wrangler
@@ -2213,6 +2390,9 @@ function configTemplate(input) {
2213
2390
  },
2214
2391
  envs: ${JSON.stringify(input.envs)},
2215
2392
  services: ${JSON.stringify(input.services)},
2393
+ // App capabilities are npm modules, not hosted services. Import their
2394
+ // data-only descriptor and add it here (for example createCrmIntegration()).
2395
+ integrations: [],
2216
2396
  db: {
2217
2397
  schema: "./src/odla/schema.mjs",
2218
2398
  rules: "./src/odla/rules.mjs",
@@ -2290,9 +2470,61 @@ function relativeDisplay(path, rootDir) {
2290
2470
 
2291
2471
  // src/provision.ts
2292
2472
  var import_apps2 = require("@odla-ai/apps");
2293
- var import_ai = require("@odla-ai/ai");
2473
+ var import_ai2 = require("@odla-ai/ai");
2294
2474
  var import_node_process7 = __toESM(require("process"), 1);
2295
2475
 
2476
+ // src/integration-provision.ts
2477
+ var import_db3 = require("@odla-ai/db");
2478
+ async function provisionIntegrationSeeds(doFetch, endpoint, tenantId, dbKey, integrations, env, out) {
2479
+ const base = `${endpoint}/app/${encodeURIComponent(tenantId)}`;
2480
+ for (const integration of integrations) {
2481
+ for (const seed of integration.seeds ?? []) {
2482
+ const payload = await postJson(doFetch, `${base}/query`, dbKey, {
2483
+ query: { [seed.ns]: { $: { where: { [seed.key.attr]: seed.key.value }, limit: 1 } } }
2484
+ });
2485
+ const rows = isRecord7(payload) && isRecord7(payload.result) ? payload.result[seed.ns] : void 0;
2486
+ if (!Array.isArray(rows)) {
2487
+ throw new Error(`${env}: integration ${integration.id} seed ${seed.id} query returned an invalid response`);
2488
+ }
2489
+ if (rows.length > 0) {
2490
+ out.log(`${env}: integration ${integration.id} seed ${seed.id} already exists`);
2491
+ continue;
2492
+ }
2493
+ await postJson(doFetch, `${base}/transact`, dbKey, {
2494
+ mutationId: `integration:${integration.id}:seed:${seed.id}`,
2495
+ ops: [{
2496
+ t: "update",
2497
+ ns: seed.ns,
2498
+ // A fresh id plus the declared-unique natural key is create-only: a
2499
+ // concurrent creator gets a unique violation instead of being overwritten.
2500
+ id: (0, import_db3.uuidv7)(),
2501
+ attrs: { ...seed.attrs, [seed.key.attr]: seed.key.value }
2502
+ }]
2503
+ });
2504
+ out.log(`${env}: integration ${integration.id} seed ${seed.id} created`);
2505
+ }
2506
+ }
2507
+ }
2508
+ async function postJson(doFetch, url, bearer, body) {
2509
+ const res = await doFetch(url, {
2510
+ method: "POST",
2511
+ headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
2512
+ body: JSON.stringify(body)
2513
+ });
2514
+ if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await responseText(res)}`);
2515
+ return res.json().catch(() => ({}));
2516
+ }
2517
+ function isRecord7(value) {
2518
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2519
+ }
2520
+ async function responseText(res) {
2521
+ try {
2522
+ return redactSecrets((await res.text()).slice(0, 500));
2523
+ } catch {
2524
+ return "";
2525
+ }
2526
+ }
2527
+
2296
2528
  // src/provision-credentials.ts
2297
2529
  var import_apps = require("@odla-ai/apps");
2298
2530
  async function provisionEnvCredentials(opts) {
@@ -2352,14 +2584,14 @@ async function mintDbKey(opts, tenantId) {
2352
2584
  appId: tenantId
2353
2585
  })
2354
2586
  });
2355
- if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText2(created)}`);
2587
+ if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText3(created)}`);
2356
2588
  res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
2357
2589
  method: "POST",
2358
2590
  headers,
2359
2591
  body: "{}"
2360
2592
  });
2361
2593
  }
2362
- if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText2(res)}`);
2594
+ if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText3(res)}`);
2363
2595
  const body = await res.json();
2364
2596
  if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
2365
2597
  return body.key;
@@ -2375,12 +2607,12 @@ async function issueO11yToken(opts) {
2375
2607
  `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`
2376
2608
  );
2377
2609
  }
2378
- if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText2(res)}`);
2610
+ if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText3(res)}`);
2379
2611
  const body = await res.json();
2380
2612
  if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
2381
2613
  return body.token;
2382
2614
  }
2383
- async function safeText2(res) {
2615
+ async function safeText3(res) {
2384
2616
  try {
2385
2617
  return redactSecrets((await res.text()).slice(0, 500));
2386
2618
  } catch {
@@ -2388,6 +2620,18 @@ async function safeText2(res) {
2388
2620
  }
2389
2621
  }
2390
2622
 
2623
+ // src/provision-helpers.ts
2624
+ var import_ai = require("@odla-ai/ai");
2625
+ function serviceRank(service) {
2626
+ if (service === "db") return 0;
2627
+ if (service === "calendar") return 2;
2628
+ return 1;
2629
+ }
2630
+ function defaultSecretName(provider) {
2631
+ const names = import_ai.DEFAULT_SECRET_NAMES;
2632
+ return names[provider] ?? `${provider}_api_key`;
2633
+ }
2634
+
2391
2635
  // src/secrets.ts
2392
2636
  var PROD_ENV_NAMES = /* @__PURE__ */ new Set(["prod", "production"]);
2393
2637
  async function secretsPush(options) {
@@ -2479,19 +2723,23 @@ async function provision(options) {
2479
2723
  out.log(` db: ${plan.dbEndpoint}`);
2480
2724
  out.log(` envs: ${plan.envs.join(", ")}`);
2481
2725
  out.log(` services: ${plan.services.join(", ")}`);
2726
+ out.log(` integrations: ${plan.integrations.length ? plan.integrations.join(", ") : "none"}`);
2482
2727
  const productionEnvs = plan.envs.filter((env) => env === "prod" || env === "production");
2483
2728
  if (!options.dryRun && productionEnvs.length > 0 && !options.yes) {
2484
2729
  throw new Error(
2485
2730
  `refusing to provision production env ${productionEnvs.join(", ")} without --yes; run --dry-run first`
2486
2731
  );
2487
2732
  }
2488
- const schema = hasDb ? await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]) : void 0;
2489
- const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
2490
- const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
2733
+ const database = await resolveDatabaseConfig(cfg);
2734
+ const { schema, rules } = database;
2491
2735
  if (options.dryRun) {
2492
2736
  out.log("dry run: no network calls or file writes");
2493
2737
  out.log(` schema: ${schema ? "yes" : "no"}`);
2494
2738
  out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
2739
+ for (const integration of database.integrations) {
2740
+ const namespaces = Object.keys(integration.schema?.entities ?? {}).length;
2741
+ out.log(` integration.${integration.id}: ${namespaces} namespaces, ${integration.seeds?.length ?? 0} seeds, ${integration.probes?.length ?? 0} smoke probes`);
2742
+ }
2495
2743
  out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
2496
2744
  if (cfg.services.includes("calendar")) {
2497
2745
  for (const env of cfg.envs) {
@@ -2601,18 +2849,21 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
2601
2849
  }
2602
2850
  }
2603
2851
  if (schema && dbKey) {
2604
- await postJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, dbKey, { schema });
2852
+ await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, dbKey, { schema });
2605
2853
  out.log(`${env}: schema pushed`);
2606
2854
  }
2607
2855
  if (rules) {
2608
- await postJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/admin/rules`, token, rules);
2856
+ await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/admin/rules`, token, rules);
2609
2857
  out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
2610
2858
  }
2859
+ if (dbKey) {
2860
+ await provisionIntegrationSeeds(doFetch, cfg.dbEndpoint, tenantId, dbKey, database.integrations, env, out);
2861
+ }
2611
2862
  if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
2612
2863
  const key = import_node_process7.default.env[cfg.ai.keyEnv];
2613
2864
  if (key) {
2614
2865
  const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
2615
- await (0, import_ai.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
2866
+ await (0, import_ai2.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
2616
2867
  out.log(`${env}: ${cfg.ai.provider} key stored in vault (${secretName})`);
2617
2868
  } else {
2618
2869
  out.log(`${env}: ${cfg.ai.keyEnv} not set; skipped provider key storage`);
@@ -2648,11 +2899,6 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
2648
2899
  }
2649
2900
  }
2650
2901
  }
2651
- function serviceRank(service) {
2652
- if (service === "db") return 0;
2653
- if (service === "calendar") return 2;
2654
- return 1;
2655
- }
2656
2902
  async function readRegistryApp(cfg, token, doFetch) {
2657
2903
  const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
2658
2904
  headers: { authorization: `Bearer ${token}` }
@@ -2662,13 +2908,13 @@ async function readRegistryApp(cfg, token, doFetch) {
2662
2908
  const json = await res.json();
2663
2909
  return json.app ?? null;
2664
2910
  }
2665
- async function postJson(doFetch, url, bearer, body) {
2911
+ async function postJson2(doFetch, url, bearer, body) {
2666
2912
  const res = await doFetch(url, {
2667
2913
  method: "POST",
2668
2914
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
2669
2915
  body: JSON.stringify(body)
2670
2916
  });
2671
- if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText3(res)}`);
2917
+ if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText4(res)}`);
2672
2918
  }
2673
2919
  function normalizeClerkConfig(value) {
2674
2920
  if (!value) return null;
@@ -2681,11 +2927,7 @@ function normalizeClerkConfig(value) {
2681
2927
  const publishableKey = envValue(cfg.publishableKey);
2682
2928
  return publishableKey ? { publishableKey, ...cfg.audience ? { audience: cfg.audience } : {}, ...cfg.mode ? { mode: cfg.mode } : {} } : null;
2683
2929
  }
2684
- function defaultSecretName(provider) {
2685
- const names = import_ai.DEFAULT_SECRET_NAMES;
2686
- return names[provider] ?? `${provider}_api_key`;
2687
- }
2688
- async function safeText3(res) {
2930
+ async function safeText4(res) {
2689
2931
  try {
2690
2932
  return redactSecrets((await res.text()).slice(0, 500));
2691
2933
  } catch {
@@ -2694,7 +2936,7 @@ async function safeText3(res) {
2694
2936
  }
2695
2937
 
2696
2938
  // src/secrets-set.ts
2697
- var import_ai2 = require("@odla-ai/ai");
2939
+ var import_ai3 = require("@odla-ai/ai");
2698
2940
  var import_apps3 = require("@odla-ai/apps");
2699
2941
  var PROD_ENV_NAMES2 = /* @__PURE__ */ new Set(["prod", "production"]);
2700
2942
  async function secretsSet(options) {
@@ -2706,7 +2948,7 @@ async function secretsSet(options) {
2706
2948
  const { cfg, tenantId, value, doFetch, out } = await resolveVaultWrite(options);
2707
2949
  const token = await getDeveloperToken(cfg, options, doFetch, out);
2708
2950
  try {
2709
- await (0, import_ai2.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, name, value);
2951
+ await (0, import_ai3.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, name, value);
2710
2952
  } catch (err) {
2711
2953
  throw new Error(scrubValue(err instanceof Error ? err.message : String(err), value));
2712
2954
  }
@@ -3012,7 +3254,7 @@ function formatBudget(usage) {
3012
3254
 
3013
3255
  // src/security-hosted-github.ts
3014
3256
  var import_node_child_process4 = require("child_process");
3015
- var import_node_util = require("util");
3257
+ var import_node_util2 = require("util");
3016
3258
 
3017
3259
  // src/security-hosted-request.ts
3018
3260
  async function requestHostedSecurityJson(options, path, init, action) {
@@ -3216,7 +3458,7 @@ async function inferGitHubRepository(cwd = process.cwd(), readOrigin = defaultRe
3216
3458
  return repositoryFromGitRemote(remote);
3217
3459
  }
3218
3460
  async function defaultReadOrigin(cwd) {
3219
- const result = await (0, import_node_util.promisify)(import_node_child_process4.execFile)(
3461
+ const result = await (0, import_node_util2.promisify)(import_node_child_process4.execFile)(
3220
3462
  "git",
3221
3463
  ["remote", "get-url", "origin"],
3222
3464
  { cwd, encoding: "utf8" }
@@ -3954,7 +4196,8 @@ async function smoke(options) {
3954
4196
  assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
3955
4197
  out.log(` calendar: ${status.status}, bookable (booking \u2192 ${status.bookingCalendarId ?? "primary"})`);
3956
4198
  }
3957
- const expectedSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
4199
+ const database = await resolveDatabaseConfig(cfg);
4200
+ const expectedSchema = database.schema;
3958
4201
  const expectedEntities = serializedEntities(expectedSchema);
3959
4202
  const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
3960
4203
  const liveSchema = liveSchemaPayload.schema ?? liveSchemaPayload;
@@ -3966,7 +4209,7 @@ async function smoke(options) {
3966
4209
  out.log(` schema: ${liveEntities.length} entities`);
3967
4210
  const aggregateEntity = expectedEntities[0] ?? liveEntities[0];
3968
4211
  if (aggregateEntity) {
3969
- const aggregate = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
4212
+ const aggregate = await postJson3(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
3970
4213
  ns: aggregateEntity,
3971
4214
  aggregate: { count: true }
3972
4215
  });
@@ -3975,6 +4218,21 @@ async function smoke(options) {
3975
4218
  } else {
3976
4219
  out.log(` aggregate: skipped (schema has no entities)`);
3977
4220
  }
4221
+ const probes = database.integrations.flatMap(
4222
+ (integration) => (integration.probes ?? []).map((probe) => ({ integration: integration.id, probe }))
4223
+ );
4224
+ if (probes.length) {
4225
+ const link = cfg.links?.[env];
4226
+ if (!link) throw new Error(`integration smoke probes require links.${env} in odla.config.mjs`);
4227
+ for (const { integration, probe } of probes) {
4228
+ const probeUrl = new URL(probe.path, link).toString();
4229
+ const res = await doFetch(probeUrl, { redirect: "manual" });
4230
+ if (res.status !== probe.expectedStatus) {
4231
+ throw new Error(`integration "${integration}" probe ${probe.path} returned ${res.status}; expected ${probe.expectedStatus}`);
4232
+ }
4233
+ out.log(` integration.${integration}: ${probe.path} \u2192 ${res.status}`);
4234
+ }
4235
+ }
3978
4236
  out.log("ok");
3979
4237
  }
3980
4238
  function assertCalendarHealthy(status, expected) {
@@ -3992,16 +4250,16 @@ async function getJson(doFetch, url, bearer) {
3992
4250
  const res = await doFetch(url, {
3993
4251
  headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
3994
4252
  });
3995
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
4253
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText5(res)}`);
3996
4254
  return res.json();
3997
4255
  }
3998
- async function postJson2(doFetch, url, bearer, body) {
4256
+ async function postJson3(doFetch, url, bearer, body) {
3999
4257
  const res = await doFetch(url, {
4000
4258
  method: "POST",
4001
4259
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
4002
4260
  body: JSON.stringify(body)
4003
4261
  });
4004
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
4262
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText5(res)}`);
4005
4263
  return res.json();
4006
4264
  }
4007
4265
  function publicConfigUrl(platformUrl, appId, env) {
@@ -4009,7 +4267,7 @@ function publicConfigUrl(platformUrl, appId, env) {
4009
4267
  url.searchParams.set("env", env);
4010
4268
  return url.toString();
4011
4269
  }
4012
- async function safeText4(res) {
4270
+ async function safeText5(res) {
4013
4271
  try {
4014
4272
  return (await res.text()).slice(0, 500);
4015
4273
  } catch {