@odla-ai/chapter 0.25.8 → 0.26.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/index.js CHANGED
@@ -519,12 +519,17 @@ var DEFAULT_ADMIN_COPY = {
519
519
  overview: "Overview",
520
520
  billing: "Billing",
521
521
  people: "People",
522
+ portfolio: "Portfolio",
522
523
  settings: "Settings",
523
524
  calendar: "Calendar",
524
525
  email: "Email",
526
+ chapters: "Chapters",
527
+ formations: "Formation applications",
528
+ dealFlow: "Deal flow",
525
529
  dashboardViewsLabel: "Dashboard views",
526
530
  settingsViewsLabel: "Settings views",
527
- collectionsLabel: "CRM collections"
531
+ collectionsLabel: "CRM collections",
532
+ portfolioViewsLabel: "Portfolio views"
528
533
  },
529
534
  dashboard: {
530
535
  loading: "Loading dashboard\u2026",
@@ -630,7 +635,19 @@ var DEFAULT_ADMIN_COPY = {
630
635
  shareWith: "Share with {target}",
631
636
  shared: "Shared with {target}.",
632
637
  shareFailed: "Could not share with {target}.",
633
- deliveryFailed: "Delivery failed."
638
+ deliveryFailed: "Delivery failed.",
639
+ loadingRollup: "Loading network overview\u2026",
640
+ loadRollupFailed: "Couldn't load the network overview",
641
+ overview: "Network overview",
642
+ configuredFollowers: "Configured followers",
643
+ reachableFollowers: "Reachable followers",
644
+ recordsAcrossFollowers: "Records across followers",
645
+ noFollowers: "No follower sites are configured.",
646
+ followers: "Followers",
647
+ available: "available",
648
+ unavailable: "unavailable",
649
+ recordTotals: "Portfolio records",
650
+ noRecordTotals: "No aggregate records are available yet."
634
651
  },
635
652
  records: {
636
653
  workflowMissing: "This record is not linked to an application workflow.",
@@ -788,9 +805,85 @@ function formatChapterCopy(template, values) {
788
805
  );
789
806
  }
790
807
 
808
+ // src/formation.ts
809
+ var DEFAULT_REQUIRED2 = ["name", "email", "location", "thesis"];
810
+ var DEFAULT_OPTIONAL2 = ["proposedChapter", "experience", "collaborators"];
811
+ function resolveLeaderFormation(input, crm) {
812
+ const type = input?.type ?? "chapter_application";
813
+ if (!input) {
814
+ return {
815
+ enabled: false,
816
+ type,
817
+ required: [],
818
+ optional: [],
819
+ maxLen: {},
820
+ defaultMaxLen: 4e3,
821
+ bodyCap: 32768
822
+ };
823
+ }
824
+ const def = crm.config.types[type];
825
+ if (!def) throw new Error(`defineChapter.formation.type: unknown CRM type "${type}"`);
826
+ const required = input.required ?? (type === "chapter_application" ? DEFAULT_REQUIRED2 : []);
827
+ const optional = input.optional ?? (type === "chapter_application" ? DEFAULT_OPTIONAL2 : []);
828
+ for (const [name, values] of [["required", required], ["optional", optional]]) {
829
+ if (!Array.isArray(values) || !values.every((field) => typeof field === "string" && field !== "")) {
830
+ throw new Error(`defineChapter.formation.${name}: must be an array of field-name strings`);
831
+ }
832
+ }
833
+ const duplicates = [...required, ...optional].filter((field, index, fields) => fields.indexOf(field) !== index);
834
+ if (duplicates.length) throw new Error(`defineChapter.formation: duplicate public field "${duplicates[0]}"`);
835
+ const unknown = [...required, ...optional].filter((field) => !def.fields[field]);
836
+ if (unknown.length) throw new Error(`defineChapter.formation: field "${unknown[0]}" is not declared on CRM type "${type}"`);
837
+ const missingRequired = Object.entries(def.fields).filter(([, field]) => field.required).map(([field]) => field).filter((field) => !required.includes(field));
838
+ if (missingRequired.length) {
839
+ throw new Error(`defineChapter.formation.required: must include required CRM field "${missingRequired[0]}"`);
840
+ }
841
+ const unsupportedRequired = required.find((field) => !["string", "email", "url", "enum"].includes(def.fields[field].type));
842
+ if (unsupportedRequired) {
843
+ throw new Error(`defineChapter.formation.required: field "${unsupportedRequired}" must be string-like`);
844
+ }
845
+ if (required.length === 0) throw new Error("defineChapter.formation.required: must not be empty");
846
+ const defaultMaxLen = input.defaultMaxLen ?? 4e3;
847
+ const bodyCap = input.bodyCap ?? 32768;
848
+ if (!Number.isSafeInteger(defaultMaxLen) || defaultMaxLen < 1 || defaultMaxLen > 32768) {
849
+ throw new Error("defineChapter.formation.defaultMaxLen: must be an integer from 1 to 32768");
850
+ }
851
+ if (!Number.isSafeInteger(bodyCap) || bodyCap < 1024 || bodyCap > 131072) {
852
+ throw new Error("defineChapter.formation.bodyCap: must be an integer from 1024 to 131072");
853
+ }
854
+ for (const [field, cap] of Object.entries(input.maxLen ?? {})) {
855
+ if (![...required, ...optional].includes(field) || !Number.isSafeInteger(cap) || cap < 1 || cap > 32768) {
856
+ throw new Error(`defineChapter.formation.maxLen.${field}: must name a public field and be an integer from 1 to 32768`);
857
+ }
858
+ }
859
+ return {
860
+ enabled: true,
861
+ type,
862
+ required,
863
+ optional,
864
+ maxLen: input.maxLen ?? {},
865
+ defaultMaxLen,
866
+ bodyCap
867
+ };
868
+ }
869
+ function formationFields(crm, formation) {
870
+ const def = crm.type(formation.type);
871
+ return [...formation.required, ...formation.optional].map((id2) => {
872
+ const field = def.fields[id2];
873
+ return {
874
+ id: id2,
875
+ label: field.label ?? id2,
876
+ type: field.type,
877
+ required: formation.required.includes(id2),
878
+ ...field.options ? { options: field.options } : {}
879
+ };
880
+ });
881
+ }
882
+
791
883
  // src/config.ts
792
884
  var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
793
885
  var FIELD = /^[a-z][a-zA-Z0-9_]*$/;
886
+ var BINDING = /^[A-Z][A-Z0-9_]{0,127}$/;
794
887
  function resolveNetwork(config, crm) {
795
888
  const seen = /* @__PURE__ */ new Set();
796
889
  const targets = [];
@@ -836,10 +929,17 @@ function resolveNetwork(config, crm) {
836
929
  if (!/^[a-zA-Z][a-zA-Z0-9_-]{1,127}$/.test(secretName)) {
837
930
  throw new Error(`defineChapter.network.targets.${target.id}.secretName: must be a vault-key identifier`);
838
931
  }
932
+ const binding = target.binding?.trim();
933
+ if (binding && !BINDING.test(binding)) {
934
+ throw new Error(
935
+ `defineChapter.network.targets.${target.id}.binding: must be an uppercase Worker binding identifier`
936
+ );
937
+ }
839
938
  targets.push({
840
939
  id: target.id,
841
940
  name: target.name?.trim() || target.id,
842
941
  url: url.origin,
942
+ ...binding ? { binding } : {},
843
943
  secretName,
844
944
  ...fields ? { fields } : {}
845
945
  });
@@ -876,6 +976,7 @@ function defineChapter(config) {
876
976
  const brand = { ...config.brand, wordmark: config.brand?.wordmark ?? name };
877
977
  const copy = resolveChapterCopy(config.copy);
878
978
  const network = resolveNetwork(config, crm);
979
+ const formation = resolveLeaderFormation(config.formation, crm);
879
980
  const { schema, rules } = chapterDb(mode, auth);
880
981
  const services = config.services ?? ["db", "calendar", "o11y"];
881
982
  const account = config.account ?? "none";
@@ -931,6 +1032,7 @@ function defineChapter(config) {
931
1032
  brand,
932
1033
  copy,
933
1034
  network,
1035
+ formation,
934
1036
  crm,
935
1037
  auth,
936
1038
  pipeline,
@@ -947,6 +1049,147 @@ function defineChapter(config) {
947
1049
  return chapter;
948
1050
  }
949
1051
 
1052
+ // src/leader.ts
1053
+ import { composeCrmConfigs } from "@odla-ai/crm";
1054
+ function leaderCrmConfig(options = {}) {
1055
+ const base = options.base ?? defaultCrm("hub");
1056
+ const portfolio = {
1057
+ types: {
1058
+ chapter: {
1059
+ label: "Chapter",
1060
+ labelPlural: "Chapters",
1061
+ workspace: "portfolio",
1062
+ nameField: "name",
1063
+ fields: {
1064
+ name: { type: "string", label: "Name", required: true },
1065
+ slug: { type: "string", label: "Slug", slot: "s1" },
1066
+ region: { type: "string", label: "Region", slot: "s2" },
1067
+ url: { type: "url", label: "Website" },
1068
+ thesis: { type: "string", label: "Investment thesis" },
1069
+ launchedAt: { type: "date", label: "Launch date", slot: "d1" },
1070
+ notes: { type: "string", label: "Notes" }
1071
+ },
1072
+ pipeline: {
1073
+ stages: [
1074
+ { id: "proposed", label: "Proposed" },
1075
+ { id: "forming", label: "Forming" },
1076
+ { id: "active", label: "Active" },
1077
+ { id: "paused", label: "Paused" },
1078
+ { id: "closed", label: "Closed", terminal: true }
1079
+ ],
1080
+ transitions: {
1081
+ proposed: ["forming", "closed"],
1082
+ forming: ["active", "paused", "closed"],
1083
+ active: ["paused", "closed"],
1084
+ paused: ["forming", "active", "closed"]
1085
+ }
1086
+ },
1087
+ facets: { rank: "manual" }
1088
+ },
1089
+ chapter_application: {
1090
+ label: "Chapter application",
1091
+ labelPlural: "Formation applications",
1092
+ workspace: "portfolio",
1093
+ nameField: "name",
1094
+ emailField: "email",
1095
+ fields: {
1096
+ name: { type: "string", label: "Applicant name", required: true },
1097
+ email: { type: "email", label: "Email", required: true },
1098
+ location: { type: "string", label: "Location", required: true, slot: "s1" },
1099
+ proposedChapter: { type: "string", label: "Proposed chapter", slot: "s2" },
1100
+ thesis: { type: "string", label: "Proposed thesis", required: true },
1101
+ experience: { type: "string", label: "Relevant experience" },
1102
+ collaborators: { type: "json", label: "Collaborators" },
1103
+ notes: { type: "string", label: "Internal notes" }
1104
+ },
1105
+ pipeline: {
1106
+ stages: [
1107
+ { id: "submitted", label: "Submitted" },
1108
+ { id: "reviewing", label: "Reviewing" },
1109
+ { id: "interviewing", label: "Interviewing" },
1110
+ { id: "approved", label: "Approved" },
1111
+ { id: "forming", label: "Forming" },
1112
+ { id: "declined", label: "Declined", terminal: true }
1113
+ ],
1114
+ transitions: {
1115
+ submitted: ["reviewing", "declined"],
1116
+ reviewing: ["interviewing", "approved", "declined"],
1117
+ interviewing: ["approved", "declined"],
1118
+ approved: ["forming", "declined"]
1119
+ }
1120
+ },
1121
+ facets: { email: true, rank: "manual" }
1122
+ },
1123
+ deal: {
1124
+ label: "Deal",
1125
+ labelPlural: "Deal flow",
1126
+ workspace: "portfolio",
1127
+ nameField: "name",
1128
+ fields: {
1129
+ name: { type: "string", label: "Company or deal", required: true },
1130
+ chapter: { type: "string", label: "Source chapter", slot: "s1" },
1131
+ sector: { type: "string", label: "Sector", slot: "s2" },
1132
+ source: { type: "string", label: "Source", slot: "s3" },
1133
+ website: { type: "url", label: "Website" },
1134
+ amount: { type: "number", label: "Target amount", slot: "n1" },
1135
+ notes: { type: "string", label: "Notes" }
1136
+ },
1137
+ pipeline: {
1138
+ stages: [
1139
+ { id: "sourced", label: "Sourced" },
1140
+ { id: "screening", label: "Screening" },
1141
+ { id: "diligence", label: "Diligence" },
1142
+ { id: "committee", label: "Committee" },
1143
+ { id: "offered", label: "Offered" },
1144
+ { id: "invested", label: "Invested", terminal: true },
1145
+ { id: "passed", label: "Passed", terminal: true }
1146
+ ],
1147
+ transitions: {
1148
+ sourced: ["screening", "passed"],
1149
+ screening: ["diligence", "committee", "passed"],
1150
+ diligence: ["committee", "offered", "passed"],
1151
+ committee: ["offered", "passed"],
1152
+ offered: ["invested", "passed"],
1153
+ passed: ["sourced"]
1154
+ }
1155
+ },
1156
+ facets: { rank: "manual" }
1157
+ }
1158
+ },
1159
+ relations: {
1160
+ formation_result: {
1161
+ from: "chapter_application",
1162
+ to: "chapter",
1163
+ label: "formed as",
1164
+ reverseLabel: "formation application"
1165
+ },
1166
+ chapter_deal: {
1167
+ from: "deal",
1168
+ to: "chapter",
1169
+ label: "sourced by",
1170
+ reverseLabel: "deal flow"
1171
+ }
1172
+ }
1173
+ };
1174
+ if (base.types.company) {
1175
+ portfolio.relations.deal_company = {
1176
+ from: "deal",
1177
+ to: "company",
1178
+ label: "concerns",
1179
+ reverseLabel: "deals"
1180
+ };
1181
+ }
1182
+ if (base.types.person) {
1183
+ portfolio.relations.application_contact = {
1184
+ from: "person",
1185
+ to: "chapter_application",
1186
+ label: "submitted",
1187
+ reverseLabel: "applicant"
1188
+ };
1189
+ }
1190
+ return composeCrmConfigs(base, portfolio);
1191
+ }
1192
+
950
1193
  // src/descriptor.ts
951
1194
  import { createCrmIntegration } from "@odla-ai/crm";
952
1195
  function createChapterIntegration(chapter, options = {}) {
@@ -985,7 +1228,15 @@ function createChapterIntegration(chapter, options = {}) {
985
1228
  },
986
1229
  rules: { ...crmDesc.rules, ...chapter.rules },
987
1230
  seeds,
988
- probes: [...crmDesc.probes ?? []],
1231
+ probes: [
1232
+ ...crmDesc.probes ?? [],
1233
+ // The snapshot route is deliberately private. A credential-free smoke
1234
+ // request must prove that the route is mounted and closed.
1235
+ { path: "/api/network/snapshot", expectedStatus: 401 },
1236
+ // Formation metadata is public only when the host explicitly enables
1237
+ // the bounded intake contract.
1238
+ ...chapter.formation.enabled ? [{ path: "/api/formation/config", expectedStatus: 200 }] : []
1239
+ ],
989
1240
  runbooks
990
1241
  };
991
1242
  }
@@ -1189,11 +1440,20 @@ function canceledPatch() {
1189
1440
  }
1190
1441
 
1191
1442
  // src/network.ts
1192
- import { createRecord, updateRecord } from "@odla-ai/crm";
1443
+ import {
1444
+ createRecord,
1445
+ getRecordByOrigin,
1446
+ updateRecord,
1447
+ upsertRecordOrigin
1448
+ } from "@odla-ai/crm";
1449
+
1450
+ // src/network-contract.ts
1193
1451
  var DEFAULT_SHARE_FIELDS = {
1194
1452
  person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
1195
1453
  company: ["name", "domain", "industry", "location", "linkedin", "notes"]
1196
1454
  };
1455
+
1456
+ // src/network.ts
1197
1457
  function sharedPersonInput(person) {
1198
1458
  const email = person.email.toLowerCase();
1199
1459
  const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
@@ -1215,25 +1475,55 @@ function shortHash(value) {
1215
1475
  }
1216
1476
  return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
1217
1477
  }
1218
- function networkSourceTag(type, hubRecordId) {
1478
+ function networkSourceTag(type, hubRecordId, sourceId) {
1219
1479
  const typeKey = type.toLowerCase();
1220
1480
  const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
1221
- const raw = `network:${typeKey}:${hubRecordId}`;
1481
+ const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
1482
+ const raw = `${prefix}:${hubRecordId}`;
1222
1483
  if (readable && raw.length <= 64) return raw;
1223
- return `network:${typeKey.slice(0, 20)}:${shortHash(`${type}\0${hubRecordId}`)}`;
1224
- }
1225
- function normalizeSharedRecord(record) {
1226
- if ("input" in record) return { version: 1, type: record.type, hubRecordId: record.hubRecordId, input: record.input };
1484
+ return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
1485
+ }
1486
+ function normalizeSharedRecord(record, fallbackSourceId = "legacy-source") {
1487
+ if ("input" in record && record.version === 2) {
1488
+ return {
1489
+ version: 2,
1490
+ sourceId: record.source.siteId,
1491
+ sourceRecordId: record.source.recordId,
1492
+ type: record.type,
1493
+ input: record.input
1494
+ };
1495
+ }
1496
+ if ("input" in record) {
1497
+ return {
1498
+ version: 1,
1499
+ sourceId: fallbackSourceId,
1500
+ sourceRecordId: record.hubRecordId,
1501
+ type: record.type,
1502
+ input: record.input
1503
+ };
1504
+ }
1227
1505
  if ("type" in record && record.type === "company") {
1228
1506
  const input = { name: record.name };
1229
1507
  for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
1230
1508
  if (record[key]) input[key] = record[key];
1231
1509
  }
1232
- return { version: 1, type: "company", hubRecordId: record.hubRecordId, input };
1510
+ return {
1511
+ version: 1,
1512
+ sourceId: fallbackSourceId,
1513
+ sourceRecordId: record.hubRecordId,
1514
+ type: "company",
1515
+ input
1516
+ };
1233
1517
  }
1234
- return { version: 1, type: "person", hubRecordId: record.hubRecordId, input: sharedPersonInput(record) };
1518
+ return {
1519
+ version: 1,
1520
+ sourceId: fallbackSourceId,
1521
+ sourceRecordId: record.hubRecordId,
1522
+ type: "person",
1523
+ input: sharedPersonInput(record)
1524
+ };
1235
1525
  }
1236
- function sharedRecordFromCrm(crm, record, target) {
1526
+ function sharedRecordFromCrm(crm, record, target, sourceId) {
1237
1527
  if (target.fields && !target.fields[record.type]) {
1238
1528
  throw new Error(`${target.name} does not accept "${record.type}" records`);
1239
1529
  }
@@ -1249,7 +1539,7 @@ function sharedRecordFromCrm(crm, record, target) {
1249
1539
  if (value !== void 0) input[field] = value;
1250
1540
  }
1251
1541
  if (input[nameField] === void 0) input[nameField] = record.name;
1252
- return { version: 1, type: record.type, hubRecordId: record.id, input };
1542
+ return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record.id }, type: record.type, input } : { version: 1, type: record.type, hubRecordId: record.id, input };
1253
1543
  }
1254
1544
  async function upsertPerson(deps, opts) {
1255
1545
  const email = opts.email.toLowerCase();
@@ -1264,6 +1554,12 @@ async function upsertPerson(deps, opts) {
1264
1554
  return { recordId: created.id };
1265
1555
  }
1266
1556
  async function findSharedRecord(deps, record, tag) {
1557
+ const structured = await getRecordByOrigin(
1558
+ { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
1559
+ record.sourceId,
1560
+ record.sourceRecordId
1561
+ );
1562
+ if (structured) return structured;
1267
1563
  const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
1268
1564
  const mappedId = mapped.crm_tag?.[0]?.recordId;
1269
1565
  if (typeof mappedId === "string") {
@@ -1297,12 +1593,15 @@ async function findSharedRecord(deps, record, tag) {
1297
1593
  }
1298
1594
  return void 0;
1299
1595
  }
1300
- async function projectSharedRecord(deps, shared) {
1301
- const record = normalizeSharedRecord(shared);
1302
- if (!record.type.trim() || !record.hubRecordId.trim()) {
1303
- throw new Error("type and hubRecordId must be non-empty");
1596
+ async function projectSharedRecord(deps, shared, options = {}) {
1597
+ const record = normalizeSharedRecord(shared, options.sourceId);
1598
+ if (!record.type.trim() || !record.sourceId.trim() || !record.sourceRecordId.trim()) {
1599
+ throw new Error("type, source site id, and source record id must be non-empty");
1304
1600
  }
1305
- const tag = networkSourceTag(record.type, record.hubRecordId);
1601
+ if (options.sourceId && options.sourceId !== record.sourceId) {
1602
+ throw new Error("signed sender does not match payload source");
1603
+ }
1604
+ const tag = networkSourceTag(record.type, record.sourceRecordId, record.version === 2 ? record.sourceId : void 0);
1306
1605
  const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1307
1606
  const existing = await findSharedRecord(deps, record, tag);
1308
1607
  let recordId;
@@ -1310,7 +1609,7 @@ async function projectSharedRecord(deps, shared) {
1310
1609
  await updateRecord(crmDeps, { id: existing.id, input: record.input });
1311
1610
  recordId = existing.id;
1312
1611
  } else {
1313
- recordId = `network_${shortHash(`${record.type}\0${record.hubRecordId}`)}`;
1612
+ recordId = `network_${shortHash(`${record.sourceId}\0${record.type}\0${record.sourceRecordId}`)}`;
1314
1613
  await createRecord({ ...crmDeps, newId: () => recordId }, {
1315
1614
  type: record.type,
1316
1615
  input: record.input,
@@ -1321,6 +1620,13 @@ async function projectSharedRecord(deps, shared) {
1321
1620
  [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
1322
1621
  { mutationId: `share-map:${tag}:${recordId}` }
1323
1622
  );
1623
+ await upsertRecordOrigin(crmDeps, {
1624
+ recordId,
1625
+ sourceId: record.sourceId,
1626
+ sourceRecordId: record.sourceRecordId,
1627
+ ...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
1628
+ payloadVersion: record.version
1629
+ });
1324
1630
  return { recordId };
1325
1631
  }
1326
1632
  async function projectApplicant(deps, applicant) {
@@ -2006,6 +2312,7 @@ export {
2006
2312
  findApplicationRef,
2007
2313
  firstPaymentPatch,
2008
2314
  formatChapterCopy,
2315
+ formationFields,
2009
2316
  getVaultSecret,
2010
2317
  hasDisclaimerAck,
2011
2318
  introIdempotencyKey,
@@ -2015,6 +2322,7 @@ export {
2015
2322
  isSlotAvailable,
2016
2323
  isValidEmail,
2017
2324
  joinConfig,
2325
+ leaderCrmConfig,
2018
2326
  meetingCreateRow,
2019
2327
  meetingRescheduleUpdate,
2020
2328
  memberApplication,
@@ -2036,6 +2344,7 @@ export {
2036
2344
  resolveApplication,
2037
2345
  resolveAuth,
2038
2346
  resolveChapterCopy,
2347
+ resolveLeaderFormation,
2039
2348
  resolvePipeline,
2040
2349
  resolveScheduling,
2041
2350
  roleFromClaim,