@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.
package/dist/index.cjs CHANGED
@@ -1003,6 +1003,77 @@ var import_promises = require("stream/promises");
1003
1003
  var import_node_fs4 = require("fs");
1004
1004
  var import_node_path3 = require("path");
1005
1005
  var import_node_url = require("url");
1006
+
1007
+ // src/integration-validation.ts
1008
+ function validateIntegrations(cfg, path, defaultServices) {
1009
+ if (cfg.integrations === void 0) return;
1010
+ if (!Array.isArray(cfg.integrations)) throw new Error(`${path}: integrations must be an array`);
1011
+ const ids = /* @__PURE__ */ new Set();
1012
+ for (const [index, integration] of cfg.integrations.entries()) {
1013
+ const at = `${path}: integrations[${index}]`;
1014
+ if (!isRecord4(integration)) throw new Error(`${at} must be an object`);
1015
+ if (!validId(integration.id)) throw new Error(`${at}.id must be lowercase letters, numbers, and hyphens`);
1016
+ if (ids.has(integration.id)) throw new Error(`${path}: duplicate integration id "${integration.id}"`);
1017
+ ids.add(integration.id);
1018
+ if (!safeText(integration.title, 200)) throw new Error(`${at}.title is required`);
1019
+ if (!safeText(integration.npm, 200)) throw new Error(`${at}.npm is required`);
1020
+ if (integration.schema !== void 0 && (!isRecord4(integration.schema) || !isRecord4(integration.schema.entities))) {
1021
+ throw new Error(`${at}.schema must contain an entities object`);
1022
+ }
1023
+ if (integration.rules !== void 0 && !isRecord4(integration.rules)) throw new Error(`${at}.rules must be an object`);
1024
+ validateSeeds(integration, at);
1025
+ validateProbes(integration, at);
1026
+ }
1027
+ const needsDb = cfg.integrations.some((integration) => integration.schema || integration.rules || integration.seeds?.length);
1028
+ const services = unique(cfg.services?.length ? cfg.services : defaultServices);
1029
+ if (needsDb && !services.includes("db")) throw new Error(`${path}: schema/rules/seed integrations require the db service`);
1030
+ }
1031
+ function validateSeeds(integration, at) {
1032
+ if (integration.seeds === void 0) return;
1033
+ if (!Array.isArray(integration.seeds)) throw new Error(`${at}.seeds must be an array`);
1034
+ const ids = /* @__PURE__ */ new Set();
1035
+ for (const [index, seed] of integration.seeds.entries()) {
1036
+ const sat = `${at}.seeds[${index}]`;
1037
+ if (!isRecord4(seed) || !safeText(seed.id, 200) || !safeText(seed.ns, 200)) throw new Error(`${sat} requires id and ns`);
1038
+ if (ids.has(seed.id)) throw new Error(`${at} has duplicate seed id "${seed.id}"`);
1039
+ ids.add(seed.id);
1040
+ if (!isRecord4(seed.key) || !safeText(seed.key.attr, 200) || !safeText(seed.key.value, 2048)) {
1041
+ throw new Error(`${sat}.key requires string attr and value`);
1042
+ }
1043
+ if (!isRecord4(seed.attrs)) throw new Error(`${sat}.attrs must be an object`);
1044
+ if (Object.hasOwn(seed.attrs, seed.key.attr) && seed.attrs[seed.key.attr] !== seed.key.value) {
1045
+ throw new Error(`${sat}.attrs.${seed.key.attr} conflicts with its natural key`);
1046
+ }
1047
+ }
1048
+ }
1049
+ function validateProbes(integration, at) {
1050
+ if (integration.probes === void 0) return;
1051
+ if (!Array.isArray(integration.probes)) throw new Error(`${at}.probes must be an array`);
1052
+ for (const [index, probe] of integration.probes.entries()) {
1053
+ const pat = `${at}.probes[${index}]`;
1054
+ if (!isRecord4(probe) || !safeProbePath(probe.path)) throw new Error(`${pat}.path must be an absolute path without query or fragment`);
1055
+ if (!Number.isInteger(probe.expectedStatus) || probe.expectedStatus < 100 || probe.expectedStatus > 599) {
1056
+ throw new Error(`${pat}.expectedStatus must be an HTTP status`);
1057
+ }
1058
+ }
1059
+ }
1060
+ function isRecord4(value) {
1061
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1062
+ }
1063
+ function safeText(value, max) {
1064
+ return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
1065
+ }
1066
+ function safeProbePath(value) {
1067
+ return typeof value === "string" && /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/.test(value);
1068
+ }
1069
+ function validId(value) {
1070
+ return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
1071
+ }
1072
+ function unique(values) {
1073
+ return [...new Set(values.filter(Boolean))];
1074
+ }
1075
+
1076
+ // src/config.ts
1006
1077
  var DEFAULT_PLATFORM = "https://odla.ai";
1007
1078
  var DEFAULT_ENVS = ["dev"];
1008
1079
  var DEFAULT_SERVICES = ["db", "ai"];
@@ -1017,8 +1088,8 @@ async function loadProjectConfig(configPath = "odla.config.mjs") {
1017
1088
  validateRawConfig(raw, resolved);
1018
1089
  const platformUrl = trimSlash(process.env.ODLA_PLATFORM_URL || raw.platformUrl || DEFAULT_PLATFORM);
1019
1090
  const dbEndpoint = trimSlash(process.env.ODLA_DB_ENDPOINT || raw.dbEndpoint || platformUrl);
1020
- const envs = unique(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
1021
- const services = unique(raw.services?.length ? raw.services : DEFAULT_SERVICES);
1091
+ const envs = unique2(raw.envs?.length ? raw.envs : DEFAULT_ENVS);
1092
+ const services = unique2(raw.services?.length ? raw.services : DEFAULT_SERVICES);
1022
1093
  validateCalendarConfig(raw, envs, services, resolved);
1023
1094
  const local = {
1024
1095
  tokenFile: (0, import_node_path3.resolve)(rootDir, raw.local?.tokenFile ?? ".odla/dev-token.json"),
@@ -1052,6 +1123,8 @@ async function resolveDataExport(cfg, value, names) {
1052
1123
  throw new Error(`${value} did not export ${names.join(", ")} or default`);
1053
1124
  }
1054
1125
  function buildPlan(cfg) {
1126
+ const integrationSchema = cfg.integrations?.some((integration) => integration.schema) ?? false;
1127
+ const integrationRules = cfg.integrations?.some((integration) => integration.rules) ?? false;
1055
1128
  return {
1056
1129
  appId: cfg.app.id,
1057
1130
  appName: cfg.app.name,
@@ -1059,8 +1132,9 @@ function buildPlan(cfg) {
1059
1132
  dbEndpoint: cfg.dbEndpoint,
1060
1133
  envs: cfg.envs,
1061
1134
  services: cfg.services,
1062
- hasSchema: !!cfg.db?.schema,
1063
- hasRules: !!cfg.db?.rules || !!cfg.db?.schema && cfg.db.defaultRules !== false,
1135
+ integrations: (cfg.integrations ?? []).map((integration) => integration.id),
1136
+ hasSchema: !!cfg.db?.schema || integrationSchema,
1137
+ hasRules: !!cfg.db?.rules || integrationRules || (!!cfg.db?.schema || integrationSchema) && cfg.db?.defaultRules !== false,
1064
1138
  aiProvider: cfg.ai?.provider
1065
1139
  };
1066
1140
  }
@@ -1069,7 +1143,7 @@ function calendarServiceConfig(cfg, env) {
1069
1143
  if (!cfg.envs.includes(env)) throw new Error(`calendar env "${env}" is not declared in config envs`);
1070
1144
  const google = cfg.calendar?.google;
1071
1145
  if (!google) throw new Error("calendar.google is required when the calendar service is enabled");
1072
- const availability = unique(
1146
+ const availability = unique2(
1073
1147
  (google.availabilityCalendars?.[env] ?? google.calendars?.[env]).map((id) => id.trim())
1074
1148
  );
1075
1149
  return {
@@ -1105,15 +1179,16 @@ function validateRawConfig(raw, path) {
1105
1179
  if (!raw || typeof raw !== "object") throw new Error(`${path} must export an object`);
1106
1180
  const cfg = raw;
1107
1181
  if (!cfg.app || typeof cfg.app !== "object") throw new Error(`${path}: missing app object`);
1108
- if (!validId(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
1182
+ if (!validId2(cfg.app.id)) throw new Error(`${path}: app.id must be lowercase letters, numbers, and hyphens`);
1109
1183
  if (!cfg.app.name || typeof cfg.app.name !== "string") throw new Error(`${path}: app.name is required`);
1110
1184
  if (cfg.envs !== void 0 && (!Array.isArray(cfg.envs) || cfg.envs.some((env) => typeof env !== "string"))) {
1111
1185
  throw new Error(`${path}: envs must be an array of names`);
1112
1186
  }
1113
- if (cfg.envs?.some((env) => !validId(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
1187
+ if (cfg.envs?.some((env) => !validId2(env))) throw new Error(`${path}: env names must be lowercase letters, numbers, and hyphens`);
1114
1188
  if (cfg.services !== void 0 && (!Array.isArray(cfg.services) || cfg.services.some((service) => typeof service !== "string" || !service.trim()))) {
1115
1189
  throw new Error(`${path}: services must be an array of non-empty names`);
1116
1190
  }
1191
+ validateIntegrations(cfg, path, DEFAULT_SERVICES);
1117
1192
  }
1118
1193
  function validateCalendarConfig(cfg, envs, services, path) {
1119
1194
  const enabled = services.includes("calendar");
@@ -1121,9 +1196,9 @@ function validateCalendarConfig(cfg, envs, services, path) {
1121
1196
  if (enabled) throw new Error(`${path}: calendar.google is required when services includes "calendar"`);
1122
1197
  return;
1123
1198
  }
1124
- if (!isRecord4(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
1199
+ if (!isRecord5(cfg.calendar)) throw new Error(`${path}: calendar must be an object`);
1125
1200
  assertOnly(cfg.calendar, ["google"], `${path}: calendar`);
1126
- if (!isRecord4(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
1201
+ if (!isRecord5(cfg.calendar.google)) throw new Error(`${path}: calendar.google must be an object`);
1127
1202
  const google = cfg.calendar.google;
1128
1203
  assertOnly(
1129
1204
  google,
@@ -1135,7 +1210,7 @@ function validateCalendarConfig(cfg, envs, services, path) {
1135
1210
  throw new Error(`${path}: calendar.google requires exactly one of availabilityCalendars or calendars (legacy)`);
1136
1211
  }
1137
1212
  const availability = google[availabilityKey];
1138
- if (!isRecord4(availability)) throw new Error(`${path}: calendar.google.${availabilityKey} must map env names to calendar ids`);
1213
+ if (!isRecord5(availability)) throw new Error(`${path}: calendar.google.${availabilityKey} must map env names to calendar ids`);
1139
1214
  const unknownEnv = Object.keys(availability).find((env) => !envs.includes(env));
1140
1215
  if (unknownEnv) throw new Error(`${path}: calendar.google.${availabilityKey}.${unknownEnv} is not in config envs`);
1141
1216
  for (const env of envs) {
@@ -1146,22 +1221,22 @@ function validateCalendarConfig(cfg, envs, services, path) {
1146
1221
  if (ids.length > 10) {
1147
1222
  throw new Error(`${path}: calendar.google.${availabilityKey}.${env} must contain at most 10 calendar ids`);
1148
1223
  }
1149
- if (ids.some((id) => !safeText(id, 1024))) {
1224
+ if (ids.some((id) => !safeText2(id, 1024))) {
1150
1225
  throw new Error(`${path}: calendar.google.${availabilityKey}.${env} contains an invalid calendar id`);
1151
1226
  }
1152
1227
  }
1153
1228
  if (google.bookingCalendar !== void 0) {
1154
- if (!isRecord4(google.bookingCalendar)) throw new Error(`${path}: calendar.google.bookingCalendar must map env names to one calendar id`);
1229
+ if (!isRecord5(google.bookingCalendar)) throw new Error(`${path}: calendar.google.bookingCalendar must map env names to one calendar id`);
1155
1230
  const unknownBookingEnv = Object.keys(google.bookingCalendar).find((env) => !envs.includes(env));
1156
1231
  if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingCalendar.${unknownBookingEnv} is not in config envs`);
1157
1232
  for (const [env, value] of Object.entries(google.bookingCalendar)) {
1158
- if (!safeText(value, 1024)) {
1233
+ if (!safeText2(value, 1024)) {
1159
1234
  throw new Error(`${path}: calendar.google.bookingCalendar.${env} must be a calendar id`);
1160
1235
  }
1161
1236
  }
1162
1237
  }
1163
1238
  if (google.bookingPageUrl !== void 0) {
1164
- if (!isRecord4(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
1239
+ if (!isRecord5(google.bookingPageUrl)) throw new Error(`${path}: calendar.google.bookingPageUrl must map env names to HTTPS URLs or null`);
1165
1240
  const unknownBookingEnv = Object.keys(google.bookingPageUrl).find((env) => !envs.includes(env));
1166
1241
  if (unknownBookingEnv) throw new Error(`${path}: calendar.google.bookingPageUrl.${unknownBookingEnv} is not in config envs`);
1167
1242
  for (const [env, value] of Object.entries(google.bookingPageUrl)) {
@@ -1176,10 +1251,10 @@ function assertOnly(value, allowed, label) {
1176
1251
  const extra = Object.keys(value).find((key) => !allowed.includes(key));
1177
1252
  if (extra) throw new Error(`${label}.${extra} is not supported`);
1178
1253
  }
1179
- function isRecord4(value) {
1254
+ function isRecord5(value) {
1180
1255
  return value !== null && typeof value === "object" && !Array.isArray(value);
1181
1256
  }
1182
- function safeText(value, max) {
1257
+ function safeText2(value, max) {
1183
1258
  return typeof value === "string" && value.trim().length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
1184
1259
  }
1185
1260
  function safeHttpsUrl(value) {
@@ -1191,7 +1266,7 @@ function safeHttpsUrl(value) {
1191
1266
  return false;
1192
1267
  }
1193
1268
  }
1194
- function validId(value) {
1269
+ function validId2(value) {
1195
1270
  return typeof value === "string" && /^[a-z0-9][a-z0-9-]*$/.test(value);
1196
1271
  }
1197
1272
  async function loadConfigModule(path) {
@@ -1204,7 +1279,7 @@ async function loadConfigModule(path) {
1204
1279
  function trimSlash(value) {
1205
1280
  return value.replace(/\/+$/, "");
1206
1281
  }
1207
- function unique(values) {
1282
+ function unique2(values) {
1208
1283
  return [...new Set(values.filter(Boolean))];
1209
1284
  }
1210
1285
 
@@ -1334,9 +1409,9 @@ var CAPABILITIES = {
1334
1409
  "issue, persist, and explicitly rotate configured service credentials",
1335
1410
  "write local Worker values and push deployed Worker secrets through Wrangler stdin",
1336
1411
  "store tenant-vault secrets (including the Clerk webhook secret and reserved Clerk secret key) write-only from stdin or an env var",
1337
- "push db schema/rules and configure platform AI, auth, and deployment links",
1412
+ "compose declared app-capability schema/rules, create guarded seeds when absent, and configure platform AI, auth, and deployment links",
1338
1413
  "apply Google Calendar booking config, then drive state-bound consent, status, discovery, and disconnect flows (bookings run live through the platform proxy)",
1339
- "validate config offline and smoke-test a provisioned db environment",
1414
+ "validate integration contracts offline and smoke-test a provisioned db environment plus anonymous capability routes",
1340
1415
  "run app-attributed hosted security discovery and independent validation without provider keys",
1341
1416
  "connect/revoke source-read-only GitHub sources and drive commit-pinned hosted security jobs without PATs or provider keys",
1342
1417
  "let admins manage system AI policy and credentials, inspect attributed usage, and read immutable admin changes through separate short-lived capability-only approvals"
@@ -1344,7 +1419,7 @@ var CAPABILITIES = {
1344
1419
  agent: [
1345
1420
  "install and import the selected odla SDKs",
1346
1421
  "wrap the Worker with withObservability and choose useful telemetry",
1347
- "make application-specific schema, rules, auth, UI, and migration decisions",
1422
+ "install capability packages, mount their runtime routes, and make application-specific schema, rules, auth, UI, and migration decisions",
1348
1423
  "wire @odla-ai/calendar into trusted Worker code and keep the app admin key out of browsers"
1349
1424
  ],
1350
1425
  human: [
@@ -2016,20 +2091,121 @@ function readPackageJson(rootDir) {
2016
2091
  }
2017
2092
  }
2018
2093
 
2094
+ // src/integrations.ts
2095
+ var import_node_util = require("util");
2096
+ async function resolveDatabaseConfig(cfg, options = {}) {
2097
+ const integrations = cfg.integrations ?? [];
2098
+ if (!cfg.services.includes("db")) return { schema: void 0, rules: void 0, integrations };
2099
+ const authoredSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
2100
+ const schema = mergeSchemas(authoredSchema, integrations);
2101
+ if (options.validateSeeds !== false) assertSeedContracts(integrations, schema);
2102
+ const configuredRules = await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]);
2103
+ const explicitRules = mergeRules(configuredRules, integrations);
2104
+ const rules = configuredRules === void 0 && schema && cfg.db?.defaultRules !== false ? { ...rulesFromSchema(schema), ...explicitRules ?? {} } : explicitRules;
2105
+ return { schema, rules, integrations };
2106
+ }
2107
+ function integrationWarnings(integrations, schema, rules) {
2108
+ const warnings = [];
2109
+ const entities = new Set(serializedEntities(schema));
2110
+ for (const integration of integrations) {
2111
+ const namespaces = Object.keys(integration.schema?.entities ?? {});
2112
+ for (const ns of namespaces) {
2113
+ if (!entities.has(ns)) warnings.push(`integration "${integration.id}" schema namespace "${ns}" is missing`);
2114
+ const rule = rules?.[ns];
2115
+ if (!rule) {
2116
+ warnings.push(`integration "${integration.id}" has no rules for "${ns}"`);
2117
+ continue;
2118
+ }
2119
+ for (const action of ["view", "create", "update", "delete"]) {
2120
+ if (rule[action] === void 0) warnings.push(`integration "${integration.id}" rule "${ns}.${action}" is missing`);
2121
+ }
2122
+ }
2123
+ for (const seed of integration.seeds ?? []) {
2124
+ if (!entities.has(seed.ns)) {
2125
+ warnings.push(`integration "${integration.id}" seed "${seed.id}" targets unknown namespace "${seed.ns}"`);
2126
+ } else if (!isUniqueAttr(schema, seed.ns, seed.key.attr)) {
2127
+ warnings.push(`integration "${integration.id}" seed "${seed.id}" key "${seed.ns}.${seed.key.attr}" is not declared unique`);
2128
+ }
2129
+ }
2130
+ }
2131
+ return warnings;
2132
+ }
2133
+ function mergeSchemas(base, integrations) {
2134
+ const fragments = integrations.filter((integration) => integration.schema);
2135
+ if (fragments.length === 0) return base;
2136
+ const merged = normalizeSchema(base);
2137
+ for (const integration of fragments) {
2138
+ const fragment = normalizeSchema(integration.schema);
2139
+ mergeMap(merged.entities, fragment.entities, `integration "${integration.id}" schema namespace`);
2140
+ mergeMap(merged.links, fragment.links, `integration "${integration.id}" schema link`);
2141
+ }
2142
+ return merged;
2143
+ }
2144
+ function mergeRules(base, integrations) {
2145
+ const fragments = integrations.filter((integration) => integration.rules);
2146
+ if (fragments.length === 0) return base;
2147
+ const merged = { ...base ?? {} };
2148
+ for (const integration of fragments) {
2149
+ mergeMap(merged, integration.rules ?? {}, `integration "${integration.id}" rule namespace`);
2150
+ }
2151
+ return merged;
2152
+ }
2153
+ function assertSeedContracts(integrations, schema) {
2154
+ const entities = new Set(serializedEntities(schema));
2155
+ for (const integration of integrations) {
2156
+ for (const seed of integration.seeds ?? []) {
2157
+ if (!entities.has(seed.ns)) {
2158
+ throw new Error(`integration "${integration.id}" seed "${seed.id}" targets unknown namespace "${seed.ns}"`);
2159
+ }
2160
+ if (!isUniqueAttr(schema, seed.ns, seed.key.attr)) {
2161
+ throw new Error(`integration "${integration.id}" seed "${seed.id}" key "${seed.ns}.${seed.key.attr}" must be declared unique`);
2162
+ }
2163
+ }
2164
+ }
2165
+ }
2166
+ function isUniqueAttr(schema, ns, attr) {
2167
+ if (!isRecord6(schema) || !isRecord6(schema.entities)) return false;
2168
+ const entity = schema.entities[ns];
2169
+ if (!isRecord6(entity) || !isRecord6(entity.attrs)) return false;
2170
+ const definition = entity.attrs[attr];
2171
+ return isRecord6(definition) && definition.unique === true;
2172
+ }
2173
+ function normalizeSchema(value) {
2174
+ if (value === void 0 || value === null) return { entities: {}, links: {} };
2175
+ if (!isRecord6(value) || !isRecord6(value.entities)) {
2176
+ throw new Error("db schema must be a serialized schema object with an entities map");
2177
+ }
2178
+ if (value.links !== void 0 && !isRecord6(value.links)) throw new Error("db schema links must be an object");
2179
+ return {
2180
+ entities: { ...value.entities },
2181
+ links: { ...value.links ?? {} }
2182
+ };
2183
+ }
2184
+ function mergeMap(target, fragment, label) {
2185
+ for (const [name, value] of Object.entries(fragment)) {
2186
+ if (Object.hasOwn(target, name) && !(0, import_node_util.isDeepStrictEqual)(target[name], value)) {
2187
+ throw new Error(`${label} "${name}" conflicts with an existing definition`);
2188
+ }
2189
+ target[name] = value;
2190
+ }
2191
+ }
2192
+ function isRecord6(value) {
2193
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2194
+ }
2195
+
2019
2196
  // src/doctor.ts
2020
2197
  async function doctor(options) {
2021
2198
  const out = options.stdout ?? console;
2022
2199
  const cfg = await loadProjectConfig(options.configPath);
2023
2200
  const plan = buildPlan(cfg);
2024
- const hasDb = cfg.services.includes("db");
2025
- const schema = hasDb ? await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]) : void 0;
2026
- const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
2027
- const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
2201
+ const database = await resolveDatabaseConfig(cfg, { validateSeeds: false });
2202
+ const { schema, rules } = database;
2028
2203
  const entities = serializedEntities(schema);
2029
2204
  out.log(`config: ${cfg.configPath}`);
2030
2205
  out.log(`app: ${plan.appName} (${plan.appId})`);
2031
2206
  out.log(`envs: ${plan.envs.join(", ")}`);
2032
2207
  out.log(`svc: ${plan.services.join(", ")}`);
2208
+ out.log(`integrations: ${plan.integrations.length ? plan.integrations.join(", ") : "none"}`);
2033
2209
  out.log(`schema: ${schema ? `${entities.length} entities` : "none"}`);
2034
2210
  out.log(`rules: ${rules ? `${Object.keys(rules).length} namespaces` : "none"}`);
2035
2211
  out.log(`ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
@@ -2051,6 +2227,7 @@ async function doctor(options) {
2051
2227
  if (entities.length > 0 && !entities.includes(ns)) warnings.push(`rules include "${ns}" but schema has no matching entity`);
2052
2228
  }
2053
2229
  }
2230
+ warnings.push(...integrationWarnings(database.integrations, schema, rules));
2054
2231
  if (cfg.services.includes("ai") && !cfg.ai?.provider) warnings.push("ai service is enabled but ai.provider is not set");
2055
2232
  if (cfg.auth?.clerk) {
2056
2233
  for (const [env, value] of Object.entries(cfg.auth.clerk)) {
@@ -2151,8 +2328,8 @@ Commands:
2151
2328
  capabilities Show what the CLI automates vs agent edits and human checkpoints.
2152
2329
  admin Manage platform-funded AI routing/credentials/usage with narrow device grants.
2153
2330
  security Connect GitHub sources and run commit-pinned hosted reviews, or scan a local snapshot.
2154
- provision Register services, configure them, persist credentials, optionally push secrets.
2155
- smoke Verify local credentials, public-config, live schema, and db aggregate.
2331
+ provision Register services, compose integrations, persist credentials, optionally push secrets.
2332
+ smoke Verify credentials, public-config, composed schema, db aggregate, and integration probes.
2156
2333
  skill Same installer; --agent accepts all, claude, codex, cursor,
2157
2334
  copilot, gemini, or agents (repeatable or comma-separated).
2158
2335
  secrets Push configured db/o11y secrets into the Worker via wrangler
@@ -2262,6 +2439,9 @@ function configTemplate(input) {
2262
2439
  },
2263
2440
  envs: ${JSON.stringify(input.envs)},
2264
2441
  services: ${JSON.stringify(input.services)},
2442
+ // App capabilities are npm modules, not hosted services. Import their
2443
+ // data-only descriptor and add it here (for example createCrmIntegration()).
2444
+ integrations: [],
2265
2445
  db: {
2266
2446
  schema: "./src/odla/schema.mjs",
2267
2447
  rules: "./src/odla/rules.mjs",
@@ -2339,9 +2519,61 @@ function relativeDisplay(path, rootDir) {
2339
2519
 
2340
2520
  // src/provision.ts
2341
2521
  var import_apps2 = require("@odla-ai/apps");
2342
- var import_ai = require("@odla-ai/ai");
2522
+ var import_ai2 = require("@odla-ai/ai");
2343
2523
  var import_node_process7 = __toESM(require("process"), 1);
2344
2524
 
2525
+ // src/integration-provision.ts
2526
+ var import_db3 = require("@odla-ai/db");
2527
+ async function provisionIntegrationSeeds(doFetch, endpoint, tenantId, dbKey, integrations, env, out) {
2528
+ const base = `${endpoint}/app/${encodeURIComponent(tenantId)}`;
2529
+ for (const integration of integrations) {
2530
+ for (const seed of integration.seeds ?? []) {
2531
+ const payload = await postJson(doFetch, `${base}/query`, dbKey, {
2532
+ query: { [seed.ns]: { $: { where: { [seed.key.attr]: seed.key.value }, limit: 1 } } }
2533
+ });
2534
+ const rows = isRecord7(payload) && isRecord7(payload.result) ? payload.result[seed.ns] : void 0;
2535
+ if (!Array.isArray(rows)) {
2536
+ throw new Error(`${env}: integration ${integration.id} seed ${seed.id} query returned an invalid response`);
2537
+ }
2538
+ if (rows.length > 0) {
2539
+ out.log(`${env}: integration ${integration.id} seed ${seed.id} already exists`);
2540
+ continue;
2541
+ }
2542
+ await postJson(doFetch, `${base}/transact`, dbKey, {
2543
+ mutationId: `integration:${integration.id}:seed:${seed.id}`,
2544
+ ops: [{
2545
+ t: "update",
2546
+ ns: seed.ns,
2547
+ // A fresh id plus the declared-unique natural key is create-only: a
2548
+ // concurrent creator gets a unique violation instead of being overwritten.
2549
+ id: (0, import_db3.uuidv7)(),
2550
+ attrs: { ...seed.attrs, [seed.key.attr]: seed.key.value }
2551
+ }]
2552
+ });
2553
+ out.log(`${env}: integration ${integration.id} seed ${seed.id} created`);
2554
+ }
2555
+ }
2556
+ }
2557
+ async function postJson(doFetch, url, bearer, body) {
2558
+ const res = await doFetch(url, {
2559
+ method: "POST",
2560
+ headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
2561
+ body: JSON.stringify(body)
2562
+ });
2563
+ if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await responseText(res)}`);
2564
+ return res.json().catch(() => ({}));
2565
+ }
2566
+ function isRecord7(value) {
2567
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2568
+ }
2569
+ async function responseText(res) {
2570
+ try {
2571
+ return redactSecrets((await res.text()).slice(0, 500));
2572
+ } catch {
2573
+ return "";
2574
+ }
2575
+ }
2576
+
2345
2577
  // src/provision-credentials.ts
2346
2578
  var import_apps = require("@odla-ai/apps");
2347
2579
  async function provisionEnvCredentials(opts) {
@@ -2401,14 +2633,14 @@ async function mintDbKey(opts, tenantId) {
2401
2633
  appId: tenantId
2402
2634
  })
2403
2635
  });
2404
- if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText2(created)}`);
2636
+ if (!created.ok) throw new Error(`db app create (${tenantId}) failed: ${created.status} ${await safeText3(created)}`);
2405
2637
  res = await opts.fetch(`${opts.cfg.dbEndpoint}/admin/apps/${encodeURIComponent(tenantId)}/keys`, {
2406
2638
  method: "POST",
2407
2639
  headers,
2408
2640
  body: "{}"
2409
2641
  });
2410
2642
  }
2411
- if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText2(res)}`);
2643
+ if (!res.ok) throw new Error(`db key mint (${tenantId}) failed: ${res.status} ${await safeText3(res)}`);
2412
2644
  const body = await res.json();
2413
2645
  if (!body.key) throw new Error(`db key mint (${tenantId}) returned no key`);
2414
2646
  return body.key;
@@ -2424,12 +2656,12 @@ async function issueO11yToken(opts) {
2424
2656
  `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`
2425
2657
  );
2426
2658
  }
2427
- if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText2(res)}`);
2659
+ if (!res.ok) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) failed: ${res.status} ${await safeText3(res)}`);
2428
2660
  const body = await res.json();
2429
2661
  if (!body.token) throw new Error(`o11y token ${opts.rotateO11y ? "rotation" : "issue"} (${opts.env}) returned no token`);
2430
2662
  return body.token;
2431
2663
  }
2432
- async function safeText2(res) {
2664
+ async function safeText3(res) {
2433
2665
  try {
2434
2666
  return redactSecrets((await res.text()).slice(0, 500));
2435
2667
  } catch {
@@ -2437,6 +2669,18 @@ async function safeText2(res) {
2437
2669
  }
2438
2670
  }
2439
2671
 
2672
+ // src/provision-helpers.ts
2673
+ var import_ai = require("@odla-ai/ai");
2674
+ function serviceRank(service) {
2675
+ if (service === "db") return 0;
2676
+ if (service === "calendar") return 2;
2677
+ return 1;
2678
+ }
2679
+ function defaultSecretName(provider) {
2680
+ const names = import_ai.DEFAULT_SECRET_NAMES;
2681
+ return names[provider] ?? `${provider}_api_key`;
2682
+ }
2683
+
2440
2684
  // src/secrets.ts
2441
2685
  var PROD_ENV_NAMES = /* @__PURE__ */ new Set(["prod", "production"]);
2442
2686
  async function secretsPush(options) {
@@ -2528,19 +2772,23 @@ async function provision(options) {
2528
2772
  out.log(` db: ${plan.dbEndpoint}`);
2529
2773
  out.log(` envs: ${plan.envs.join(", ")}`);
2530
2774
  out.log(` services: ${plan.services.join(", ")}`);
2775
+ out.log(` integrations: ${plan.integrations.length ? plan.integrations.join(", ") : "none"}`);
2531
2776
  const productionEnvs = plan.envs.filter((env) => env === "prod" || env === "production");
2532
2777
  if (!options.dryRun && productionEnvs.length > 0 && !options.yes) {
2533
2778
  throw new Error(
2534
2779
  `refusing to provision production env ${productionEnvs.join(", ")} without --yes; run --dry-run first`
2535
2780
  );
2536
2781
  }
2537
- const schema = hasDb ? await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]) : void 0;
2538
- const configuredRules = hasDb ? await resolveDataExport(cfg, cfg.db?.rules, ["rules", "RULES"]) : void 0;
2539
- const rules = configuredRules ?? (schema && cfg.db?.defaultRules !== false ? rulesFromSchema(schema) : void 0);
2782
+ const database = await resolveDatabaseConfig(cfg);
2783
+ const { schema, rules } = database;
2540
2784
  if (options.dryRun) {
2541
2785
  out.log("dry run: no network calls or file writes");
2542
2786
  out.log(` schema: ${schema ? "yes" : "no"}`);
2543
2787
  out.log(` rules: ${rules ? Object.keys(rules).length : 0} namespaces`);
2788
+ for (const integration of database.integrations) {
2789
+ const namespaces = Object.keys(integration.schema?.entities ?? {}).length;
2790
+ out.log(` integration.${integration.id}: ${namespaces} namespaces, ${integration.seeds?.length ?? 0} seeds, ${integration.probes?.length ?? 0} smoke probes`);
2791
+ }
2544
2792
  out.log(` ai: ${cfg.services.includes("ai") ? cfg.ai?.provider ?? "not configured" : "not enabled"}`);
2545
2793
  if (cfg.services.includes("calendar")) {
2546
2794
  for (const env of cfg.envs) {
@@ -2650,18 +2898,21 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
2650
2898
  }
2651
2899
  }
2652
2900
  if (schema && dbKey) {
2653
- await postJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, dbKey, { schema });
2901
+ await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/schema`, dbKey, { schema });
2654
2902
  out.log(`${env}: schema pushed`);
2655
2903
  }
2656
2904
  if (rules) {
2657
- await postJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/admin/rules`, token, rules);
2905
+ await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(tenantId)}/admin/rules`, token, rules);
2658
2906
  out.log(`${env}: rules pushed (${Object.keys(rules).length} namespaces)`);
2659
2907
  }
2908
+ if (dbKey) {
2909
+ await provisionIntegrationSeeds(doFetch, cfg.dbEndpoint, tenantId, dbKey, database.integrations, env, out);
2910
+ }
2660
2911
  if (cfg.services.includes("ai") && cfg.ai?.provider && cfg.ai.keyEnv) {
2661
2912
  const key = import_node_process7.default.env[cfg.ai.keyEnv];
2662
2913
  if (key) {
2663
2914
  const secretName = cfg.ai.secretName ?? defaultSecretName(cfg.ai.provider);
2664
- await (0, import_ai.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
2915
+ await (0, import_ai2.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, secretName, key);
2665
2916
  out.log(`${env}: ${cfg.ai.provider} key stored in vault (${secretName})`);
2666
2917
  } else {
2667
2918
  out.log(`${env}: ${cfg.ai.keyEnv} not set; skipped provider key storage`);
@@ -2697,11 +2948,6 @@ ${env}: credentials are already saved; retry "odla-ai secrets push --env ${env}$
2697
2948
  }
2698
2949
  }
2699
2950
  }
2700
- function serviceRank(service) {
2701
- if (service === "db") return 0;
2702
- if (service === "calendar") return 2;
2703
- return 1;
2704
- }
2705
2951
  async function readRegistryApp(cfg, token, doFetch) {
2706
2952
  const res = await doFetch(`${cfg.platformUrl}/registry/apps/${encodeURIComponent(cfg.app.id)}`, {
2707
2953
  headers: { authorization: `Bearer ${token}` }
@@ -2711,13 +2957,13 @@ async function readRegistryApp(cfg, token, doFetch) {
2711
2957
  const json = await res.json();
2712
2958
  return json.app ?? null;
2713
2959
  }
2714
- async function postJson(doFetch, url, bearer, body) {
2960
+ async function postJson2(doFetch, url, bearer, body) {
2715
2961
  const res = await doFetch(url, {
2716
2962
  method: "POST",
2717
2963
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
2718
2964
  body: JSON.stringify(body)
2719
2965
  });
2720
- if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText3(res)}`);
2966
+ if (!res.ok) throw new Error(`${new URL(url).pathname} failed: ${res.status} ${await safeText4(res)}`);
2721
2967
  }
2722
2968
  function normalizeClerkConfig(value) {
2723
2969
  if (!value) return null;
@@ -2730,11 +2976,7 @@ function normalizeClerkConfig(value) {
2730
2976
  const publishableKey = envValue(cfg.publishableKey);
2731
2977
  return publishableKey ? { publishableKey, ...cfg.audience ? { audience: cfg.audience } : {}, ...cfg.mode ? { mode: cfg.mode } : {} } : null;
2732
2978
  }
2733
- function defaultSecretName(provider) {
2734
- const names = import_ai.DEFAULT_SECRET_NAMES;
2735
- return names[provider] ?? `${provider}_api_key`;
2736
- }
2737
- async function safeText3(res) {
2979
+ async function safeText4(res) {
2738
2980
  try {
2739
2981
  return redactSecrets((await res.text()).slice(0, 500));
2740
2982
  } catch {
@@ -2743,7 +2985,7 @@ async function safeText3(res) {
2743
2985
  }
2744
2986
 
2745
2987
  // src/secrets-set.ts
2746
- var import_ai2 = require("@odla-ai/ai");
2988
+ var import_ai3 = require("@odla-ai/ai");
2747
2989
  var import_apps3 = require("@odla-ai/apps");
2748
2990
  var PROD_ENV_NAMES2 = /* @__PURE__ */ new Set(["prod", "production"]);
2749
2991
  async function secretsSet(options) {
@@ -2755,7 +2997,7 @@ async function secretsSet(options) {
2755
2997
  const { cfg, tenantId, value, doFetch, out } = await resolveVaultWrite(options);
2756
2998
  const token = await getDeveloperToken(cfg, options, doFetch, out);
2757
2999
  try {
2758
- await (0, import_ai2.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, name, value);
3000
+ await (0, import_ai3.putSecret)({ endpoint: cfg.dbEndpoint, token, fetch: doFetch }, tenantId, name, value);
2759
3001
  } catch (err) {
2760
3002
  throw new Error(scrubValue(err instanceof Error ? err.message : String(err), value));
2761
3003
  }
@@ -3061,7 +3303,7 @@ function formatBudget(usage) {
3061
3303
 
3062
3304
  // src/security-hosted-github.ts
3063
3305
  var import_node_child_process4 = require("child_process");
3064
- var import_node_util = require("util");
3306
+ var import_node_util2 = require("util");
3065
3307
 
3066
3308
  // src/security-hosted-request.ts
3067
3309
  async function requestHostedSecurityJson(options, path, init, action) {
@@ -3265,7 +3507,7 @@ async function inferGitHubRepository(cwd = process.cwd(), readOrigin = defaultRe
3265
3507
  return repositoryFromGitRemote(remote);
3266
3508
  }
3267
3509
  async function defaultReadOrigin(cwd) {
3268
- const result = await (0, import_node_util.promisify)(import_node_child_process4.execFile)(
3510
+ const result = await (0, import_node_util2.promisify)(import_node_child_process4.execFile)(
3269
3511
  "git",
3270
3512
  ["remote", "get-url", "origin"],
3271
3513
  { cwd, encoding: "utf8" }
@@ -4014,7 +4256,8 @@ async function smoke(options) {
4014
4256
  assertCalendarHealthy(status, calendarServiceConfig(cfg, env));
4015
4257
  out.log(` calendar: ${status.status}, bookable (booking \u2192 ${status.bookingCalendarId ?? "primary"})`);
4016
4258
  }
4017
- const expectedSchema = await resolveDataExport(cfg, cfg.db?.schema, ["schema", "SCHEMA"]);
4259
+ const database = await resolveDatabaseConfig(cfg);
4260
+ const expectedSchema = database.schema;
4018
4261
  const expectedEntities = serializedEntities(expectedSchema);
4019
4262
  const liveSchemaPayload = await getJson(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/schema`, entry.dbKey);
4020
4263
  const liveSchema = liveSchemaPayload.schema ?? liveSchemaPayload;
@@ -4026,7 +4269,7 @@ async function smoke(options) {
4026
4269
  out.log(` schema: ${liveEntities.length} entities`);
4027
4270
  const aggregateEntity = expectedEntities[0] ?? liveEntities[0];
4028
4271
  if (aggregateEntity) {
4029
- const aggregate = await postJson2(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
4272
+ const aggregate = await postJson3(doFetch, `${cfg.dbEndpoint}/app/${encodeURIComponent(entry.tenantId)}/aggregate`, entry.dbKey, {
4030
4273
  ns: aggregateEntity,
4031
4274
  aggregate: { count: true }
4032
4275
  });
@@ -4035,6 +4278,21 @@ async function smoke(options) {
4035
4278
  } else {
4036
4279
  out.log(` aggregate: skipped (schema has no entities)`);
4037
4280
  }
4281
+ const probes = database.integrations.flatMap(
4282
+ (integration) => (integration.probes ?? []).map((probe) => ({ integration: integration.id, probe }))
4283
+ );
4284
+ if (probes.length) {
4285
+ const link = cfg.links?.[env];
4286
+ if (!link) throw new Error(`integration smoke probes require links.${env} in odla.config.mjs`);
4287
+ for (const { integration, probe } of probes) {
4288
+ const probeUrl = new URL(probe.path, link).toString();
4289
+ const res = await doFetch(probeUrl, { redirect: "manual" });
4290
+ if (res.status !== probe.expectedStatus) {
4291
+ throw new Error(`integration "${integration}" probe ${probe.path} returned ${res.status}; expected ${probe.expectedStatus}`);
4292
+ }
4293
+ out.log(` integration.${integration}: ${probe.path} \u2192 ${res.status}`);
4294
+ }
4295
+ }
4038
4296
  out.log("ok");
4039
4297
  }
4040
4298
  function assertCalendarHealthy(status, expected) {
@@ -4052,16 +4310,16 @@ async function getJson(doFetch, url, bearer) {
4052
4310
  const res = await doFetch(url, {
4053
4311
  headers: bearer ? { authorization: `Bearer ${bearer}` } : void 0
4054
4312
  });
4055
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
4313
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText5(res)}`);
4056
4314
  return res.json();
4057
4315
  }
4058
- async function postJson2(doFetch, url, bearer, body) {
4316
+ async function postJson3(doFetch, url, bearer, body) {
4059
4317
  const res = await doFetch(url, {
4060
4318
  method: "POST",
4061
4319
  headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
4062
4320
  body: JSON.stringify(body)
4063
4321
  });
4064
- if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText4(res)}`);
4322
+ if (!res.ok) throw new Error(`${new URL(url).pathname} returned ${res.status}: ${await safeText5(res)}`);
4065
4323
  return res.json();
4066
4324
  }
4067
4325
  function publicConfigUrl(platformUrl, appId, env) {
@@ -4069,7 +4327,7 @@ function publicConfigUrl(platformUrl, appId, env) {
4069
4327
  url.searchParams.set("env", env);
4070
4328
  return url.toString();
4071
4329
  }
4072
- async function safeText4(res) {
4330
+ async function safeText5(res) {
4073
4331
  try {
4074
4332
  return (await res.text()).slice(0, 500);
4075
4333
  } catch {