@odla-ai/chapter 0.42.0 → 0.42.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/README.md CHANGED
@@ -888,6 +888,10 @@ export const chapter = defineChapter({
888
888
  },
889
889
  // Separate write consent: browsing never implies note permission.
890
890
  sharedNotes: ["person"],
891
+ // Separate again: name the exact profile fields and record types the
892
+ // parent may mutate. Omission keeps the edge read-only.
893
+ editableFields: { person: ["name", "email", "phone", "linkedin"] },
894
+ stageTransitions: ["person"],
891
895
  }],
892
896
  },
893
897
  });
@@ -896,14 +900,24 @@ export const chapter = defineChapter({
896
900
  `GET /api/network/records` requires the edge HMAC and rejects signed senders not
897
901
  listed in `network.readers`. Results are capped at 50 records, offset-paged,
898
902
  search-bounded, and projected to the reader's field list. Pipeline stage and
899
- record timestamps are the only metadata outside that list; application
903
+ record timestamps, optimistic `revision`, and explicitly delegated edit
904
+ metadata are the only metadata outside that list; application
900
905
  narrative, billing, account state, activities, and notes do not cross unless
901
906
  the follower explicitly names a corresponding CRM field.
902
907
 
903
908
  The leader's authenticated `GET /api/admin/network/records` proxy signs the
904
909
  follower request server-to-server, so neither the browser session nor the
905
- vaulted edge secret crosses sites. The Network detail view has two deliberate
906
- lanes:
910
+ vaulted edge secret crosses sites. When both halves of an edge opt in,
911
+ `PATCH /api/admin/network/records` accepts one bounded field patch or lifecycle
912
+ destination with the follower-authored `expectedRevision`, current stage, and a
913
+ stable mutation id. The follower revalidates its CRM schema, identity/email
914
+ rules, pipeline gates, and atomic revision guard; it remains source of truth and
915
+ records the leader peer plus initiating admin in the CRM activity audit. A
916
+ retry with the same input is idempotent, while stale state is `409`, an
917
+ undelegated operation is `403`, invalid fields or transitions are `422`, and
918
+ ordinary browse-only edges remain read-only.
919
+
920
+ The Network detail view also has two deliberate note lanes:
907
921
 
908
922
  - `GET/POST /api/admin/network/notes` stores private annotations in the
909
923
  leader's deny-all `networkNotes` namespace. They are keyed to the follower's
@@ -935,6 +949,8 @@ export const chapter = defineChapter({
935
949
  company: ["name", "domain", "industry", "location", "linkedin"],
936
950
  },
937
951
  sharedNotes: ["person"],
952
+ editableFields: { person: ["name", "email", "phone", "linkedin"] },
953
+ stageTransitions: ["person"],
938
954
  }],
939
955
  },
940
956
  });
@@ -945,7 +961,7 @@ Treat the network as websites connected by explicit delivery edges:
945
961
  - each website is a node with its own `appId`, ODLA tenants/keys, CRM config,
946
962
  Clerk application, publishable keys, issuer, roles, and local users;
947
963
  - each `network.targets[]` entry is a directed edge from one leader to one
948
- follower: `{ id, name, url, binding?, secretName?, fields }`;
964
+ follower: `{ id, name, url, binding?, secretName?, fields, editableFields?, stageTransitions? }`;
949
965
  - the payload is a versioned, allowlisted business record
950
966
  `{ version: 2, source: { siteId, recordId }, type, input }`, never an
951
967
  identity-provider session, another site's pipeline/account/billing state, or
@@ -186,6 +186,13 @@ interface ChapterNetworkTarget {
186
186
  * this follower. Omitted by default: a target never gains write permission
187
187
  * merely because its records are browsable. */
188
188
  sharedNotes?: readonly string[];
189
+ /** Per-record-type upper bound on profile fields this leader permits its
190
+ * admins to edit remotely. The follower must independently delegate the
191
+ * same field; readable fields never become writable implicitly. */
192
+ editableFields?: Record<string, readonly string[]>;
193
+ /** Record types on which this leader permits remote lifecycle transitions.
194
+ * The follower must independently opt in and remains pipeline authority. */
195
+ stageTransitions?: readonly string[];
189
196
  }
190
197
  /** One signed peer allowed to browse a privacy-bounded projection of this
191
198
  * site's CRM. The follower owns this allowlist; a leader target declaration
@@ -200,6 +207,13 @@ interface ChapterNetworkReader {
200
207
  * Omitted by default: browse permission never silently grants write access.
201
208
  * Shared notes land in the follower CRM activity feed. */
202
209
  sharedNotes?: readonly string[];
210
+ /** Exact local CRM fields this reader may edit remotely, by record type.
211
+ * Every field must also be readable so the peer can perform an optimistic
212
+ * edit against the value and revision it actually reviewed. */
213
+ editableFields?: Record<string, readonly string[]>;
214
+ /** Local CRM record types on which this reader may request a lifecycle
215
+ * transition. Chapter still enforces the local pipeline and gates. */
216
+ stageTransitions?: readonly string[];
203
217
  }
204
218
  /** Leader/follower network configuration. A follower needs no code config to
205
219
  * receive pushed records. It must explicitly declare `readers` before any peer
@@ -242,12 +256,16 @@ interface ResolvedNetworkTarget {
242
256
  secretName: string;
243
257
  fields?: Record<string, readonly string[]>;
244
258
  sharedNotes: readonly string[];
259
+ editableFields: Record<string, readonly string[]>;
260
+ stageTransitions: readonly string[];
245
261
  }
246
262
  /** A validated follower-owned record projection for one signed reader. */
247
263
  interface ResolvedNetworkReader {
248
264
  id: string;
249
265
  fields: Record<string, readonly string[]>;
250
266
  sharedNotes: readonly string[];
267
+ editableFields: Record<string, readonly string[]>;
268
+ stageTransitions: readonly string[];
251
269
  }
252
270
  /** Fully-resolved leader network configuration. */
253
271
  interface ResolvedNetwork {
package/dist/index.cjs CHANGED
@@ -1553,6 +1553,12 @@ function formationFields(crm, formation) {
1553
1553
  return resolveFormationFlowFields(crm, formation);
1554
1554
  }
1555
1555
 
1556
+ // src/network-contract.ts
1557
+ var DEFAULT_SHARE_FIELDS = {
1558
+ person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
1559
+ company: ["name", "domain", "industry", "location", "linkedin", "notes"]
1560
+ };
1561
+
1556
1562
  // src/config-network.ts
1557
1563
  var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
1558
1564
  var FIELD = /^[a-z][a-zA-Z0-9_]*$/;
@@ -1564,6 +1570,27 @@ function validateSharedNotes(opts) {
1564
1570
  throw new Error(`${opts.path}: must contain unique CRM types allowed by fields`);
1565
1571
  }
1566
1572
  }
1573
+ function validateEditableFields(opts) {
1574
+ for (const [type, names] of Object.entries(opts.values)) {
1575
+ const readable = opts.readable?.[type];
1576
+ const def = opts.crm.config.types[type];
1577
+ const duplicate = names.some((name, index) => names.indexOf(name) !== index);
1578
+ const unknown = names.some((name) => !FIELD.test(name) || !def?.fields[name]);
1579
+ const hidden = opts.readable ? !readable || names.some((name) => !readable.includes(name)) : false;
1580
+ if (!FIELD.test(type) || names.length === 0 || duplicate || !def || unknown || hidden) {
1581
+ throw new Error(
1582
+ `${opts.path}.${type}: must contain unique, readable fields declared on CRM type "${type}"`
1583
+ );
1584
+ }
1585
+ }
1586
+ }
1587
+ function validateStageTransitions(opts) {
1588
+ const duplicate = opts.values.some((type, index) => opts.values.indexOf(type) !== index);
1589
+ const invalid = opts.values.some((type) => !FIELD.test(type) || !opts.crm.config.types[type]?.pipeline || (opts.readable ? !opts.readable[type] : false));
1590
+ if (duplicate || invalid) {
1591
+ throw new Error(`${opts.path}: must contain unique, readable CRM types with pipelines`);
1592
+ }
1593
+ }
1567
1594
  function resolveNetwork(config, crm) {
1568
1595
  const seen = /* @__PURE__ */ new Set();
1569
1596
  const targets = [];
@@ -1622,6 +1649,21 @@ function resolveNetwork(config, crm) {
1622
1649
  crm,
1623
1650
  fields
1624
1651
  });
1652
+ const readableFields = fields ?? DEFAULT_SHARE_FIELDS;
1653
+ const editableFields = target.editableFields ?? {};
1654
+ validateEditableFields({
1655
+ path: `defineChapter.network.targets.${target.id}.editableFields`,
1656
+ values: editableFields,
1657
+ crm,
1658
+ readable: readableFields
1659
+ });
1660
+ const stageTransitions = target.stageTransitions ?? [];
1661
+ validateStageTransitions({
1662
+ path: `defineChapter.network.targets.${target.id}.stageTransitions`,
1663
+ values: stageTransitions,
1664
+ crm,
1665
+ readable: readableFields
1666
+ });
1625
1667
  targets.push({
1626
1668
  id: target.id,
1627
1669
  name: target.name?.trim() || target.id,
@@ -1629,7 +1671,9 @@ function resolveNetwork(config, crm) {
1629
1671
  ...binding ? { binding } : {},
1630
1672
  secretName,
1631
1673
  ...fields ? { fields } : {},
1632
- sharedNotes
1674
+ sharedNotes,
1675
+ editableFields,
1676
+ stageTransitions
1633
1677
  });
1634
1678
  }
1635
1679
  const readerIds = /* @__PURE__ */ new Set();
@@ -1676,7 +1720,21 @@ function resolveNetwork(config, crm) {
1676
1720
  crm,
1677
1721
  fields: reader.fields
1678
1722
  });
1679
- readers.push({ id: reader.id, fields: reader.fields, sharedNotes });
1723
+ const editableFields = reader.editableFields ?? {};
1724
+ validateEditableFields({
1725
+ path: `defineChapter.network.readers.${reader.id}.editableFields`,
1726
+ values: editableFields,
1727
+ crm,
1728
+ readable: reader.fields
1729
+ });
1730
+ const stageTransitions = reader.stageTransitions ?? [];
1731
+ validateStageTransitions({
1732
+ path: `defineChapter.network.readers.${reader.id}.stageTransitions`,
1733
+ values: stageTransitions,
1734
+ crm,
1735
+ readable: reader.fields
1736
+ });
1737
+ readers.push({ id: reader.id, fields: reader.fields, sharedNotes, editableFields, stageTransitions });
1680
1738
  }
1681
1739
  return { targets, readers };
1682
1740
  }
@@ -3254,14 +3312,6 @@ function findGiftOffer(catalog, offerId, now) {
3254
3312
 
3255
3313
  // src/network.ts
3256
3314
  var import_crm5 = require("@odla-ai/crm");
3257
-
3258
- // src/network-contract.ts
3259
- var DEFAULT_SHARE_FIELDS = {
3260
- person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
3261
- company: ["name", "domain", "industry", "location", "linkedin", "notes"]
3262
- };
3263
-
3264
- // src/network.ts
3265
3315
  function sharedPersonInput(person) {
3266
3316
  const email = person.email.toLowerCase();
3267
3317
  const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();