@odla-ai/chapter 0.25.6 → 0.26.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.
@@ -138,6 +138,48 @@ function createWorkerContext(options) {
138
138
  // src/worker-routes.ts
139
139
  var import_crm2 = require("@odla-ai/crm");
140
140
 
141
+ // src/discussion-reference-security.ts
142
+ async function authorizeCrmDiscussionReference(req, env, fetcher = fetch) {
143
+ const match = req.headers.get("authorization")?.match(/^Bearer ([^\s]+)$/);
144
+ const appIncarnation = env.ODLA_APP_INCARNATION;
145
+ const expectedTenant = env.ODLA_ENV === "prod" ? env.ODLA_APP_ID : `${env.ODLA_APP_ID}--${env.ODLA_ENV}`;
146
+ if (!match || env.ODLA_TENANT !== expectedTenant || typeof appIncarnation !== "string" || !/^[a-f0-9]{32}$/.test(appIncarnation)) return null;
147
+ const requestUrl = new URL(req.url);
148
+ requestUrl.search = "";
149
+ requestUrl.hash = "";
150
+ const audience = requestUrl.href;
151
+ try {
152
+ const url = new URL(
153
+ "/registry/discuss/reference-authority/verify",
154
+ env.ODLA_PLATFORM
155
+ );
156
+ const response = await fetcher(url, {
157
+ method: "POST",
158
+ headers: { "content-type": "application/json" },
159
+ body: JSON.stringify({
160
+ token: match[1],
161
+ appId: env.ODLA_APP_ID,
162
+ appIncarnation,
163
+ env: env.ODLA_ENV,
164
+ product: "crm",
165
+ projectCapability: "crm.read",
166
+ audience
167
+ }),
168
+ redirect: "manual"
169
+ });
170
+ if (!response.ok) return null;
171
+ const value = await response.json();
172
+ if (value.authority?.appId !== env.ODLA_APP_ID || value.authority.appIncarnation !== appIncarnation || value.authority.appEnv !== env.ODLA_ENV || value.authority.capability !== "product.reference.read" || value.authority.product !== "crm" || value.authority.projectCapability !== "crm.read" || value.authority.audience !== audience || typeof value.actor?.id !== "string" || value.actor.kind !== "human" && value.actor.kind !== "agent" || value.actor.email !== void 0 && typeof value.actor.email !== "string") return null;
173
+ return {
174
+ userId: value.actor.id,
175
+ discussionReferenceScope: "all",
176
+ ...value.actor.email ? { email: value.actor.email } : {}
177
+ };
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
141
183
  // src/application-id.ts
142
184
  var APPLICATION_ID_DOMAIN = "odla-ai/chapter/application/v1:";
143
185
  async function applicationIdForSubmission(submissionId) {
@@ -221,160 +263,6 @@ function joinConfig(group, paymentsReady) {
221
263
  };
222
264
  }
223
265
 
224
- // src/network.ts
225
- var import_crm = require("@odla-ai/crm");
226
- var DEFAULT_SHARE_FIELDS = {
227
- person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
228
- company: ["name", "domain", "industry", "location", "linkedin", "notes"]
229
- };
230
- function sharedPersonInput(person) {
231
- const email = person.email.toLowerCase();
232
- const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
233
- const input = { name: person.name ?? fullName ?? email, email };
234
- if (input.name === "") input.name = email;
235
- if (person.firstName) input.firstName = person.firstName;
236
- if (person.lastName) input.lastName = person.lastName;
237
- if (person.phone) input.phone = person.phone;
238
- if (person.linkedin) input.linkedin = person.linkedin;
239
- return input;
240
- }
241
- function shortHash(value) {
242
- let a = 2166136261;
243
- let b = 2654435769;
244
- for (let i = 0; i < value.length; i += 1) {
245
- const n = value.charCodeAt(i);
246
- a = Math.imul(a ^ n, 16777619);
247
- b = Math.imul(b ^ n, 2246822507);
248
- }
249
- return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
250
- }
251
- function networkSourceTag(type, hubRecordId) {
252
- const typeKey = type.toLowerCase();
253
- const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
254
- const raw = `network:${typeKey}:${hubRecordId}`;
255
- if (readable && raw.length <= 64) return raw;
256
- return `network:${typeKey.slice(0, 20)}:${shortHash(`${type}\0${hubRecordId}`)}`;
257
- }
258
- function normalizeSharedRecord(record) {
259
- if ("input" in record) return { version: 1, type: record.type, hubRecordId: record.hubRecordId, input: record.input };
260
- if ("type" in record && record.type === "company") {
261
- const input = { name: record.name };
262
- for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
263
- if (record[key]) input[key] = record[key];
264
- }
265
- return { version: 1, type: "company", hubRecordId: record.hubRecordId, input };
266
- }
267
- return { version: 1, type: "person", hubRecordId: record.hubRecordId, input: sharedPersonInput(record) };
268
- }
269
- function sharedRecordFromCrm(crm, record, target) {
270
- if (target.fields && !target.fields[record.type]) {
271
- throw new Error(`${target.name} does not accept "${record.type}" records`);
272
- }
273
- const def = crm.type(record.type);
274
- const fields = target.fields?.[record.type] ?? DEFAULT_SHARE_FIELDS[record.type];
275
- if (!fields) {
276
- throw new Error(`${target.name} requires an explicit field allowlist for "${record.type}" records`);
277
- }
278
- const nameField = def.nameField ?? "name";
279
- const input = {};
280
- for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
281
- const value = record.fields?.[field];
282
- if (value !== void 0) input[field] = value;
283
- }
284
- if (input[nameField] === void 0) input[nameField] = record.name;
285
- return { version: 1, type: record.type, hubRecordId: record.id, input };
286
- }
287
- async function upsertPerson(deps, opts) {
288
- const email = opts.email.toLowerCase();
289
- const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
290
- const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
291
- const existing = crm_record?.[0];
292
- if (existing && typeof existing.id === "string") {
293
- await (0, import_crm.updateRecord)(crmDeps3, { id: existing.id, input: opts.input });
294
- return { recordId: existing.id };
295
- }
296
- const created = await (0, import_crm.createRecord)(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
297
- return { recordId: created.id };
298
- }
299
- async function findSharedRecord(deps, record, tag) {
300
- const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
301
- const mappedId = mapped.crm_tag?.[0]?.recordId;
302
- if (typeof mappedId === "string") {
303
- const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
304
- if (found.crm_record?.[0]) return found.crm_record[0];
305
- }
306
- const def = deps.crm.type(record.type);
307
- const emailField = def.emailField;
308
- if (emailField && typeof record.input[emailField] === "string") {
309
- const primaryEmail = record.input[emailField].toLowerCase();
310
- const found = await deps.db.query({
311
- crm_record: { $: { where: { type: record.type, primaryEmail }, limit: 1 } }
312
- });
313
- if (found.crm_record?.[0]) return found.crm_record[0];
314
- }
315
- const domain = record.input.domain;
316
- const domainSlot = def.fields.domain?.slot;
317
- if (typeof domain === "string" && domainSlot) {
318
- const found = await deps.db.query({
319
- crm_record: { $: { where: { type: record.type, [domainSlot]: domain }, limit: 1 } }
320
- });
321
- if (found.crm_record?.[0]) return found.crm_record[0];
322
- }
323
- const nameField = def.nameField ?? "name";
324
- const name = record.input[nameField];
325
- if (record.type === "company" && typeof name === "string" && name.trim()) {
326
- const found = await deps.db.query({
327
- crm_record: { $: { where: { type: record.type, name: name.trim() }, limit: 1 } }
328
- });
329
- if (found.crm_record?.[0]) return found.crm_record[0];
330
- }
331
- return void 0;
332
- }
333
- async function projectSharedRecord(deps, shared) {
334
- const record = normalizeSharedRecord(shared);
335
- if (!record.type.trim() || !record.hubRecordId.trim()) {
336
- throw new Error("type and hubRecordId must be non-empty");
337
- }
338
- const tag = networkSourceTag(record.type, record.hubRecordId);
339
- const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
340
- const existing = await findSharedRecord(deps, record, tag);
341
- let recordId;
342
- if (existing && typeof existing.id === "string") {
343
- await (0, import_crm.updateRecord)(crmDeps3, { id: existing.id, input: record.input });
344
- recordId = existing.id;
345
- } else {
346
- recordId = `network_${shortHash(`${record.type}\0${record.hubRecordId}`)}`;
347
- await (0, import_crm.createRecord)({ ...crmDeps3, newId: () => recordId }, {
348
- type: record.type,
349
- input: record.input,
350
- mutationId: `share-create:${tag}`
351
- });
352
- }
353
- await deps.db.transact(
354
- [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
355
- { mutationId: `share-map:${tag}:${recordId}` }
356
- );
357
- return { recordId };
358
- }
359
- async function projectApplicant(deps, applicant) {
360
- const base = sharedPersonInput({
361
- email: applicant.email,
362
- firstName: applicant.firstName,
363
- lastName: applicant.lastName,
364
- phone: applicant.phone,
365
- linkedin: applicant.linkedin,
366
- hubRecordId: applicant.applicationId
367
- });
368
- const mutationId = `apply:${applicant.applicationId}`;
369
- const extra = applicant.extra ?? {};
370
- if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
371
- try {
372
- return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
373
- } catch {
374
- return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
375
- }
376
- }
377
-
378
266
  // src/scheduling.ts
379
267
  var SCHEDULING_DEFAULTS = {
380
268
  slotMinutes: 45,
@@ -603,6 +491,210 @@ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
603
491
  return { ...healed, refreshed };
604
492
  }
605
493
 
494
+ // src/network.ts
495
+ var import_crm = require("@odla-ai/crm");
496
+
497
+ // src/network-contract.ts
498
+ var DEFAULT_SHARE_FIELDS = {
499
+ person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
500
+ company: ["name", "domain", "industry", "location", "linkedin", "notes"]
501
+ };
502
+
503
+ // src/network.ts
504
+ function sharedPersonInput(person) {
505
+ const email = person.email.toLowerCase();
506
+ const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
507
+ const input = { name: person.name ?? fullName ?? email, email };
508
+ if (input.name === "") input.name = email;
509
+ if (person.firstName) input.firstName = person.firstName;
510
+ if (person.lastName) input.lastName = person.lastName;
511
+ if (person.phone) input.phone = person.phone;
512
+ if (person.linkedin) input.linkedin = person.linkedin;
513
+ return input;
514
+ }
515
+ function shortHash(value) {
516
+ let a = 2166136261;
517
+ let b = 2654435769;
518
+ for (let i = 0; i < value.length; i += 1) {
519
+ const n = value.charCodeAt(i);
520
+ a = Math.imul(a ^ n, 16777619);
521
+ b = Math.imul(b ^ n, 2246822507);
522
+ }
523
+ return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
524
+ }
525
+ function networkSourceTag(type, hubRecordId, sourceId) {
526
+ const typeKey = type.toLowerCase();
527
+ const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
528
+ const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
529
+ const raw = `${prefix}:${hubRecordId}`;
530
+ if (readable && raw.length <= 64) return raw;
531
+ return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
532
+ }
533
+ function normalizeSharedRecord(record, fallbackSourceId = "legacy-source") {
534
+ if ("input" in record && record.version === 2) {
535
+ return {
536
+ version: 2,
537
+ sourceId: record.source.siteId,
538
+ sourceRecordId: record.source.recordId,
539
+ type: record.type,
540
+ input: record.input
541
+ };
542
+ }
543
+ if ("input" in record) {
544
+ return {
545
+ version: 1,
546
+ sourceId: fallbackSourceId,
547
+ sourceRecordId: record.hubRecordId,
548
+ type: record.type,
549
+ input: record.input
550
+ };
551
+ }
552
+ if ("type" in record && record.type === "company") {
553
+ const input = { name: record.name };
554
+ for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
555
+ if (record[key]) input[key] = record[key];
556
+ }
557
+ return {
558
+ version: 1,
559
+ sourceId: fallbackSourceId,
560
+ sourceRecordId: record.hubRecordId,
561
+ type: "company",
562
+ input
563
+ };
564
+ }
565
+ return {
566
+ version: 1,
567
+ sourceId: fallbackSourceId,
568
+ sourceRecordId: record.hubRecordId,
569
+ type: "person",
570
+ input: sharedPersonInput(record)
571
+ };
572
+ }
573
+ function sharedRecordFromCrm(crm, record, target, sourceId) {
574
+ if (target.fields && !target.fields[record.type]) {
575
+ throw new Error(`${target.name} does not accept "${record.type}" records`);
576
+ }
577
+ const def = crm.type(record.type);
578
+ const fields = target.fields?.[record.type] ?? DEFAULT_SHARE_FIELDS[record.type];
579
+ if (!fields) {
580
+ throw new Error(`${target.name} requires an explicit field allowlist for "${record.type}" records`);
581
+ }
582
+ const nameField = def.nameField ?? "name";
583
+ const input = {};
584
+ for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
585
+ const value = record.fields?.[field];
586
+ if (value !== void 0) input[field] = value;
587
+ }
588
+ if (input[nameField] === void 0) input[nameField] = record.name;
589
+ return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record.id }, type: record.type, input } : { version: 1, type: record.type, hubRecordId: record.id, input };
590
+ }
591
+ async function upsertPerson(deps, opts) {
592
+ const email = opts.email.toLowerCase();
593
+ const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
594
+ const { crm_record } = await deps.db.query({ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } } });
595
+ const existing = crm_record?.[0];
596
+ if (existing && typeof existing.id === "string") {
597
+ await (0, import_crm.updateRecord)(crmDeps3, { id: existing.id, input: opts.input });
598
+ return { recordId: existing.id };
599
+ }
600
+ const created = await (0, import_crm.createRecord)(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
601
+ return { recordId: created.id };
602
+ }
603
+ async function findSharedRecord(deps, record, tag) {
604
+ const structured = await (0, import_crm.getRecordByOrigin)(
605
+ { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
606
+ record.sourceId,
607
+ record.sourceRecordId
608
+ );
609
+ if (structured) return structured;
610
+ const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
611
+ const mappedId = mapped.crm_tag?.[0]?.recordId;
612
+ if (typeof mappedId === "string") {
613
+ const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
614
+ if (found.crm_record?.[0]) return found.crm_record[0];
615
+ }
616
+ const def = deps.crm.type(record.type);
617
+ const emailField = def.emailField;
618
+ if (emailField && typeof record.input[emailField] === "string") {
619
+ const primaryEmail = record.input[emailField].toLowerCase();
620
+ const found = await deps.db.query({
621
+ crm_record: { $: { where: { type: record.type, primaryEmail }, limit: 1 } }
622
+ });
623
+ if (found.crm_record?.[0]) return found.crm_record[0];
624
+ }
625
+ const domain = record.input.domain;
626
+ const domainSlot = def.fields.domain?.slot;
627
+ if (typeof domain === "string" && domainSlot) {
628
+ const found = await deps.db.query({
629
+ crm_record: { $: { where: { type: record.type, [domainSlot]: domain }, limit: 1 } }
630
+ });
631
+ if (found.crm_record?.[0]) return found.crm_record[0];
632
+ }
633
+ const nameField = def.nameField ?? "name";
634
+ const name = record.input[nameField];
635
+ if (record.type === "company" && typeof name === "string" && name.trim()) {
636
+ const found = await deps.db.query({
637
+ crm_record: { $: { where: { type: record.type, name: name.trim() }, limit: 1 } }
638
+ });
639
+ if (found.crm_record?.[0]) return found.crm_record[0];
640
+ }
641
+ return void 0;
642
+ }
643
+ async function projectSharedRecord(deps, shared, options = {}) {
644
+ const record = normalizeSharedRecord(shared, options.sourceId);
645
+ if (!record.type.trim() || !record.sourceId.trim() || !record.sourceRecordId.trim()) {
646
+ throw new Error("type, source site id, and source record id must be non-empty");
647
+ }
648
+ if (options.sourceId && options.sourceId !== record.sourceId) {
649
+ throw new Error("signed sender does not match payload source");
650
+ }
651
+ const tag = networkSourceTag(record.type, record.sourceRecordId, record.version === 2 ? record.sourceId : void 0);
652
+ const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
653
+ const existing = await findSharedRecord(deps, record, tag);
654
+ let recordId;
655
+ if (existing && typeof existing.id === "string") {
656
+ await (0, import_crm.updateRecord)(crmDeps3, { id: existing.id, input: record.input });
657
+ recordId = existing.id;
658
+ } else {
659
+ recordId = `network_${shortHash(`${record.sourceId}\0${record.type}\0${record.sourceRecordId}`)}`;
660
+ await (0, import_crm.createRecord)({ ...crmDeps3, newId: () => recordId }, {
661
+ type: record.type,
662
+ input: record.input,
663
+ mutationId: `share-create:${tag}`
664
+ });
665
+ }
666
+ await deps.db.transact(
667
+ [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
668
+ { mutationId: `share-map:${tag}:${recordId}` }
669
+ );
670
+ await (0, import_crm.upsertRecordOrigin)(crmDeps3, {
671
+ recordId,
672
+ sourceId: record.sourceId,
673
+ sourceRecordId: record.sourceRecordId,
674
+ ...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
675
+ payloadVersion: record.version
676
+ });
677
+ return { recordId };
678
+ }
679
+ async function projectApplicant(deps, applicant) {
680
+ const base = sharedPersonInput({
681
+ email: applicant.email,
682
+ firstName: applicant.firstName,
683
+ lastName: applicant.lastName,
684
+ phone: applicant.phone,
685
+ linkedin: applicant.linkedin,
686
+ hubRecordId: applicant.applicationId
687
+ });
688
+ const mutationId = `apply:${applicant.applicationId}`;
689
+ const extra = applicant.extra ?? {};
690
+ if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
691
+ try {
692
+ return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
693
+ } catch {
694
+ return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
695
+ }
696
+ }
697
+
606
698
  // src/email.ts
607
699
  function render(template, vars) {
608
700
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
@@ -898,6 +990,7 @@ var handleCrm = async (req, url, env, ctx) => {
898
990
  if (!u || !await ctx.isAdmin(db, u)) return null;
899
991
  return u.email ? { userId: u.userId, email: u.email } : { userId: u.userId };
900
992
  },
993
+ authorizeDiscussionReferences: (r) => authorizeCrmDiscussionReference(r, env),
901
994
  sender: ctx.crmSender(env),
902
995
  from: env.EMAIL_FROM,
903
996
  envName: env.ODLA_ENV,
@@ -908,44 +1001,6 @@ var handleCrm = async (req, url, env, ctx) => {
908
1001
  if (res) return res;
909
1002
  return json({ error: "not found" }, 404);
910
1003
  };
911
- var handleNetworkShared = async (req, url, env, ctx) => {
912
- if (req.method !== "POST" || url.pathname !== "/api/network/shared") return null;
913
- const db = ctx.makeDb(env);
914
- const secret = await getVaultSecret(db, "network_share_secret");
915
- const provided = (req.headers.get("authorization") ?? "").replace(/^Bearer /, "");
916
- if (!secret || provided.length !== secret.length || provided !== secret) {
917
- return json({ error: "unauthorized" }, 401);
918
- }
919
- let payload;
920
- try {
921
- payload = JSON.parse(await req.text());
922
- } catch {
923
- return json({ error: "invalid JSON body" }, 400);
924
- }
925
- if (typeof payload.hubRecordId !== "string" || !payload.hubRecordId.trim()) {
926
- return json({ error: "hubRecordId is required" }, 400);
927
- }
928
- if ("input" in payload) {
929
- if (payload.version !== 1 || typeof payload.type !== "string" || !payload.type.trim() || !payload.input || typeof payload.input !== "object" || Array.isArray(payload.input)) {
930
- return json({ error: "version 1, type, and input are required" }, 400);
931
- }
932
- } else if (payload.type !== "company" && typeof payload.email !== "string") {
933
- return json({ error: "legacy person shares require email" }, 400);
934
- } else if (payload.type === "company" && typeof payload.name !== "string") {
935
- return json({ error: "business shares require name" }, 400);
936
- }
937
- try {
938
- const record = normalizeSharedRecord(payload);
939
- const { recordId } = await projectSharedRecord(
940
- { crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
941
- record
942
- );
943
- return json({ recordId, type: record.type });
944
- } catch (err) {
945
- const message = err instanceof Error ? err.message : "invalid shared record";
946
- return json({ error: message }, 400);
947
- }
948
- };
949
1004
  var handleMember = async (req, url, env, ctx) => {
950
1005
  const chapter = ctx.chapter;
951
1006
  if (chapter.mode !== "chapter") return null;
@@ -1042,6 +1097,62 @@ var handleMember = async (req, url, env, ctx) => {
1042
1097
  return null;
1043
1098
  };
1044
1099
 
1100
+ // src/worker-routes-network-receive.ts
1101
+ var import_db2 = require("@odla-ai/db");
1102
+ var handleNetworkShared = async (req, url, env, ctx) => {
1103
+ if (req.method !== "POST" || url.pathname !== "/api/network/shared") return null;
1104
+ const db = ctx.makeDb(env);
1105
+ const secret = await getVaultSecret(db, "network_share_secret");
1106
+ if (!secret) return json({ error: "unauthorized" }, 401);
1107
+ let raw;
1108
+ let sourceId;
1109
+ if (req.headers.has(import_db2.FEDERATION_HEADERS.version)) {
1110
+ const verified = await (0, import_db2.verifyFederatedRequest)(req, { secret });
1111
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
1112
+ raw = verified.body;
1113
+ sourceId = verified.sender;
1114
+ } else {
1115
+ const provided = (req.headers.get("authorization") ?? "").replace(/^Bearer /, "");
1116
+ if (provided.length !== secret.length || provided !== secret) {
1117
+ return json({ error: "unauthorized" }, 401);
1118
+ }
1119
+ raw = await req.text();
1120
+ }
1121
+ let payload;
1122
+ try {
1123
+ payload = JSON.parse(raw);
1124
+ } catch {
1125
+ return json({ error: "invalid JSON body" }, 400);
1126
+ }
1127
+ if ("input" in payload) {
1128
+ const v1 = payload.version === 1 && typeof payload.hubRecordId === "string" && payload.hubRecordId.trim();
1129
+ const source = payload.source;
1130
+ const v2 = payload.version === 2 && source && typeof source.siteId === "string" && source.siteId.trim() && typeof source.recordId === "string" && source.recordId.trim();
1131
+ if (!v1 && !v2 || typeof payload.type !== "string" || !payload.type.trim() || !payload.input || typeof payload.input !== "object" || Array.isArray(payload.input)) {
1132
+ return json({ error: "a supported version, source record, type, and input are required" }, 400);
1133
+ }
1134
+ } else if (typeof payload.hubRecordId !== "string" || !payload.hubRecordId.trim()) {
1135
+ return json({ error: "hubRecordId is required" }, 400);
1136
+ } else if (payload.type !== "company" && typeof payload.email !== "string") {
1137
+ return json({ error: "legacy person shares require email" }, 400);
1138
+ } else if (payload.type === "company" && typeof payload.name !== "string") {
1139
+ return json({ error: "business shares require name" }, 400);
1140
+ }
1141
+ try {
1142
+ const { recordId } = await projectSharedRecord(
1143
+ { crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
1144
+ payload,
1145
+ sourceId ? { sourceId } : {}
1146
+ );
1147
+ const record = normalizeSharedRecord(payload, sourceId);
1148
+ return json({ recordId, type: record.type });
1149
+ } catch (error) {
1150
+ return json({
1151
+ error: error instanceof Error ? error.message : "invalid shared record"
1152
+ }, 400);
1153
+ }
1154
+ };
1155
+
1045
1156
  // src/crm-sync.ts
1046
1157
  var import_crm3 = require("@odla-ai/crm");
1047
1158
  var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
@@ -1197,7 +1308,11 @@ async function bookSlot(req, env, ctx) {
1197
1308
  let meetingOp;
1198
1309
  try {
1199
1310
  if (decision.reschedule && decision.eventId) {
1200
- await cal.actions.reschedule(decision.eventId, { startAt, endAt });
1311
+ await cal.actions.reschedule(decision.eventId, {
1312
+ idempotencyKey: `meeting:${String(existing?.id)}:reschedule:${startAt}:${endAt}`,
1313
+ startAt,
1314
+ endAt
1315
+ });
1201
1316
  meetUrl = existing?.meetUrl ?? null;
1202
1317
  htmlLink = existing?.htmlLink ?? null;
1203
1318
  meetingOp = { t: "update", ns: "meetings", id: String(existing?.id), attrs: meetingRescheduleUpdate(startAt, endAt) };
@@ -2227,7 +2342,11 @@ var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
2227
2342
  minNoticeMs: cfg.minNoticeHours * 36e5
2228
2343
  });
2229
2344
  if (!isSlotAvailable(slots, startAt)) return json({ error: "slot no longer available", code: "calendar_slot_unavailable" }, 409);
2230
- await cal.actions.reschedule(String(meeting.googleEventId), { startAt, endAt });
2345
+ await cal.actions.reschedule(String(meeting.googleEventId), {
2346
+ idempotencyKey: `meeting:${String(meeting.id)}:reschedule:${startAt}:${endAt}`,
2347
+ startAt,
2348
+ endAt
2349
+ });
2231
2350
  } catch {
2232
2351
  return json({ error: "reschedule failed upstream" }, 502);
2233
2352
  }
@@ -2247,7 +2366,9 @@ var handleAdminMeetingCancel = async (req, url, env, ctx) => {
2247
2366
  if (meeting.status !== "scheduled") return json({ error: "already cancelled" }, 409);
2248
2367
  if (meeting.googleEventId) {
2249
2368
  try {
2250
- await calFor(env).actions.cancel(String(meeting.googleEventId));
2369
+ await calFor(env).actions.cancel(String(meeting.googleEventId), {
2370
+ idempotencyKey: `meeting:${String(meeting.id)}:cancel`
2371
+ });
2251
2372
  } catch {
2252
2373
  return json({ error: "cancel failed upstream" }, 502);
2253
2374
  }
@@ -2517,6 +2638,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
2517
2638
 
2518
2639
  // src/worker-routes-network.ts
2519
2640
  var import_crm4 = require("@odla-ai/crm");
2641
+ var import_db3 = require("@odla-ai/db");
2520
2642
  async function gate4(req, env, ctx) {
2521
2643
  const db = ctx.makeDb(env);
2522
2644
  const user = await ctx.verifyUser(req, env);
@@ -2537,33 +2659,158 @@ var handleAdminNetworkTargets = async (req, url, env, ctx) => {
2537
2659
  }))
2538
2660
  });
2539
2661
  };
2662
+ var handleNetworkSnapshot = async (req, url, env, ctx) => {
2663
+ if (req.method !== "GET" || url.pathname !== "/api/network/snapshot") return null;
2664
+ const db = ctx.makeDb(env);
2665
+ const secret = await getVaultSecret(db, "network_share_secret");
2666
+ if (!secret) return json({ error: "unauthorized" }, 401);
2667
+ const verified = await (0, import_db3.verifyFederatedRequest)(req, { secret });
2668
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
2669
+ const types = await Promise.all(
2670
+ Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm4.typeSummary)({ crm: ctx.chapter.crm, db }, type))
2671
+ );
2672
+ const snapshot = {
2673
+ version: 1,
2674
+ site: { id: ctx.chapter.id, name: ctx.chapter.name, mode: ctx.chapter.mode },
2675
+ generatedAt: Date.now(),
2676
+ types
2677
+ };
2678
+ return json(snapshot);
2679
+ };
2680
+ async function snapshotOne(db, ctx, target) {
2681
+ const secret = await getVaultSecret(db, target.secretName);
2682
+ if (!secret) return { id: target.id, name: target.name, url: target.url, available: false, error: "edge secret is missing" };
2683
+ const destination = new URL("/api/network/snapshot", target.url);
2684
+ try {
2685
+ const headers = await (0, import_db3.signFederatedRequest)({
2686
+ secret,
2687
+ sender: ctx.chapter.id,
2688
+ method: "GET",
2689
+ url: destination
2690
+ });
2691
+ const response = await fetch(destination, { headers, signal: AbortSignal.timeout(1e4) });
2692
+ const body = await response.json().catch(() => null);
2693
+ if (!response.ok || !body || !("version" in body) || body.version !== 1 || !("site" in body) || body.site.id !== target.id || !Array.isArray(body.types)) {
2694
+ const upstream = body && "error" in body && typeof body.error === "string" ? body.error : void 0;
2695
+ return {
2696
+ id: target.id,
2697
+ name: target.name,
2698
+ url: target.url,
2699
+ available: false,
2700
+ error: upstream ?? (response.ok ? "invalid follower snapshot" : `follower returned ${response.status}`)
2701
+ };
2702
+ }
2703
+ return {
2704
+ id: target.id,
2705
+ name: target.name,
2706
+ url: target.url,
2707
+ available: true,
2708
+ generatedAt: body.generatedAt,
2709
+ types: body.types
2710
+ };
2711
+ } catch (error) {
2712
+ return {
2713
+ id: target.id,
2714
+ name: target.name,
2715
+ url: target.url,
2716
+ available: false,
2717
+ error: error instanceof Error ? error.message : "snapshot failed"
2718
+ };
2719
+ }
2720
+ }
2721
+ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
2722
+ if (req.method !== "GET" || url.pathname !== "/api/admin/network/rollup") return null;
2723
+ const got = await gate4(req, env, ctx);
2724
+ if ("response" in got) return got.response;
2725
+ const targets = await Promise.all(
2726
+ ctx.chapter.network.targets.map((target) => snapshotOne(got.db, ctx, target))
2727
+ );
2728
+ const byType = /* @__PURE__ */ new Map();
2729
+ for (const target of targets) {
2730
+ for (const type of target.types ?? []) {
2731
+ const aggregate = byType.get(type.type) ?? { type: type.type, total: 0, stages: {} };
2732
+ aggregate.total += type.total;
2733
+ for (const [stage, count] of Object.entries(type.stages)) {
2734
+ aggregate.stages[stage] = (aggregate.stages[stage] ?? 0) + count;
2735
+ }
2736
+ byType.set(type.type, aggregate);
2737
+ }
2738
+ }
2739
+ const rollup = {
2740
+ configured: targets.length,
2741
+ available: targets.filter((target) => target.available).length,
2742
+ totalRecords: [...byType.values()].reduce((sum, type) => sum + type.total, 0),
2743
+ types: [...byType.values()],
2744
+ targets
2745
+ };
2746
+ return json(rollup);
2747
+ };
2540
2748
  async function pushOne(db, ctx, target, record) {
2541
2749
  const secret = await getVaultSecret(db, target.secretName);
2542
- if (!secret) return { id: target.id, name: target.name, ok: false, error: `vault secret "${target.secretName}" is missing` };
2750
+ const crmDeps3 = { crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() };
2751
+ if (!secret) {
2752
+ const error = `vault secret "${target.secretName}" is missing`;
2753
+ await (0, import_crm4.recordDeliveryAttempt)(crmDeps3, {
2754
+ recordId: record.id,
2755
+ targetId: target.id,
2756
+ status: "failed",
2757
+ payloadVersion: 2,
2758
+ error
2759
+ });
2760
+ return { id: target.id, name: target.name, ok: false, error };
2761
+ }
2543
2762
  let payload;
2544
2763
  try {
2545
- payload = sharedRecordFromCrm(ctx.chapter.crm, record, target);
2764
+ payload = sharedRecordFromCrm(ctx.chapter.crm, record, target, ctx.chapter.id);
2546
2765
  } catch (err) {
2547
2766
  return { id: target.id, name: target.name, ok: false, error: err instanceof Error ? err.message : "record is not shareable" };
2548
2767
  }
2768
+ const payloadBody = JSON.stringify(payload);
2769
+ const destination = new URL("/api/network/shared", target.url);
2549
2770
  try {
2550
- const res = await fetch(new URL("/api/network/shared", target.url), {
2771
+ const signed = await (0, import_db3.signFederatedRequest)({
2772
+ secret,
2773
+ sender: ctx.chapter.id,
2774
+ method: "POST",
2775
+ url: destination,
2776
+ body: payloadBody
2777
+ });
2778
+ const res = await fetch(destination, {
2551
2779
  method: "POST",
2552
- headers: { authorization: `Bearer ${secret}`, "content-type": "application/json" },
2553
- body: JSON.stringify(payload),
2780
+ headers: { ...signed, "content-type": "application/json" },
2781
+ body: payloadBody,
2554
2782
  signal: AbortSignal.timeout(1e4)
2555
2783
  });
2556
- const body = await res.json().catch(() => ({}));
2784
+ const responseBody = await res.json().catch(() => ({}));
2557
2785
  if (!res.ok) {
2558
- return { id: target.id, name: target.name, ok: false, status: res.status, error: body.error ?? "follower rejected the record" };
2786
+ const error = responseBody.error ?? "follower rejected the record";
2787
+ await (0, import_crm4.recordDeliveryAttempt)(crmDeps3, {
2788
+ recordId: record.id,
2789
+ targetId: target.id,
2790
+ status: "failed",
2791
+ payloadVersion: 2,
2792
+ error
2793
+ });
2794
+ return { id: target.id, name: target.name, ok: false, status: res.status, error };
2559
2795
  }
2560
- await (0, import_crm4.addTag)(
2561
- { crm: ctx.chapter.crm, db },
2562
- { recordId: record.id, tag: `shared:${target.id}`, mutationId: `network-delivered:${record.id}:${target.id}` }
2563
- );
2564
- return { id: target.id, name: target.name, ok: true, status: res.status, recordId: body.recordId };
2796
+ await (0, import_crm4.recordDeliveryAttempt)(crmDeps3, {
2797
+ recordId: record.id,
2798
+ targetId: target.id,
2799
+ status: "delivered",
2800
+ payloadVersion: 2,
2801
+ ...responseBody.recordId ? { remoteRecordId: responseBody.recordId } : {}
2802
+ });
2803
+ return { id: target.id, name: target.name, ok: true, status: res.status, recordId: responseBody.recordId };
2565
2804
  } catch (err) {
2566
- return { id: target.id, name: target.name, ok: false, error: err instanceof Error ? err.message : "delivery failed" };
2805
+ const error = err instanceof Error ? err.message : "delivery failed";
2806
+ await (0, import_crm4.recordDeliveryAttempt)(crmDeps3, {
2807
+ recordId: record.id,
2808
+ targetId: target.id,
2809
+ status: "failed",
2810
+ payloadVersion: 2,
2811
+ error
2812
+ });
2813
+ return { id: target.id, name: target.name, ok: false, error };
2567
2814
  }
2568
2815
  }
2569
2816
  var handleAdminNetworkPush = async (req, url, env, ctx) => {
@@ -2591,6 +2838,100 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
2591
2838
  return json({ ok: results.every((result) => result.ok), results });
2592
2839
  };
2593
2840
 
2841
+ // src/worker-routes-formation.ts
2842
+ var import_crm5 = require("@odla-ai/crm");
2843
+
2844
+ // src/formation.ts
2845
+ function formationFields(crm, formation) {
2846
+ const def = crm.type(formation.type);
2847
+ return [...formation.required, ...formation.optional].map((id) => {
2848
+ const field = def.fields[id];
2849
+ return {
2850
+ id,
2851
+ label: field.label ?? id,
2852
+ type: field.type,
2853
+ required: formation.required.includes(id),
2854
+ ...field.options ? { options: field.options } : {}
2855
+ };
2856
+ });
2857
+ }
2858
+
2859
+ // src/worker-routes-formation.ts
2860
+ var SUBMISSION = /^[A-Za-z0-9_-]{8,128}$/;
2861
+ var handleFormation = async (req, url, env, ctx) => {
2862
+ const formation = ctx.chapter.formation;
2863
+ if (!formation.enabled) return null;
2864
+ if (req.method === "GET" && url.pathname === "/api/formation/config") {
2865
+ return json({
2866
+ type: formation.type,
2867
+ fields: formationFields(ctx.chapter.crm, formation)
2868
+ });
2869
+ }
2870
+ if (req.method !== "POST" || url.pathname !== "/api/formation/applications") return null;
2871
+ const raw = await req.text();
2872
+ if (raw.length > formation.bodyCap) return json({ error: "request body too large" }, 413);
2873
+ let body;
2874
+ try {
2875
+ body = JSON.parse(raw);
2876
+ } catch {
2877
+ return json({ error: "invalid JSON body" }, 400);
2878
+ }
2879
+ if (!body || typeof body !== "object" || Array.isArray(body)) return json({ error: "JSON body must be an object" }, 400);
2880
+ const submissionId = typeof body.submissionId === "string" ? body.submissionId : void 0;
2881
+ if (submissionId && !SUBMISSION.test(submissionId)) {
2882
+ return json({ error: "submissionId must be an 8\u2013128 character base64url value" }, 400);
2883
+ }
2884
+ const allowed = /* @__PURE__ */ new Set([...formation.required, ...formation.optional]);
2885
+ const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !allowed.has(field));
2886
+ if (unknown.length) return json({ error: `field "${unknown[0]}" is not accepted` }, 400);
2887
+ for (const field of formation.required) {
2888
+ const value = body[field];
2889
+ if (typeof value !== "string" || value.trim() === "") return json({ error: `${field} is required` }, 400);
2890
+ }
2891
+ const input = {};
2892
+ for (const field of allowed) {
2893
+ const value = body[field];
2894
+ if (value === void 0) continue;
2895
+ const cap = formation.maxLen[field] ?? formation.defaultMaxLen;
2896
+ if (typeof value === "string") {
2897
+ if (value.length > cap) return json({ error: `${field} exceeds ${cap} characters` }, 400);
2898
+ input[field] = value.trim();
2899
+ } else if (Array.isArray(value)) {
2900
+ input[field] = value.slice(0, 100);
2901
+ } else {
2902
+ input[field] = value;
2903
+ }
2904
+ }
2905
+ const db = ctx.makeDb(env);
2906
+ const id = submissionId ? await applicationIdForSubmission(`formation:${ctx.chapter.id}:${submissionId}`) : crypto.randomUUID();
2907
+ try {
2908
+ const created = await (0, import_crm5.createRecord)(
2909
+ {
2910
+ crm: ctx.chapter.crm,
2911
+ db,
2912
+ now: () => Date.now(),
2913
+ newId: () => id
2914
+ },
2915
+ {
2916
+ type: formation.type,
2917
+ input,
2918
+ ...submissionId ? { mutationId: `formation:${ctx.chapter.id}:${submissionId}` } : {}
2919
+ }
2920
+ );
2921
+ return json({
2922
+ id,
2923
+ duplicate: created.duplicate,
2924
+ status: created.record.stage ?? null
2925
+ }, created.duplicate ? 200 : 201);
2926
+ } catch (error) {
2927
+ const detail = error && typeof error === "object" && "fields" in error ? error.fields : void 0;
2928
+ return json({
2929
+ error: error instanceof Error ? error.message : "invalid formation application",
2930
+ ...detail ? { fields: detail } : {}
2931
+ }, 400);
2932
+ }
2933
+ };
2934
+
2594
2935
  // src/worker.ts
2595
2936
  var BUILTIN_ROUTES = [
2596
2937
  handleHealth,
@@ -2598,6 +2939,8 @@ var BUILTIN_ROUTES = [
2598
2939
  handleMe,
2599
2940
  handleCrm,
2600
2941
  handleNetworkShared,
2942
+ handleNetworkSnapshot,
2943
+ handleFormation,
2601
2944
  handleMember,
2602
2945
  handleSchedule,
2603
2946
  handlePayments,
@@ -2625,6 +2968,7 @@ var BUILTIN_ROUTES = [
2625
2968
  handleAdminComms,
2626
2969
  // Leader → follower record delivery
2627
2970
  handleAdminNetworkTargets,
2971
+ handleAdminNetworkRollup,
2628
2972
  handleAdminNetworkPush,
2629
2973
  // API requests must never fall through to an SPA asset response. Hosts still
2630
2974
  // get first refusal through options.routes, then this terminates unknown API