@odla-ai/chapter 0.19.0 → 0.20.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.
@@ -274,6 +274,10 @@ function joinConfig(group, paymentsReady) {
274
274
 
275
275
  // src/network.ts
276
276
  var import_crm = require("@odla-ai/crm");
277
+ var DEFAULT_SHARE_FIELDS = {
278
+ person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
279
+ company: ["name", "domain", "industry", "location", "linkedin", "notes"]
280
+ };
277
281
  function sharedPersonInput(person) {
278
282
  const email = person.email.toLowerCase();
279
283
  const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
@@ -285,6 +289,52 @@ function sharedPersonInput(person) {
285
289
  if (person.linkedin) input.linkedin = person.linkedin;
286
290
  return input;
287
291
  }
292
+ function shortHash(value) {
293
+ let a = 2166136261;
294
+ let b = 2654435769;
295
+ for (let i = 0; i < value.length; i += 1) {
296
+ const n = value.charCodeAt(i);
297
+ a = Math.imul(a ^ n, 16777619);
298
+ b = Math.imul(b ^ n, 2246822507);
299
+ }
300
+ return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
301
+ }
302
+ function networkSourceTag(type, hubRecordId) {
303
+ const typeKey = type.toLowerCase();
304
+ const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
305
+ const raw = `network:${typeKey}:${hubRecordId}`;
306
+ if (readable && raw.length <= 64) return raw;
307
+ return `network:${typeKey.slice(0, 20)}:${shortHash(`${type}\0${hubRecordId}`)}`;
308
+ }
309
+ function normalizeSharedRecord(record) {
310
+ if ("input" in record) return { version: 1, type: record.type, hubRecordId: record.hubRecordId, input: record.input };
311
+ if ("type" in record && record.type === "company") {
312
+ const input = { name: record.name };
313
+ for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
314
+ if (record[key]) input[key] = record[key];
315
+ }
316
+ return { version: 1, type: "company", hubRecordId: record.hubRecordId, input };
317
+ }
318
+ return { version: 1, type: "person", hubRecordId: record.hubRecordId, input: sharedPersonInput(record) };
319
+ }
320
+ function sharedRecordFromCrm(crm, record, target) {
321
+ if (target.fields && !target.fields[record.type]) {
322
+ throw new Error(`${target.name} does not accept "${record.type}" records`);
323
+ }
324
+ const def = crm.type(record.type);
325
+ const fields = target.fields?.[record.type] ?? DEFAULT_SHARE_FIELDS[record.type];
326
+ if (!fields) {
327
+ throw new Error(`${target.name} requires an explicit field allowlist for "${record.type}" records`);
328
+ }
329
+ const nameField = def.nameField ?? "name";
330
+ const input = {};
331
+ for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
332
+ const value = record.fields?.[field];
333
+ if (value !== void 0) input[field] = value;
334
+ }
335
+ if (input[nameField] === void 0) input[nameField] = record.name;
336
+ return { version: 1, type: record.type, hubRecordId: record.id, input };
337
+ }
288
338
  async function upsertPerson(deps, opts) {
289
339
  const email = opts.email.toLowerCase();
290
340
  const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
@@ -297,8 +347,65 @@ async function upsertPerson(deps, opts) {
297
347
  const created = await (0, import_crm.createRecord)(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
298
348
  return { recordId: created.id };
299
349
  }
300
- async function projectSharedRecord(deps, person) {
301
- return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
350
+ async function findSharedRecord(deps, record, tag) {
351
+ const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
352
+ const mappedId = mapped.crm_tag?.[0]?.recordId;
353
+ if (typeof mappedId === "string") {
354
+ const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
355
+ if (found.crm_record?.[0]) return found.crm_record[0];
356
+ }
357
+ const def = deps.crm.type(record.type);
358
+ const emailField = def.emailField;
359
+ if (emailField && typeof record.input[emailField] === "string") {
360
+ const primaryEmail = record.input[emailField].toLowerCase();
361
+ const found = await deps.db.query({
362
+ crm_record: { $: { where: { type: record.type, primaryEmail }, limit: 1 } }
363
+ });
364
+ if (found.crm_record?.[0]) return found.crm_record[0];
365
+ }
366
+ const domain = record.input.domain;
367
+ const domainSlot = def.fields.domain?.slot;
368
+ if (typeof domain === "string" && domainSlot) {
369
+ const found = await deps.db.query({
370
+ crm_record: { $: { where: { type: record.type, [domainSlot]: domain }, limit: 1 } }
371
+ });
372
+ if (found.crm_record?.[0]) return found.crm_record[0];
373
+ }
374
+ const nameField = def.nameField ?? "name";
375
+ const name = record.input[nameField];
376
+ if (record.type === "company" && typeof name === "string" && name.trim()) {
377
+ const found = await deps.db.query({
378
+ crm_record: { $: { where: { type: record.type, name: name.trim() }, limit: 1 } }
379
+ });
380
+ if (found.crm_record?.[0]) return found.crm_record[0];
381
+ }
382
+ return void 0;
383
+ }
384
+ async function projectSharedRecord(deps, shared) {
385
+ const record = normalizeSharedRecord(shared);
386
+ if (!record.type.trim() || !record.hubRecordId.trim()) {
387
+ throw new Error("type and hubRecordId must be non-empty");
388
+ }
389
+ const tag = networkSourceTag(record.type, record.hubRecordId);
390
+ const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
391
+ const existing = await findSharedRecord(deps, record, tag);
392
+ let recordId;
393
+ if (existing && typeof existing.id === "string") {
394
+ await (0, import_crm.updateRecord)(crmDeps3, { id: existing.id, input: record.input });
395
+ recordId = existing.id;
396
+ } else {
397
+ recordId = `network_${shortHash(`${record.type}\0${record.hubRecordId}`)}`;
398
+ await (0, import_crm.createRecord)({ ...crmDeps3, newId: () => recordId }, {
399
+ type: record.type,
400
+ input: record.input,
401
+ mutationId: `share-create:${tag}`
402
+ });
403
+ }
404
+ await deps.db.transact(
405
+ [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
406
+ { mutationId: `share-map:${tag}:${recordId}` }
407
+ );
408
+ return { recordId };
302
409
  }
303
410
  async function projectApplicant(deps, applicant) {
304
411
  const base = sharedPersonInput({
@@ -676,20 +783,35 @@ var handleNetworkShared = async (req, url, env, ctx) => {
676
783
  if (!secret || provided.length !== secret.length || provided !== secret) {
677
784
  return json({ error: "unauthorized" }, 401);
678
785
  }
679
- let person;
786
+ let payload;
680
787
  try {
681
- person = JSON.parse(await req.text());
788
+ payload = JSON.parse(await req.text());
682
789
  } catch {
683
790
  return json({ error: "invalid JSON body" }, 400);
684
791
  }
685
- if (typeof person.email !== "string" || typeof person.hubRecordId !== "string") {
686
- return json({ error: "email and hubRecordId are required" }, 400);
792
+ if (typeof payload.hubRecordId !== "string" || !payload.hubRecordId.trim()) {
793
+ return json({ error: "hubRecordId is required" }, 400);
794
+ }
795
+ if ("input" in payload) {
796
+ if (payload.version !== 1 || typeof payload.type !== "string" || !payload.type.trim() || !payload.input || typeof payload.input !== "object" || Array.isArray(payload.input)) {
797
+ return json({ error: "version 1, type, and input are required" }, 400);
798
+ }
799
+ } else if (payload.type !== "company" && typeof payload.email !== "string") {
800
+ return json({ error: "legacy person shares require email" }, 400);
801
+ } else if (payload.type === "company" && typeof payload.name !== "string") {
802
+ return json({ error: "business shares require name" }, 400);
803
+ }
804
+ try {
805
+ const record = normalizeSharedRecord(payload);
806
+ const { recordId } = await projectSharedRecord(
807
+ { crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
808
+ record
809
+ );
810
+ return json({ recordId, type: record.type });
811
+ } catch (err) {
812
+ const message = err instanceof Error ? err.message : "invalid shared record";
813
+ return json({ error: message }, 400);
687
814
  }
688
- const { recordId } = await projectSharedRecord(
689
- { crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
690
- person
691
- );
692
- return json({ recordId });
693
815
  };
694
816
  var handleMember = async (req, url, env, ctx) => {
695
817
  const chapter = ctx.chapter;
@@ -1522,16 +1644,16 @@ var crmDeps = (db, ctx) => ({
1522
1644
  });
1523
1645
  var handleAdminCrmSync = async (req, url, env, ctx) => {
1524
1646
  if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
1525
- const gate4 = await adminGate(req, env, ctx);
1526
- if (gate4 instanceof Response) return gate4;
1527
- const result = await backfillCrm(crmDeps(gate4.db, ctx));
1647
+ const gate5 = await adminGate(req, env, ctx);
1648
+ if (gate5 instanceof Response) return gate5;
1649
+ const result = await backfillCrm(crmDeps(gate5.db, ctx));
1528
1650
  return json({ ok: true, ...result });
1529
1651
  };
1530
1652
  var handleAdminPeople = async (req, url, env, ctx) => {
1531
1653
  if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
1532
- const gate4 = await adminGate(req, env, ctx);
1533
- if (gate4 instanceof Response) return gate4;
1534
- const { db } = gate4;
1654
+ const gate5 = await adminGate(req, env, ctx);
1655
+ if (gate5 instanceof Response) return gate5;
1656
+ const { db } = gate5;
1535
1657
  const sk = await getVaultSecret(db, "clerk_secret_key");
1536
1658
  const [appsRes, usersRes, roleList] = await Promise.all([
1537
1659
  db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
@@ -1571,9 +1693,9 @@ var handleAdminPeople = async (req, url, env, ctx) => {
1571
1693
  };
1572
1694
  var handleAdminPeopleAccess = async (req, url, env, ctx) => {
1573
1695
  if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
1574
- const gate4 = await adminGate(req, env, ctx);
1575
- if (gate4 instanceof Response) return gate4;
1576
- const { db } = gate4;
1696
+ const gate5 = await adminGate(req, env, ctx);
1697
+ if (gate5 instanceof Response) return gate5;
1698
+ const { db } = gate5;
1577
1699
  const targetId = url.searchParams.get("userId") ?? "";
1578
1700
  if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
1579
1701
  const sk = await getVaultSecret(db, "clerk_secret_key");
@@ -1584,9 +1706,9 @@ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
1584
1706
  };
1585
1707
  var handleAdminPeopleRole = async (req, url, env, ctx) => {
1586
1708
  if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
1587
- const gate4 = await adminGate(req, env, ctx);
1588
- if (gate4 instanceof Response) return gate4;
1589
- const { db, actor } = gate4;
1709
+ const gate5 = await adminGate(req, env, ctx);
1710
+ if (gate5 instanceof Response) return gate5;
1711
+ const { db, actor } = gate5;
1590
1712
  let body;
1591
1713
  try {
1592
1714
  body = await req.json();
@@ -2125,6 +2247,82 @@ var handleAdminComms = async (req, url, env, ctx) => {
2125
2247
  return json({ items });
2126
2248
  };
2127
2249
 
2250
+ // src/worker-routes-network.ts
2251
+ var import_crm4 = require("@odla-ai/crm");
2252
+ async function gate4(req, env, ctx) {
2253
+ const db = ctx.makeDb(env);
2254
+ const user = await ctx.verifyUser(req, env);
2255
+ if (!user) return { response: json({ error: "unauthorized" }, 401) };
2256
+ if (!await ctx.isAdmin(db, user)) return { response: json({ error: "forbidden" }, 403) };
2257
+ return { db, user };
2258
+ }
2259
+ var handleAdminNetworkTargets = async (req, url, env, ctx) => {
2260
+ if (req.method !== "GET" || url.pathname !== "/api/admin/network/targets") return null;
2261
+ const got = await gate4(req, env, ctx);
2262
+ if ("response" in got) return got.response;
2263
+ return json({
2264
+ targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields }) => ({
2265
+ id,
2266
+ name,
2267
+ url: targetUrl,
2268
+ types: Object.keys(fields ?? DEFAULT_SHARE_FIELDS)
2269
+ }))
2270
+ });
2271
+ };
2272
+ async function pushOne(db, ctx, target, record) {
2273
+ const secret = await getVaultSecret(db, target.secretName);
2274
+ if (!secret) return { id: target.id, name: target.name, ok: false, error: `vault secret "${target.secretName}" is missing` };
2275
+ let payload;
2276
+ try {
2277
+ payload = sharedRecordFromCrm(ctx.chapter.crm, record, target);
2278
+ } catch (err) {
2279
+ return { id: target.id, name: target.name, ok: false, error: err instanceof Error ? err.message : "record is not shareable" };
2280
+ }
2281
+ try {
2282
+ const res = await fetch(new URL("/api/network/shared", target.url), {
2283
+ method: "POST",
2284
+ headers: { authorization: `Bearer ${secret}`, "content-type": "application/json" },
2285
+ body: JSON.stringify(payload),
2286
+ signal: AbortSignal.timeout(1e4)
2287
+ });
2288
+ const body = await res.json().catch(() => ({}));
2289
+ if (!res.ok) {
2290
+ return { id: target.id, name: target.name, ok: false, status: res.status, error: body.error ?? "follower rejected the record" };
2291
+ }
2292
+ await (0, import_crm4.addTag)(
2293
+ { crm: ctx.chapter.crm, db },
2294
+ { recordId: record.id, tag: `shared:${target.id}`, mutationId: `network-delivered:${record.id}:${target.id}` }
2295
+ );
2296
+ return { id: target.id, name: target.name, ok: true, status: res.status, recordId: body.recordId };
2297
+ } catch (err) {
2298
+ return { id: target.id, name: target.name, ok: false, error: err instanceof Error ? err.message : "delivery failed" };
2299
+ }
2300
+ }
2301
+ var handleAdminNetworkPush = async (req, url, env, ctx) => {
2302
+ if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
2303
+ const got = await gate4(req, env, ctx);
2304
+ if ("response" in got) return got.response;
2305
+ let body;
2306
+ try {
2307
+ body = await req.json();
2308
+ } catch {
2309
+ return json({ error: "invalid JSON body" }, 400);
2310
+ }
2311
+ if (typeof body.recordId !== "string" || !Array.isArray(body.targetIds) || body.targetIds.length === 0) {
2312
+ return json({ error: "recordId and a non-empty targetIds array are required" }, 400);
2313
+ }
2314
+ const requested = new Set(body.targetIds.filter((id) => typeof id === "string"));
2315
+ if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
2316
+ const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
2317
+ if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
2318
+ const record = await (0, import_crm4.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
2319
+ if (!record) return json({ error: "record not found" }, 404);
2320
+ const results = await Promise.all(
2321
+ targets.map((target) => pushOne(got.db, ctx, target, record))
2322
+ );
2323
+ return json({ ok: results.every((result) => result.ok), results });
2324
+ };
2325
+
2128
2326
  // src/worker.ts
2129
2327
  var BUILTIN_ROUTES = [
2130
2328
  handleHealth,
@@ -2155,7 +2353,10 @@ var BUILTIN_ROUTES = [
2155
2353
  handleAdminGroupEmail,
2156
2354
  handleAdminEmailLog,
2157
2355
  handleAdminEmailTest,
2158
- handleAdminComms
2356
+ handleAdminComms,
2357
+ // Leader → follower record delivery
2358
+ handleAdminNetworkTargets,
2359
+ handleAdminNetworkPush
2159
2360
  ];
2160
2361
  function chapterWorker(options) {
2161
2362
  const ctx = createWorkerContext(options);