@odla-ai/chapter 0.37.0 → 0.38.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.
@@ -167,204 +167,10 @@ async function applicationIdForSubmission(submissionId) {
167
167
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
168
168
  }
169
169
 
170
- // src/admission-grants-contract.ts
171
- var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
172
- var ADMISSION_GRANTS_PATH = "/api/admission-grants";
173
-
174
- // src/signup-control-canonical.ts
175
- var encoder = new TextEncoder();
176
- function canonicalValue(value) {
177
- if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
178
- return JSON.stringify(value);
179
- }
180
- if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
181
- if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
182
- const record3 = value;
183
- const entries = Object.keys(record3).sort().flatMap((key) => {
184
- const item = record3[key];
185
- return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
186
- });
187
- return `{${entries.join(",")}}`;
188
- }
189
- function canonicalSignupControl(value) {
190
- return canonicalValue(value);
191
- }
192
- async function signupControlDigest(document) {
193
- const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
194
- return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
195
- }
196
-
197
- // src/admission-grants.ts
198
- var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
199
- var MAX_GRANTS = 2e3;
200
- var MAX_NOTE = 2e3;
201
- var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
202
- var isAdmissionEmail = (value) => EMAIL_RE.test(value);
203
- function normalizeAdmissionEmail(email) {
170
+ // src/email-identity.ts
171
+ function normalizeChapterEmail(email) {
204
172
  return email.trim().toLowerCase();
205
173
  }
206
- function validateAdmissionGrantDocument(input) {
207
- const errors = [];
208
- const root = input && typeof input === "object" ? input : {};
209
- if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
210
- const revision = Number(root.revision);
211
- const generatedAt = Number(root.generatedAt);
212
- if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
213
- if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
214
- const source = root.source && typeof root.source === "object" ? root.source : {};
215
- const target = root.target && typeof root.target === "object" ? root.target : {};
216
- const sourceId = typeof source.appId === "string" ? source.appId : "";
217
- const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
218
- const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
219
- const environment = typeof target.environment === "string" ? target.environment : "";
220
- if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
221
- if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
222
- if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
223
- if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
224
- if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
225
- const rawGrants = Array.isArray(root.grants) ? root.grants : [];
226
- if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
227
- const grants = [];
228
- const seenIds = /* @__PURE__ */ new Set();
229
- const seenEmails = /* @__PURE__ */ new Set();
230
- for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
231
- const grant = raw && typeof raw === "object" ? raw : {};
232
- const id = typeof grant.id === "string" ? grant.id : "";
233
- const email = typeof grant.email === "string" ? grant.email.trim() : "";
234
- const normalized = normalizeAdmissionEmail(email);
235
- const decision = grant.decision;
236
- const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
237
- const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
238
- if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
239
- if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
240
- if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
241
- if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
242
- if (decision !== "waive_interview" && decision !== "require_interview") {
243
- errors.push(`document.grants[${index}].decision is invalid`);
244
- }
245
- if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
246
- if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
247
- errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
248
- }
249
- seenIds.add(id);
250
- seenEmails.add(normalized);
251
- grants.push({
252
- id,
253
- email,
254
- decision,
255
- ...note ? { note } : {},
256
- ...expiresAt !== void 0 ? { expiresAt } : {}
257
- });
258
- }
259
- if (errors.length) return { ok: false, errors };
260
- return {
261
- ok: true,
262
- value: {
263
- schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
264
- revision,
265
- generatedAt,
266
- source: { appId: sourceId, actor: sourceActor },
267
- target: { chapterId, environment },
268
- grants
269
- }
270
- };
271
- }
272
- var rows = (value) => Array.isArray(value) ? value : [];
273
- function asGrant(row) {
274
- if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
275
- if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
276
- if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
277
- return row;
278
- }
279
- async function findAdmissionGrant(db, targetId, email, now) {
280
- const emailNormalized = normalizeAdmissionEmail(email);
281
- if (!isAdmissionEmail(emailNormalized)) return null;
282
- const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
283
- const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
284
- grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
285
- )).sort((a, b) => {
286
- const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
287
- return scope || b.createdAt - a.createdAt;
288
- });
289
- return active[0] ?? null;
290
- }
291
- async function hasAdmissionRevision(db, sourceId, targetId, environment) {
292
- const where = { sourceId, targetId, environment };
293
- if (db.aggregate) {
294
- return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
295
- }
296
- const found = await db.query({ admissionGrantRevisions: {} });
297
- return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
298
- }
299
- async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
300
- if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
301
- const result = await db.query({ admissionGrantRevisions: { $: {
302
- where: { sourceId, targetId, environment },
303
- order: { revision: "desc" },
304
- limit: 1
305
- } } });
306
- const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
307
- return row ? row : null;
308
- }
309
- async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
310
- const { document, digest } = envelope;
311
- const current = rows((await db.query({ admissionGrants: { $: {
312
- where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
313
- limit: MAX_GRANTS
314
- } } })).admissionGrants);
315
- const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
316
- const ops = [];
317
- for (const row of current) {
318
- const id = String(row.id ?? "");
319
- if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
320
- active: false,
321
- revokedAt: receivedAt,
322
- revokedBy: document.source.actor,
323
- updatedAt: receivedAt,
324
- updatedBy: document.source.actor
325
- } });
326
- }
327
- for (const grant of document.grants) {
328
- const id = `admission:parent:${document.source.appId}:${grant.id}`;
329
- const existing = current.find((row) => row.id === id);
330
- ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
331
- id,
332
- sourceGrantId: grant.id,
333
- sourceScope: "parent",
334
- sourceId: document.source.appId,
335
- targetId: document.target.chapterId,
336
- email: grant.email,
337
- emailNormalized: normalizeAdmissionEmail(grant.email),
338
- decision: grant.decision,
339
- ...grant.note ? { note: grant.note } : {},
340
- ...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
341
- active: true,
342
- sourceRevision: document.revision,
343
- createdAt: Number(existing?.createdAt ?? receivedAt),
344
- createdBy: String(existing?.createdBy ?? document.source.actor),
345
- updatedAt: receivedAt,
346
- updatedBy: document.source.actor
347
- } });
348
- const retract = [
349
- ...!grant.note && existing?.note !== void 0 ? ["note"] : [],
350
- ...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
351
- ];
352
- if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
353
- }
354
- if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
355
- const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
356
- ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
357
- id: revisionId,
358
- sourceId: document.source.appId,
359
- targetId: document.target.chapterId,
360
- environment: document.target.environment,
361
- revision: document.revision,
362
- digest,
363
- active: true,
364
- receivedAt
365
- } });
366
- await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
367
- }
368
174
 
369
175
  // src/member.ts
370
176
  var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
@@ -417,9 +223,9 @@ function resolveApplication(a) {
417
223
  validateEmail: a?.validateEmail ?? true
418
224
  };
419
225
  }
420
- var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
226
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
421
227
  function isValidEmail(value) {
422
- return typeof value === "string" && EMAIL_RE2.test(value);
228
+ return typeof value === "string" && EMAIL_RE.test(value);
423
229
  }
424
230
  function clampArray(value, max) {
425
231
  if (!Array.isArray(value)) return value;
@@ -454,7 +260,7 @@ async function submitApplication(db, chapter, fields, opts) {
454
260
  const cap = app.maxLen[f] ?? app.defaultMaxLen;
455
261
  if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
456
262
  }
457
- if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
263
+ if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email.trim())) {
458
264
  return { ok: false, error: "email must be a valid email address" };
459
265
  }
460
266
  const acked = hasDisclaimerAck(fields);
@@ -470,7 +276,7 @@ async function submitApplication(db, chapter, fields, opts) {
470
276
  if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
471
277
  if (opts.groupId) row.groupId = opts.groupId;
472
278
  if (typeof fields.email === "string" && isValidEmail(fields.email.trim())) {
473
- row.emailNormalized = normalizeAdmissionEmail(fields.email);
279
+ row.emailNormalized = normalizeChapterEmail(fields.email);
474
280
  }
475
281
  if (opts.admissionGrant?.decision === "waive_interview") {
476
282
  row.admissionGrantId = opts.admissionGrant.id;
@@ -698,6 +504,281 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
698
504
  };
699
505
  }
700
506
 
507
+ // src/admission-grants-contract.ts
508
+ var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
509
+ var ADMISSION_GRANTS_PATH = "/api/admission-grants";
510
+
511
+ // src/signup-control-canonical.ts
512
+ var encoder = new TextEncoder();
513
+ function canonicalValue(value) {
514
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
515
+ return JSON.stringify(value);
516
+ }
517
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
518
+ if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
519
+ const record3 = value;
520
+ const entries = Object.keys(record3).sort().flatMap((key) => {
521
+ const item = record3[key];
522
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
523
+ });
524
+ return `{${entries.join(",")}}`;
525
+ }
526
+ function canonicalSignupControl(value) {
527
+ return canonicalValue(value);
528
+ }
529
+ async function signupControlDigest(document) {
530
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
531
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
532
+ }
533
+
534
+ // src/admission-grants.ts
535
+ var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
536
+ var MAX_GRANTS = 2e3;
537
+ var MAX_NOTE = 2e3;
538
+ var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
539
+ var isAdmissionEmail = (value) => EMAIL_RE2.test(value);
540
+ function normalizeAdmissionEmail(email) {
541
+ return normalizeChapterEmail(email);
542
+ }
543
+ function validateAdmissionGrantDocument(input) {
544
+ const errors = [];
545
+ const root = input && typeof input === "object" ? input : {};
546
+ if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
547
+ const revision = Number(root.revision);
548
+ const generatedAt = Number(root.generatedAt);
549
+ if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
550
+ if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
551
+ const source = root.source && typeof root.source === "object" ? root.source : {};
552
+ const target = root.target && typeof root.target === "object" ? root.target : {};
553
+ const sourceId = typeof source.appId === "string" ? source.appId : "";
554
+ const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
555
+ const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
556
+ const environment = typeof target.environment === "string" ? target.environment : "";
557
+ if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
558
+ if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
559
+ if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
560
+ if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
561
+ if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
562
+ const rawGrants = Array.isArray(root.grants) ? root.grants : [];
563
+ if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
564
+ const grants = [];
565
+ const seenIds = /* @__PURE__ */ new Set();
566
+ const seenEmails = /* @__PURE__ */ new Set();
567
+ for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
568
+ const grant = raw && typeof raw === "object" ? raw : {};
569
+ const id = typeof grant.id === "string" ? grant.id : "";
570
+ const email = typeof grant.email === "string" ? grant.email.trim() : "";
571
+ const normalized = normalizeAdmissionEmail(email);
572
+ const decision = grant.decision;
573
+ const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
574
+ const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
575
+ if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
576
+ if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
577
+ if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
578
+ if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
579
+ if (decision !== "waive_interview" && decision !== "require_interview") {
580
+ errors.push(`document.grants[${index}].decision is invalid`);
581
+ }
582
+ if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
583
+ if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
584
+ errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
585
+ }
586
+ seenIds.add(id);
587
+ seenEmails.add(normalized);
588
+ grants.push({
589
+ id,
590
+ email,
591
+ decision,
592
+ ...note ? { note } : {},
593
+ ...expiresAt !== void 0 ? { expiresAt } : {}
594
+ });
595
+ }
596
+ if (errors.length) return { ok: false, errors };
597
+ return {
598
+ ok: true,
599
+ value: {
600
+ schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
601
+ revision,
602
+ generatedAt,
603
+ source: { appId: sourceId, actor: sourceActor },
604
+ target: { chapterId, environment },
605
+ grants
606
+ }
607
+ };
608
+ }
609
+ var rows = (value) => Array.isArray(value) ? value : [];
610
+ function asGrant(row) {
611
+ if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
612
+ if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
613
+ if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
614
+ return row;
615
+ }
616
+ async function findAdmissionGrant(db, targetId, email, now) {
617
+ const emailNormalized = normalizeAdmissionEmail(email);
618
+ if (!isAdmissionEmail(emailNormalized)) return null;
619
+ const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
620
+ const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
621
+ grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
622
+ )).sort((a, b) => {
623
+ const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
624
+ return scope || b.createdAt - a.createdAt;
625
+ });
626
+ return active[0] ?? null;
627
+ }
628
+ async function hasAdmissionRevision(db, sourceId, targetId, environment) {
629
+ const where = { sourceId, targetId, environment };
630
+ if (db.aggregate) {
631
+ return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
632
+ }
633
+ const found = await db.query({ admissionGrantRevisions: {} });
634
+ return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
635
+ }
636
+ async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
637
+ if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
638
+ const result = await db.query({ admissionGrantRevisions: { $: {
639
+ where: { sourceId, targetId, environment },
640
+ order: { revision: "desc" },
641
+ limit: 1
642
+ } } });
643
+ const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
644
+ return row ? row : null;
645
+ }
646
+ async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
647
+ const { document, digest } = envelope;
648
+ const current = rows((await db.query({ admissionGrants: { $: {
649
+ where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
650
+ limit: MAX_GRANTS
651
+ } } })).admissionGrants);
652
+ const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
653
+ const ops = [];
654
+ for (const row of current) {
655
+ const id = String(row.id ?? "");
656
+ if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
657
+ active: false,
658
+ revokedAt: receivedAt,
659
+ revokedBy: document.source.actor,
660
+ updatedAt: receivedAt,
661
+ updatedBy: document.source.actor
662
+ } });
663
+ }
664
+ for (const grant of document.grants) {
665
+ const id = `admission:parent:${document.source.appId}:${grant.id}`;
666
+ const existing = current.find((row) => row.id === id);
667
+ ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
668
+ id,
669
+ sourceGrantId: grant.id,
670
+ sourceScope: "parent",
671
+ sourceId: document.source.appId,
672
+ targetId: document.target.chapterId,
673
+ email: grant.email,
674
+ emailNormalized: normalizeAdmissionEmail(grant.email),
675
+ decision: grant.decision,
676
+ ...grant.note ? { note: grant.note } : {},
677
+ ...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
678
+ active: true,
679
+ sourceRevision: document.revision,
680
+ createdAt: Number(existing?.createdAt ?? receivedAt),
681
+ createdBy: String(existing?.createdBy ?? document.source.actor),
682
+ updatedAt: receivedAt,
683
+ updatedBy: document.source.actor
684
+ } });
685
+ const retract = [
686
+ ...!grant.note && existing?.note !== void 0 ? ["note"] : [],
687
+ ...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
688
+ ];
689
+ if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
690
+ }
691
+ if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
692
+ const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
693
+ ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
694
+ id: revisionId,
695
+ sourceId: document.source.appId,
696
+ targetId: document.target.chapterId,
697
+ environment: document.target.environment,
698
+ revision: document.revision,
699
+ digest,
700
+ active: true,
701
+ receivedAt
702
+ } });
703
+ await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
704
+ }
705
+
706
+ // src/flow-fields.ts
707
+ var CHAPTER_FLOW_FIELDS_SCHEMA_VERSION = 1;
708
+ var APPLICATION_LABELS = {
709
+ firstName: "First name",
710
+ lastName: "Last name",
711
+ email: "Email",
712
+ referral: "Referral source",
713
+ referralName: "Referral name",
714
+ whoYouAre: "Who you are",
715
+ linkedin: "LinkedIn",
716
+ message: "Intro message",
717
+ phone: "Phone",
718
+ state: "State"
719
+ };
720
+ function humanize(id) {
721
+ const text3 = id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
722
+ return text3 ? `${text3[0].toUpperCase()}${text3.slice(1)}` : id;
723
+ }
724
+ function applicationDefs(chapter) {
725
+ try {
726
+ return chapter.crm.type("person").fields;
727
+ } catch {
728
+ return {};
729
+ }
730
+ }
731
+ function applicationType(id, def) {
732
+ if (def && ["string", "email", "url", "enum"].includes(def.type)) return def.type;
733
+ return id === "email" ? "email" : id === "linkedin" ? "url" : "string";
734
+ }
735
+ function applicationFlowFields(chapter, application = chapter.application) {
736
+ const defs = applicationDefs(chapter);
737
+ return [...application.required, ...application.optional].map((id, order) => {
738
+ const def = defs[id];
739
+ const conditions = application.conditions[id];
740
+ const type = applicationType(id, def);
741
+ return {
742
+ id,
743
+ label: def?.label ?? APPLICATION_LABELS[id] ?? humanize(id),
744
+ type,
745
+ required: application.required.includes(id),
746
+ order,
747
+ maxLength: application.maxLen[id] ?? application.defaultMaxLen,
748
+ ...type === "enum" && def?.options ? { options: [...def.options] } : {},
749
+ ...conditions ? { conditions: { ...conditions } } : {},
750
+ ...type === "email" ? { emailIdentity: "literal" } : {}
751
+ };
752
+ });
753
+ }
754
+ function resolveFormationFlowFields(crm, formation) {
755
+ if (!formation.enabled) return [];
756
+ const def = crm.type(formation.type);
757
+ return [...formation.required, ...formation.optional].map((id, order) => {
758
+ const field = def.fields[id];
759
+ return {
760
+ id,
761
+ label: field.label ?? humanize(id),
762
+ type: field.type,
763
+ required: formation.required.includes(id),
764
+ order,
765
+ maxLength: formation.maxLen[id] ?? formation.defaultMaxLen,
766
+ ...field.options ? { options: [...field.options] } : {},
767
+ ...id === def.emailField || field.type === "email" ? { emailIdentity: "literal" } : {}
768
+ };
769
+ });
770
+ }
771
+ function formationFlowFields(chapter, formation = chapter.formation) {
772
+ return resolveFormationFlowFields(chapter.crm, formation);
773
+ }
774
+ function chapterFlowFieldContract(chapter, flow, application = chapter.application) {
775
+ return {
776
+ schemaVersion: CHAPTER_FLOW_FIELDS_SCHEMA_VERSION,
777
+ flow,
778
+ fields: flow === "join" ? applicationFlowFields(chapter, application) : formationFlowFields(chapter)
779
+ };
780
+ }
781
+
701
782
  // src/gift-offers.ts
702
783
  var GIFT_OFFER_CATALOG_VERSION = 1;
703
784
  var MAX_GIFT_OFFERS = 20;
@@ -1708,7 +1789,7 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
1708
1789
  // src/worker-routes.ts
1709
1790
  async function memberSessionApplication(db, chapterId, email) {
1710
1791
  let apps = (await db.query({ applications: { $: {
1711
- where: { emailNormalized: normalizeAdmissionEmail(email) },
1792
+ where: { emailNormalized: normalizeChapterEmail(email) },
1712
1793
  limit: 20
1713
1794
  } } })).applications;
1714
1795
  if (!apps?.length) apps = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
@@ -1807,6 +1888,7 @@ var handleMember = async (req, url, env, ctx) => {
1807
1888
  const application = controlledApplication(chapter.application, activeControl?.document);
1808
1889
  return json({
1809
1890
  ...joinConfig(group, paymentsReady, joinConfigTiers(offered), application.conditions),
1891
+ fieldContract: chapterFlowFieldContract(chapter, "join", application),
1810
1892
  copy: { ...chapter.copy.join, ...activeControl?.document.application?.copy ?? {} },
1811
1893
  ...activeControl ? { signupControl: {
1812
1894
  revision: activeControl.revision,
@@ -3050,7 +3132,7 @@ var handleAdminAdmissionGrants = async (req, url, env, ctx) => {
3050
3132
  return json({ error: "invalid JSON body" }, 400);
3051
3133
  }
3052
3134
  const email = typeof body.email === "string" ? body.email.trim() : "";
3053
- const emailNormalized = normalizeAdmissionEmail(email);
3135
+ const emailNormalized = normalizeChapterEmail(email);
3054
3136
  if (!isValidEmail(emailNormalized)) return json({ error: "email must be valid" }, 400);
3055
3137
  const decision = body.decision ?? "waive_interview";
3056
3138
  if (decision !== "waive_interview" && decision !== "require_interview") {
@@ -3552,7 +3634,7 @@ async function rotateGiftClaim(db, claim, now) {
3552
3634
  async function redeemGiftClaim(db, claim, beneficiary, now) {
3553
3635
  if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
3554
3636
  const chapterId = String(claim.chapterId);
3555
- const emailNormalized = normalizeAdmissionEmail(beneficiary.email);
3637
+ const emailNormalized = normalizeChapterEmail(beneficiary.email);
3556
3638
  const target = claim.target;
3557
3639
  const ops = [];
3558
3640
  let result;
@@ -3849,7 +3931,7 @@ async function startSubscription(req, env, ctx) {
3849
3931
  offer,
3850
3932
  purchaserType: "application",
3851
3933
  purchaserEmail: String(app.email ?? ""),
3852
- purchaserEmailNormalized: normalizeAdmissionEmail(String(app.email ?? "")),
3934
+ purchaserEmailNormalized: normalizeChapterEmail(String(app.email ?? "")),
3853
3935
  purchaserApplicationId: applicationId,
3854
3936
  stripeSecret: secretKey,
3855
3937
  publishableKey: group.stripePublishableKey,
@@ -3998,7 +4080,7 @@ var handleGifts = async (req, url, env, ctx) => {
3998
4080
  if (!user) return json({ error: "unauthorized" }, 401);
3999
4081
  const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
4000
4082
  const found = await db.query({ giftClaims: { $: { where: {
4001
- purchaserEmailNormalized: normalizeAdmissionEmail(user.email),
4083
+ purchaserEmailNormalized: normalizeChapterEmail(user.email),
4002
4084
  chapterId: ctx.chapter.id
4003
4085
  }, limit: 200 } } });
4004
4086
  const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
@@ -4025,7 +4107,7 @@ var handleGifts = async (req, url, env, ctx) => {
4025
4107
  const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
4026
4108
  const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
4027
4109
  if (!group || !stripeSecret || typeof group.stripePublishableKey !== "string") return json({ error: "payments not configured" }, 503);
4028
- const emailNormalized = normalizeAdmissionEmail(user.email);
4110
+ const emailNormalized = normalizeChapterEmail(user.email);
4029
4111
  const applications = rows2((await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications);
4030
4112
  const app = applications.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
4031
4113
  try {
@@ -4067,7 +4149,7 @@ var handleGifts = async (req, url, env, ctx) => {
4067
4149
  return invalid();
4068
4150
  }
4069
4151
  }
4070
- const owns = user ? claim.purchaserEmailNormalized === normalizeAdmissionEmail(user.email) : claim.purchaserApplicationId === applicationId;
4152
+ const owns = user ? claim.purchaserEmailNormalized === normalizeChapterEmail(user.email) : claim.purchaserApplicationId === applicationId;
4071
4153
  if (!owns) return invalid();
4072
4154
  const rotated = await rotateGiftClaim(db, claim, Date.now());
4073
4155
  return rotated ? json(rotated) : invalid();
@@ -5595,31 +5677,17 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
5595
5677
 
5596
5678
  // src/worker-routes-formation.ts
5597
5679
  import { createRecord as createRecord3 } from "@odla-ai/crm";
5598
-
5599
- // src/formation.ts
5600
- function formationFields(crm, formation) {
5601
- const def = crm.type(formation.type);
5602
- return [...formation.required, ...formation.optional].map((id) => {
5603
- const field = def.fields[id];
5604
- return {
5605
- id,
5606
- label: field.label ?? id,
5607
- type: field.type,
5608
- required: formation.required.includes(id),
5609
- ...field.options ? { options: field.options } : {}
5610
- };
5611
- });
5612
- }
5613
-
5614
- // src/worker-routes-formation.ts
5615
5680
  var SUBMISSION = /^[A-Za-z0-9_-]{8,128}$/;
5616
5681
  var handleFormation = async (req, url, env, ctx) => {
5617
5682
  const formation = ctx.chapter.formation;
5618
5683
  if (!formation.enabled) return null;
5619
5684
  if (req.method === "GET" && url.pathname === "/api/formation/config") {
5685
+ const fieldContract = chapterFlowFieldContract(ctx.chapter, "formation");
5620
5686
  return json({
5621
5687
  type: formation.type,
5622
- fields: formationFields(ctx.chapter.crm, formation)
5688
+ fieldContract,
5689
+ // Backward compatibility for hosts that consumed the original array.
5690
+ fields: fieldContract.fields
5623
5691
  });
5624
5692
  }
5625
5693
  if (req.method !== "POST" || url.pathname !== "/api/formation/applications") return null;