@odla-ai/chapter 0.37.0 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -22,6 +22,7 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  ADMISSION_GRANTS_PATH: () => ADMISSION_GRANTS_PATH,
24
24
  ADMISSION_GRANTS_SCHEMA_VERSION: () => ADMISSION_GRANTS_SCHEMA_VERSION,
25
+ CHAPTER_FLOW_FIELDS_SCHEMA_VERSION: () => CHAPTER_FLOW_FIELDS_SCHEMA_VERSION,
25
26
  CHAPTER_PROMOTION_ADAPTER: () => CHAPTER_PROMOTION_ADAPTER,
26
27
  DEFAULT_CHAPTER_COPY: () => DEFAULT_CHAPTER_COPY,
27
28
  DEFAULT_SHARE_FIELDS: () => DEFAULT_SHARE_FIELDS,
@@ -38,6 +39,7 @@ __export(index_exports, {
38
39
  SIGNUP_CONTROL_THEME_STYLE_ID: () => SIGNUP_CONTROL_THEME_STYLE_ID,
39
40
  applicantProfile: () => applicantProfile,
40
41
  applicationBookingUpdate: () => applicationBookingUpdate,
42
+ applicationFlowFields: () => applicationFlowFields,
41
43
  applicationSummary: () => applicationSummary,
42
44
  applyMembershipTierMarkdown: () => applyMembershipTierMarkdown,
43
45
  backfillCrm: () => backfillCrm,
@@ -55,6 +57,7 @@ __export(index_exports, {
55
57
  canonicalSignupControl: () => canonicalSignupControl,
56
58
  chapterBrandFromTokens: () => chapterBrandFromTokens,
57
59
  chapterDb: () => chapterDb,
60
+ chapterFlowFieldContract: () => chapterFlowFieldContract,
58
61
  clampArray: () => clampArray,
59
62
  clerkGetUser: () => clerkGetUser,
60
63
  clerkGetUserByEmail: () => clerkGetUserByEmail,
@@ -83,6 +86,7 @@ __export(index_exports, {
83
86
  firstPaymentPatch: () => firstPaymentPatch,
84
87
  formatChapterCopy: () => formatChapterCopy,
85
88
  formationFields: () => formationFields,
89
+ formationFlowFields: () => formationFlowFields,
86
90
  getVaultSecret: () => getVaultSecret,
87
91
  giftOfferActive: () => giftOfferActive,
88
92
  hasDisclaimerAck: () => hasDisclaimerAck,
@@ -111,6 +115,7 @@ __export(index_exports, {
111
115
  membershipTierPresentationFromMarkdown: () => membershipTierPresentationFromMarkdown,
112
116
  networkSourceTag: () => networkSourceTag,
113
117
  normalizeAdmissionEmail: () => normalizeAdmissionEmail,
118
+ normalizeChapterEmail: () => normalizeChapterEmail,
114
119
  normalizeSharedRecord: () => normalizeSharedRecord,
115
120
  normalizeWebhookEvent: () => normalizeWebhookEvent,
116
121
  offerableTiers: () => offerableTiers,
@@ -183,212 +188,10 @@ async function applicationIdForSubmission(submissionId) {
183
188
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
184
189
  }
185
190
 
186
- // src/admission-grants-contract.ts
187
- var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
188
- var ADMISSION_GRANTS_PATH = "/api/admission-grants";
189
-
190
- // src/signup-control-canonical.ts
191
- var encoder = new TextEncoder();
192
- function canonicalValue(value) {
193
- if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
194
- return JSON.stringify(value);
195
- }
196
- if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
197
- if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
198
- const record3 = value;
199
- const entries = Object.keys(record3).sort().flatMap((key) => {
200
- const item = record3[key];
201
- return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
202
- });
203
- return `{${entries.join(",")}}`;
204
- }
205
- function canonicalSignupControl(value) {
206
- return canonicalValue(value);
207
- }
208
- async function signupControlDigest(document) {
209
- const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
210
- return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
211
- }
212
- async function createSignupControlEnvelope(document) {
213
- return { document, digest: await signupControlDigest(document) };
214
- }
215
-
216
- // src/admission-grants.ts
217
- var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
218
- var MAX_GRANTS = 2e3;
219
- var MAX_NOTE = 2e3;
220
- var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
221
- var isAdmissionEmail = (value) => EMAIL_RE.test(value);
222
- function normalizeAdmissionEmail(email) {
191
+ // src/email-identity.ts
192
+ function normalizeChapterEmail(email) {
223
193
  return email.trim().toLowerCase();
224
194
  }
225
- function validateAdmissionGrantDocument(input) {
226
- const errors = [];
227
- const root = input && typeof input === "object" ? input : {};
228
- if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
229
- const revision = Number(root.revision);
230
- const generatedAt = Number(root.generatedAt);
231
- if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
232
- if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
233
- const source = root.source && typeof root.source === "object" ? root.source : {};
234
- const target = root.target && typeof root.target === "object" ? root.target : {};
235
- const sourceId = typeof source.appId === "string" ? source.appId : "";
236
- const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
237
- const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
238
- const environment = typeof target.environment === "string" ? target.environment : "";
239
- if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
240
- if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
241
- if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
242
- if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
243
- if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
244
- const rawGrants = Array.isArray(root.grants) ? root.grants : [];
245
- if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
246
- const grants = [];
247
- const seenIds = /* @__PURE__ */ new Set();
248
- const seenEmails = /* @__PURE__ */ new Set();
249
- for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
250
- const grant = raw && typeof raw === "object" ? raw : {};
251
- const id4 = typeof grant.id === "string" ? grant.id : "";
252
- const email = typeof grant.email === "string" ? grant.email.trim() : "";
253
- const normalized = normalizeAdmissionEmail(email);
254
- const decision = grant.decision;
255
- const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
256
- const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
257
- if (!ID.test(id4)) errors.push(`document.grants[${index}].id must be an identifier`);
258
- if (seenIds.has(id4)) errors.push(`document.grants repeats id "${id4}"`);
259
- if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
260
- if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
261
- if (decision !== "waive_interview" && decision !== "require_interview") {
262
- errors.push(`document.grants[${index}].decision is invalid`);
263
- }
264
- if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
265
- if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
266
- errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
267
- }
268
- seenIds.add(id4);
269
- seenEmails.add(normalized);
270
- grants.push({
271
- id: id4,
272
- email,
273
- decision,
274
- ...note ? { note } : {},
275
- ...expiresAt !== void 0 ? { expiresAt } : {}
276
- });
277
- }
278
- if (errors.length) return { ok: false, errors };
279
- return {
280
- ok: true,
281
- value: {
282
- schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
283
- revision,
284
- generatedAt,
285
- source: { appId: sourceId, actor: sourceActor },
286
- target: { chapterId, environment },
287
- grants
288
- }
289
- };
290
- }
291
- async function createAdmissionGrantEnvelope(document) {
292
- const checked = validateAdmissionGrantDocument(document);
293
- if (!checked.ok) throw new TypeError(`Invalid admission grant document: ${checked.errors.join("; ")}`);
294
- return { document: checked.value, digest: await signupControlDigest(checked.value) };
295
- }
296
- var rows = (value) => Array.isArray(value) ? value : [];
297
- function asGrant(row) {
298
- if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
299
- if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
300
- if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
301
- return row;
302
- }
303
- async function findAdmissionGrant(db, targetId, email, now) {
304
- const emailNormalized = normalizeAdmissionEmail(email);
305
- if (!isAdmissionEmail(emailNormalized)) return null;
306
- const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
307
- const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
308
- grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
309
- )).sort((a, b) => {
310
- const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
311
- return scope || b.createdAt - a.createdAt;
312
- });
313
- return active[0] ?? null;
314
- }
315
- async function hasAdmissionRevision(db, sourceId, targetId, environment) {
316
- const where = { sourceId, targetId, environment };
317
- if (db.aggregate) {
318
- return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
319
- }
320
- const found = await db.query({ admissionGrantRevisions: {} });
321
- return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
322
- }
323
- async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
324
- if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
325
- const result = await db.query({ admissionGrantRevisions: { $: {
326
- where: { sourceId, targetId, environment },
327
- order: { revision: "desc" },
328
- limit: 1
329
- } } });
330
- const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
331
- return row ? row : null;
332
- }
333
- async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
334
- const { document, digest } = envelope;
335
- const current = rows((await db.query({ admissionGrants: { $: {
336
- where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
337
- limit: MAX_GRANTS
338
- } } })).admissionGrants);
339
- const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
340
- const ops = [];
341
- for (const row of current) {
342
- const id4 = String(row.id ?? "");
343
- if (id4 && !incomingIds.has(id4)) ops.push({ t: "update", ns: "admissionGrants", id: id4, attrs: {
344
- active: false,
345
- revokedAt: receivedAt,
346
- revokedBy: document.source.actor,
347
- updatedAt: receivedAt,
348
- updatedBy: document.source.actor
349
- } });
350
- }
351
- for (const grant of document.grants) {
352
- const id4 = `admission:parent:${document.source.appId}:${grant.id}`;
353
- const existing = current.find((row) => row.id === id4);
354
- ops.push({ t: "update", ns: "admissionGrants", id: id4, attrs: {
355
- id: id4,
356
- sourceGrantId: grant.id,
357
- sourceScope: "parent",
358
- sourceId: document.source.appId,
359
- targetId: document.target.chapterId,
360
- email: grant.email,
361
- emailNormalized: normalizeAdmissionEmail(grant.email),
362
- decision: grant.decision,
363
- ...grant.note ? { note: grant.note } : {},
364
- ...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
365
- active: true,
366
- sourceRevision: document.revision,
367
- createdAt: Number(existing?.createdAt ?? receivedAt),
368
- createdBy: String(existing?.createdBy ?? document.source.actor),
369
- updatedAt: receivedAt,
370
- updatedBy: document.source.actor
371
- } });
372
- const retract = [
373
- ...!grant.note && existing?.note !== void 0 ? ["note"] : [],
374
- ...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
375
- ];
376
- if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id: id4, attrs: retract });
377
- }
378
- if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
379
- const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
380
- ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
381
- id: revisionId,
382
- sourceId: document.source.appId,
383
- targetId: document.target.chapterId,
384
- environment: document.target.environment,
385
- revision: document.revision,
386
- digest,
387
- active: true,
388
- receivedAt
389
- } });
390
- await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
391
- }
392
195
 
393
196
  // src/member.ts
394
197
  var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
@@ -441,9 +244,9 @@ function resolveApplication(a) {
441
244
  validateEmail: a?.validateEmail ?? true
442
245
  };
443
246
  }
444
- var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
247
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
445
248
  function isValidEmail(value) {
446
- return typeof value === "string" && EMAIL_RE2.test(value);
249
+ return typeof value === "string" && EMAIL_RE.test(value);
447
250
  }
448
251
  function clampArray(value, max) {
449
252
  if (!Array.isArray(value)) return value;
@@ -478,7 +281,7 @@ async function submitApplication(db, chapter, fields, opts) {
478
281
  const cap = app.maxLen[f] ?? app.defaultMaxLen;
479
282
  if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
480
283
  }
481
- if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
284
+ if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email.trim())) {
482
285
  return { ok: false, error: "email must be a valid email address" };
483
286
  }
484
287
  const acked = hasDisclaimerAck(fields);
@@ -494,7 +297,7 @@ async function submitApplication(db, chapter, fields, opts) {
494
297
  if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
495
298
  if (opts.groupId) row.groupId = opts.groupId;
496
299
  if (typeof fields.email === "string" && isValidEmail(fields.email.trim())) {
497
- row.emailNormalized = normalizeAdmissionEmail(fields.email);
300
+ row.emailNormalized = normalizeChapterEmail(fields.email);
498
301
  }
499
302
  if (opts.admissionGrant?.decision === "waive_interview") {
500
303
  row.admissionGrantId = opts.admissionGrant.id;
@@ -1556,6 +1359,82 @@ function formatChapterCopy(template, values) {
1556
1359
  );
1557
1360
  }
1558
1361
 
1362
+ // src/flow-fields.ts
1363
+ var CHAPTER_FLOW_FIELDS_SCHEMA_VERSION = 1;
1364
+ var APPLICATION_LABELS = {
1365
+ firstName: "First name",
1366
+ lastName: "Last name",
1367
+ email: "Email",
1368
+ referral: "Referral source",
1369
+ referralName: "Referral name",
1370
+ whoYouAre: "Who you are",
1371
+ linkedin: "LinkedIn",
1372
+ message: "Intro message",
1373
+ phone: "Phone",
1374
+ state: "State"
1375
+ };
1376
+ function humanize(id4) {
1377
+ const text3 = id4.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
1378
+ return text3 ? `${text3[0].toUpperCase()}${text3.slice(1)}` : id4;
1379
+ }
1380
+ function applicationDefs(chapter) {
1381
+ try {
1382
+ return chapter.crm.type("person").fields;
1383
+ } catch {
1384
+ return {};
1385
+ }
1386
+ }
1387
+ function applicationType(id4, def) {
1388
+ if (def && ["string", "email", "url", "enum"].includes(def.type)) return def.type;
1389
+ return id4 === "email" ? "email" : id4 === "linkedin" ? "url" : "string";
1390
+ }
1391
+ function applicationFlowFields(chapter, application = chapter.application) {
1392
+ const defs = applicationDefs(chapter);
1393
+ return [...application.required, ...application.optional].map((id4, order) => {
1394
+ const def = defs[id4];
1395
+ const conditions = application.conditions[id4];
1396
+ const type = applicationType(id4, def);
1397
+ return {
1398
+ id: id4,
1399
+ label: def?.label ?? APPLICATION_LABELS[id4] ?? humanize(id4),
1400
+ type,
1401
+ required: application.required.includes(id4),
1402
+ order,
1403
+ maxLength: application.maxLen[id4] ?? application.defaultMaxLen,
1404
+ ...type === "enum" && def?.options ? { options: [...def.options] } : {},
1405
+ ...conditions ? { conditions: { ...conditions } } : {},
1406
+ ...type === "email" ? { emailIdentity: "literal" } : {}
1407
+ };
1408
+ });
1409
+ }
1410
+ function resolveFormationFlowFields(crm, formation) {
1411
+ if (!formation.enabled) return [];
1412
+ const def = crm.type(formation.type);
1413
+ return [...formation.required, ...formation.optional].map((id4, order) => {
1414
+ const field = def.fields[id4];
1415
+ return {
1416
+ id: id4,
1417
+ label: field.label ?? humanize(id4),
1418
+ type: field.type,
1419
+ required: formation.required.includes(id4),
1420
+ order,
1421
+ maxLength: formation.maxLen[id4] ?? formation.defaultMaxLen,
1422
+ ...field.options ? { options: [...field.options] } : {},
1423
+ ...id4 === def.emailField || field.type === "email" ? { emailIdentity: "literal" } : {}
1424
+ };
1425
+ });
1426
+ }
1427
+ function formationFlowFields(chapter, formation = chapter.formation) {
1428
+ return resolveFormationFlowFields(chapter.crm, formation);
1429
+ }
1430
+ function chapterFlowFieldContract(chapter, flow, application = chapter.application) {
1431
+ return {
1432
+ schemaVersion: CHAPTER_FLOW_FIELDS_SCHEMA_VERSION,
1433
+ flow,
1434
+ fields: flow === "join" ? applicationFlowFields(chapter, application) : formationFlowFields(chapter)
1435
+ };
1436
+ }
1437
+
1559
1438
  // src/formation.ts
1560
1439
  var DEFAULT_REQUIRED2 = ["name", "email", "location", "thesis"];
1561
1440
  var DEFAULT_OPTIONAL2 = ["proposedChapter", "experience", "collaborators"];
@@ -1618,17 +1497,7 @@ function resolveLeaderFormation(input, crm) {
1618
1497
  };
1619
1498
  }
1620
1499
  function formationFields(crm, formation) {
1621
- const def = crm.type(formation.type);
1622
- return [...formation.required, ...formation.optional].map((id4) => {
1623
- const field = def.fields[id4];
1624
- return {
1625
- id: id4,
1626
- label: field.label ?? id4,
1627
- type: field.type,
1628
- required: formation.required.includes(id4),
1629
- ...field.options ? { options: field.options } : {}
1630
- };
1631
- });
1500
+ return resolveFormationFlowFields(crm, formation);
1632
1501
  }
1633
1502
 
1634
1503
  // src/config-network.ts
@@ -2207,7 +2076,7 @@ function createChapterIntegration(chapter, options = {}) {
2207
2076
  }
2208
2077
 
2209
2078
  // src/member-content-validation-helpers.ts
2210
- var ID2 = /^[a-z][a-z0-9-]{0,79}$/;
2079
+ var ID = /^[a-z][a-z0-9-]{0,79}$/;
2211
2080
  var SLUG5 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2212
2081
  var ROOT_KEYS = /* @__PURE__ */ new Set(["schemaVersion", "revision", "generatedAt", "source", "target", "pages", "upsellOverrides"]);
2213
2082
  var SOURCE_KEYS = /* @__PURE__ */ new Set(["type", "appId"]);
@@ -2247,7 +2116,7 @@ function offerValue(value, path, errors, tiers2, audience) {
2247
2116
  }
2248
2117
  unknownKeys(raw, OFFER_KEYS, path, errors);
2249
2118
  const tierId = textValue(raw.tierId, `${path}.tierId`, errors, 80, true) ?? "";
2250
- if (tierId && !ID2.test(tierId)) errors.push(`${path}.tierId must be a lowercase kebab-case id`);
2119
+ if (tierId && !ID.test(tierId)) errors.push(`${path}.tierId must be a lowercase kebab-case id`);
2251
2120
  if (tiers2.size && !tiers2.has(tierId)) errors.push(`${path}.tierId must reference a declared tier`);
2252
2121
  if (audience?.kind === "tiers" && tierId && !audience.tierIds.includes(tierId)) {
2253
2122
  errors.push(`${path}.tierId must grant access to this page`);
@@ -2280,7 +2149,7 @@ function validateMemberContentDocument(input, options = {}) {
2280
2149
  const type = sourceRaw.type;
2281
2150
  const appId = textValue(sourceRaw.appId, "document.source.appId", errors, 63, true) ?? "";
2282
2151
  if (type !== "parent" && type !== "chapter") errors.push("document.source.type must be parent or chapter");
2283
- if (appId && !ID2.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
2152
+ if (appId && !ID.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
2284
2153
  if (options.sourceType && type !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
2285
2154
  if (options.sourceAppId && appId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
2286
2155
  if (type === "parent" || type === "chapter") source = { type, appId };
@@ -2317,7 +2186,7 @@ function validateMemberContentDocument(input, options = {}) {
2317
2186
  const id4 = textValue(raw.id, `${path}.id`, errors, 80, true) ?? "";
2318
2187
  const slug = textValue(raw.slug, `${path}.slug`, errors, 120, true) ?? "";
2319
2188
  const title = textValue(raw.title, `${path}.title`, errors, MAX_TEXT, true) ?? "";
2320
- if (id4 && !ID2.test(id4)) errors.push(`${path}.id must be a lowercase kebab-case id`);
2189
+ if (id4 && !ID.test(id4)) errors.push(`${path}.id must be a lowercase kebab-case id`);
2321
2190
  if (slug && !SLUG5.test(slug)) errors.push(`${path}.slug must be a lowercase URL slug`);
2322
2191
  if (ids.has(id4) || knownParentIds.has(id4)) errors.push(`${path}.id duplicates page "${id4}"`);
2323
2192
  if (slugs.has(slug) || (options.parentPages ?? []).some((page) => page.slug === slug)) errors.push(`${path}.slug duplicates "${slug}"`);
@@ -2497,25 +2366,25 @@ function projectMemberContent(content, viewer, tiers2) {
2497
2366
  }
2498
2367
 
2499
2368
  // src/member-content-canonical.ts
2500
- var encoder2 = new TextEncoder();
2501
- function canonicalValue2(value) {
2369
+ var encoder = new TextEncoder();
2370
+ function canonicalValue(value) {
2502
2371
  if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
2503
2372
  return JSON.stringify(value);
2504
2373
  }
2505
- if (Array.isArray(value)) return `[${value.map(canonicalValue2).join(",")}]`;
2374
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
2506
2375
  if (typeof value !== "object") throw new TypeError("member content contains an unsupported value");
2507
2376
  const record3 = value;
2508
2377
  const entries = Object.keys(record3).sort().flatMap((key) => {
2509
2378
  const item = record3[key];
2510
- return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue2(item)}`];
2379
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
2511
2380
  });
2512
2381
  return `{${entries.join(",")}}`;
2513
2382
  }
2514
2383
  function canonicalMemberContent(value) {
2515
- return canonicalValue2(value);
2384
+ return canonicalValue(value);
2516
2385
  }
2517
2386
  async function memberContentDigest(document) {
2518
- const digest = await crypto.subtle.digest("SHA-256", encoder2.encode(canonicalMemberContent(document)));
2387
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalMemberContent(document)));
2519
2388
  return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
2520
2389
  }
2521
2390
  async function createMemberContentEnvelope(document) {
@@ -2695,6 +2564,213 @@ function canceledPatch() {
2695
2564
  return { canceled: true };
2696
2565
  }
2697
2566
 
2567
+ // src/admission-grants-contract.ts
2568
+ var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
2569
+ var ADMISSION_GRANTS_PATH = "/api/admission-grants";
2570
+
2571
+ // src/signup-control-canonical.ts
2572
+ var encoder2 = new TextEncoder();
2573
+ function canonicalValue2(value) {
2574
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
2575
+ return JSON.stringify(value);
2576
+ }
2577
+ if (Array.isArray(value)) return `[${value.map(canonicalValue2).join(",")}]`;
2578
+ if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
2579
+ const record3 = value;
2580
+ const entries = Object.keys(record3).sort().flatMap((key) => {
2581
+ const item = record3[key];
2582
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue2(item)}`];
2583
+ });
2584
+ return `{${entries.join(",")}}`;
2585
+ }
2586
+ function canonicalSignupControl(value) {
2587
+ return canonicalValue2(value);
2588
+ }
2589
+ async function signupControlDigest(document) {
2590
+ const digest = await crypto.subtle.digest("SHA-256", encoder2.encode(canonicalSignupControl(document)));
2591
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
2592
+ }
2593
+ async function createSignupControlEnvelope(document) {
2594
+ return { document, digest: await signupControlDigest(document) };
2595
+ }
2596
+
2597
+ // src/admission-grants.ts
2598
+ var ID2 = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
2599
+ var MAX_GRANTS = 2e3;
2600
+ var MAX_NOTE = 2e3;
2601
+ var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2602
+ var isAdmissionEmail = (value) => EMAIL_RE2.test(value);
2603
+ function normalizeAdmissionEmail(email) {
2604
+ return normalizeChapterEmail(email);
2605
+ }
2606
+ function validateAdmissionGrantDocument(input) {
2607
+ const errors = [];
2608
+ const root = input && typeof input === "object" ? input : {};
2609
+ if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
2610
+ const revision = Number(root.revision);
2611
+ const generatedAt = Number(root.generatedAt);
2612
+ if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
2613
+ if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
2614
+ const source = root.source && typeof root.source === "object" ? root.source : {};
2615
+ const target = root.target && typeof root.target === "object" ? root.target : {};
2616
+ const sourceId = typeof source.appId === "string" ? source.appId : "";
2617
+ const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
2618
+ const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
2619
+ const environment = typeof target.environment === "string" ? target.environment : "";
2620
+ if (!ID2.test(sourceId)) errors.push("document.source.appId must be an identifier");
2621
+ if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
2622
+ if (!ID2.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
2623
+ if (!ID2.test(environment)) errors.push("document.target.environment must be an identifier");
2624
+ if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
2625
+ const rawGrants = Array.isArray(root.grants) ? root.grants : [];
2626
+ if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
2627
+ const grants = [];
2628
+ const seenIds = /* @__PURE__ */ new Set();
2629
+ const seenEmails = /* @__PURE__ */ new Set();
2630
+ for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
2631
+ const grant = raw && typeof raw === "object" ? raw : {};
2632
+ const id4 = typeof grant.id === "string" ? grant.id : "";
2633
+ const email = typeof grant.email === "string" ? grant.email.trim() : "";
2634
+ const normalized = normalizeAdmissionEmail(email);
2635
+ const decision = grant.decision;
2636
+ const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
2637
+ const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
2638
+ if (!ID2.test(id4)) errors.push(`document.grants[${index}].id must be an identifier`);
2639
+ if (seenIds.has(id4)) errors.push(`document.grants repeats id "${id4}"`);
2640
+ if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
2641
+ if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
2642
+ if (decision !== "waive_interview" && decision !== "require_interview") {
2643
+ errors.push(`document.grants[${index}].decision is invalid`);
2644
+ }
2645
+ if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
2646
+ if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
2647
+ errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
2648
+ }
2649
+ seenIds.add(id4);
2650
+ seenEmails.add(normalized);
2651
+ grants.push({
2652
+ id: id4,
2653
+ email,
2654
+ decision,
2655
+ ...note ? { note } : {},
2656
+ ...expiresAt !== void 0 ? { expiresAt } : {}
2657
+ });
2658
+ }
2659
+ if (errors.length) return { ok: false, errors };
2660
+ return {
2661
+ ok: true,
2662
+ value: {
2663
+ schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
2664
+ revision,
2665
+ generatedAt,
2666
+ source: { appId: sourceId, actor: sourceActor },
2667
+ target: { chapterId, environment },
2668
+ grants
2669
+ }
2670
+ };
2671
+ }
2672
+ async function createAdmissionGrantEnvelope(document) {
2673
+ const checked = validateAdmissionGrantDocument(document);
2674
+ if (!checked.ok) throw new TypeError(`Invalid admission grant document: ${checked.errors.join("; ")}`);
2675
+ return { document: checked.value, digest: await signupControlDigest(checked.value) };
2676
+ }
2677
+ var rows = (value) => Array.isArray(value) ? value : [];
2678
+ function asGrant(row) {
2679
+ if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
2680
+ if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
2681
+ if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
2682
+ return row;
2683
+ }
2684
+ async function findAdmissionGrant(db, targetId, email, now) {
2685
+ const emailNormalized = normalizeAdmissionEmail(email);
2686
+ if (!isAdmissionEmail(emailNormalized)) return null;
2687
+ const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
2688
+ const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
2689
+ grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
2690
+ )).sort((a, b) => {
2691
+ const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
2692
+ return scope || b.createdAt - a.createdAt;
2693
+ });
2694
+ return active[0] ?? null;
2695
+ }
2696
+ async function hasAdmissionRevision(db, sourceId, targetId, environment) {
2697
+ const where = { sourceId, targetId, environment };
2698
+ if (db.aggregate) {
2699
+ return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
2700
+ }
2701
+ const found = await db.query({ admissionGrantRevisions: {} });
2702
+ return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
2703
+ }
2704
+ async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
2705
+ if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
2706
+ const result = await db.query({ admissionGrantRevisions: { $: {
2707
+ where: { sourceId, targetId, environment },
2708
+ order: { revision: "desc" },
2709
+ limit: 1
2710
+ } } });
2711
+ const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
2712
+ return row ? row : null;
2713
+ }
2714
+ async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
2715
+ const { document, digest } = envelope;
2716
+ const current = rows((await db.query({ admissionGrants: { $: {
2717
+ where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
2718
+ limit: MAX_GRANTS
2719
+ } } })).admissionGrants);
2720
+ const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
2721
+ const ops = [];
2722
+ for (const row of current) {
2723
+ const id4 = String(row.id ?? "");
2724
+ if (id4 && !incomingIds.has(id4)) ops.push({ t: "update", ns: "admissionGrants", id: id4, attrs: {
2725
+ active: false,
2726
+ revokedAt: receivedAt,
2727
+ revokedBy: document.source.actor,
2728
+ updatedAt: receivedAt,
2729
+ updatedBy: document.source.actor
2730
+ } });
2731
+ }
2732
+ for (const grant of document.grants) {
2733
+ const id4 = `admission:parent:${document.source.appId}:${grant.id}`;
2734
+ const existing = current.find((row) => row.id === id4);
2735
+ ops.push({ t: "update", ns: "admissionGrants", id: id4, attrs: {
2736
+ id: id4,
2737
+ sourceGrantId: grant.id,
2738
+ sourceScope: "parent",
2739
+ sourceId: document.source.appId,
2740
+ targetId: document.target.chapterId,
2741
+ email: grant.email,
2742
+ emailNormalized: normalizeAdmissionEmail(grant.email),
2743
+ decision: grant.decision,
2744
+ ...grant.note ? { note: grant.note } : {},
2745
+ ...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
2746
+ active: true,
2747
+ sourceRevision: document.revision,
2748
+ createdAt: Number(existing?.createdAt ?? receivedAt),
2749
+ createdBy: String(existing?.createdBy ?? document.source.actor),
2750
+ updatedAt: receivedAt,
2751
+ updatedBy: document.source.actor
2752
+ } });
2753
+ const retract = [
2754
+ ...!grant.note && existing?.note !== void 0 ? ["note"] : [],
2755
+ ...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
2756
+ ];
2757
+ if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id: id4, attrs: retract });
2758
+ }
2759
+ if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
2760
+ const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
2761
+ ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
2762
+ id: revisionId,
2763
+ sourceId: document.source.appId,
2764
+ targetId: document.target.chapterId,
2765
+ environment: document.target.environment,
2766
+ revision: document.revision,
2767
+ digest,
2768
+ active: true,
2769
+ receivedAt
2770
+ } });
2771
+ await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
2772
+ }
2773
+
2698
2774
  // src/join-continuation.ts
2699
2775
  function resolveJoinContinuation(input) {
2700
2776
  if (input.status === "approved") return "accepted";
@@ -3059,10 +3135,10 @@ async function syncApplicationToCrm(deps, opts) {
3059
3135
  return recordId;
3060
3136
  }
3061
3137
  async function backfillCrm(deps) {
3062
- const [appsRes, usersRes] = await Promise.all([
3063
- deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
3064
- deps.db.query({ $users: { $: { limit: 1e3 } } })
3065
- ]);
3138
+ const data = await deps.db.query({
3139
+ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } },
3140
+ $users: { $: { limit: 1e3 } }
3141
+ });
3066
3142
  const seen = /* @__PURE__ */ new Set();
3067
3143
  let synced = 0;
3068
3144
  const errors = [];
@@ -3077,8 +3153,8 @@ async function backfillCrm(deps) {
3077
3153
  errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
3078
3154
  }
3079
3155
  };
3080
- for (const a of appsRes.applications ?? []) await run(a, str(a.status));
3081
- for (const u of usersRes.$users ?? []) {
3156
+ for (const a of data.applications ?? []) await run(a, str(a.status));
3157
+ for (const u of data.$users ?? []) {
3082
3158
  if (u.deleted === true) continue;
3083
3159
  await run({ email: u.email, firstName: str(u.name) });
3084
3160
  }