@odla-ai/chapter 0.35.0 → 0.37.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.
@@ -22,6 +22,8 @@ var worker_exports = {};
22
22
  __export(worker_exports, {
23
23
  chapterWorker: () => chapterWorker,
24
24
  createWorkerContext: () => createWorkerContext,
25
+ deliverAdmissionGrants: () => deliverAdmissionGrants,
26
+ deliverMemberContent: () => deliverMemberContent,
25
27
  deliverSignupControl: () => deliverSignupControl,
26
28
  renderActiveSignupControlHtml: () => renderActiveSignupControlHtml,
27
29
  withActiveSignupControlTheme: () => withActiveSignupControlTheme
@@ -139,7 +141,7 @@ function createWorkerContext(options) {
139
141
  }
140
142
 
141
143
  // src/worker-routes.ts
142
- var import_crm3 = require("@odla-ai/crm");
144
+ var import_crm4 = require("@odla-ai/crm");
143
145
 
144
146
  // src/discussion-reference-security.ts
145
147
  async function authorizeCrmDiscussionReference(req, env, fetcher = fetch) {
@@ -197,6 +199,205 @@ async function applicationIdForSubmission(submissionId) {
197
199
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
198
200
  }
199
201
 
202
+ // src/admission-grants-contract.ts
203
+ var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
204
+ var ADMISSION_GRANTS_PATH = "/api/admission-grants";
205
+
206
+ // src/signup-control-canonical.ts
207
+ var encoder = new TextEncoder();
208
+ function canonicalValue(value) {
209
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
210
+ return JSON.stringify(value);
211
+ }
212
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
213
+ if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
214
+ const record3 = value;
215
+ const entries = Object.keys(record3).sort().flatMap((key) => {
216
+ const item = record3[key];
217
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
218
+ });
219
+ return `{${entries.join(",")}}`;
220
+ }
221
+ function canonicalSignupControl(value) {
222
+ return canonicalValue(value);
223
+ }
224
+ async function signupControlDigest(document) {
225
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
226
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
227
+ }
228
+
229
+ // src/admission-grants.ts
230
+ var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
231
+ var MAX_GRANTS = 2e3;
232
+ var MAX_NOTE = 2e3;
233
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
234
+ var isAdmissionEmail = (value) => EMAIL_RE.test(value);
235
+ function normalizeAdmissionEmail(email) {
236
+ return email.trim().toLowerCase();
237
+ }
238
+ function validateAdmissionGrantDocument(input) {
239
+ const errors = [];
240
+ const root = input && typeof input === "object" ? input : {};
241
+ if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
242
+ const revision = Number(root.revision);
243
+ const generatedAt = Number(root.generatedAt);
244
+ if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
245
+ if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
246
+ const source = root.source && typeof root.source === "object" ? root.source : {};
247
+ const target = root.target && typeof root.target === "object" ? root.target : {};
248
+ const sourceId = typeof source.appId === "string" ? source.appId : "";
249
+ const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
250
+ const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
251
+ const environment = typeof target.environment === "string" ? target.environment : "";
252
+ if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
253
+ if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
254
+ if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
255
+ if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
256
+ if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
257
+ const rawGrants = Array.isArray(root.grants) ? root.grants : [];
258
+ if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
259
+ const grants = [];
260
+ const seenIds = /* @__PURE__ */ new Set();
261
+ const seenEmails = /* @__PURE__ */ new Set();
262
+ for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
263
+ const grant = raw && typeof raw === "object" ? raw : {};
264
+ const id = typeof grant.id === "string" ? grant.id : "";
265
+ const email = typeof grant.email === "string" ? grant.email.trim() : "";
266
+ const normalized = normalizeAdmissionEmail(email);
267
+ const decision = grant.decision;
268
+ const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
269
+ const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
270
+ if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
271
+ if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
272
+ if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
273
+ if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
274
+ if (decision !== "waive_interview" && decision !== "require_interview") {
275
+ errors.push(`document.grants[${index}].decision is invalid`);
276
+ }
277
+ if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
278
+ if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
279
+ errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
280
+ }
281
+ seenIds.add(id);
282
+ seenEmails.add(normalized);
283
+ grants.push({
284
+ id,
285
+ email,
286
+ decision,
287
+ ...note ? { note } : {},
288
+ ...expiresAt !== void 0 ? { expiresAt } : {}
289
+ });
290
+ }
291
+ if (errors.length) return { ok: false, errors };
292
+ return {
293
+ ok: true,
294
+ value: {
295
+ schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
296
+ revision,
297
+ generatedAt,
298
+ source: { appId: sourceId, actor: sourceActor },
299
+ target: { chapterId, environment },
300
+ grants
301
+ }
302
+ };
303
+ }
304
+ var rows = (value) => Array.isArray(value) ? value : [];
305
+ function asGrant(row) {
306
+ if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
307
+ if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
308
+ if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
309
+ return row;
310
+ }
311
+ async function findAdmissionGrant(db, targetId, email, now) {
312
+ const emailNormalized = normalizeAdmissionEmail(email);
313
+ if (!isAdmissionEmail(emailNormalized)) return null;
314
+ const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
315
+ const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
316
+ grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
317
+ )).sort((a, b) => {
318
+ const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
319
+ return scope || b.createdAt - a.createdAt;
320
+ });
321
+ return active[0] ?? null;
322
+ }
323
+ async function hasAdmissionRevision(db, sourceId, targetId, environment) {
324
+ const where = { sourceId, targetId, environment };
325
+ if (db.aggregate) {
326
+ return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
327
+ }
328
+ const found = await db.query({ admissionGrantRevisions: {} });
329
+ return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
330
+ }
331
+ async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
332
+ if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
333
+ const result = await db.query({ admissionGrantRevisions: { $: {
334
+ where: { sourceId, targetId, environment },
335
+ order: { revision: "desc" },
336
+ limit: 1
337
+ } } });
338
+ const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
339
+ return row ? row : null;
340
+ }
341
+ async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
342
+ const { document, digest } = envelope;
343
+ const current = rows((await db.query({ admissionGrants: { $: {
344
+ where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
345
+ limit: MAX_GRANTS
346
+ } } })).admissionGrants);
347
+ const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
348
+ const ops = [];
349
+ for (const row of current) {
350
+ const id = String(row.id ?? "");
351
+ if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
352
+ active: false,
353
+ revokedAt: receivedAt,
354
+ revokedBy: document.source.actor,
355
+ updatedAt: receivedAt,
356
+ updatedBy: document.source.actor
357
+ } });
358
+ }
359
+ for (const grant of document.grants) {
360
+ const id = `admission:parent:${document.source.appId}:${grant.id}`;
361
+ const existing = current.find((row) => row.id === id);
362
+ ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
363
+ id,
364
+ sourceGrantId: grant.id,
365
+ sourceScope: "parent",
366
+ sourceId: document.source.appId,
367
+ targetId: document.target.chapterId,
368
+ email: grant.email,
369
+ emailNormalized: normalizeAdmissionEmail(grant.email),
370
+ decision: grant.decision,
371
+ ...grant.note ? { note: grant.note } : {},
372
+ ...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
373
+ active: true,
374
+ sourceRevision: document.revision,
375
+ createdAt: Number(existing?.createdAt ?? receivedAt),
376
+ createdBy: String(existing?.createdBy ?? document.source.actor),
377
+ updatedAt: receivedAt,
378
+ updatedBy: document.source.actor
379
+ } });
380
+ const retract = [
381
+ ...!grant.note && existing?.note !== void 0 ? ["note"] : [],
382
+ ...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
383
+ ];
384
+ if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
385
+ }
386
+ if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
387
+ const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
388
+ ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
389
+ id: revisionId,
390
+ sourceId: document.source.appId,
391
+ targetId: document.target.chapterId,
392
+ environment: document.target.environment,
393
+ revision: document.revision,
394
+ digest,
395
+ active: true,
396
+ receivedAt
397
+ } });
398
+ await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
399
+ }
400
+
200
401
  // src/member.ts
201
402
  var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
202
403
  var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
@@ -248,9 +449,9 @@ function resolveApplication(a) {
248
449
  validateEmail: a?.validateEmail ?? true
249
450
  };
250
451
  }
251
- var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
452
+ var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
252
453
  function isValidEmail(value) {
253
- return typeof value === "string" && EMAIL_RE.test(value);
454
+ return typeof value === "string" && EMAIL_RE2.test(value);
254
455
  }
255
456
  function clampArray(value, max) {
256
457
  if (!Array.isArray(value)) return value;
@@ -262,14 +463,14 @@ function hasDisclaimerAck(fields) {
262
463
  var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
263
464
  function applicantProfile(chapter, fields) {
264
465
  const app = chapter.application;
265
- const allowed = (f) => app.profileFields.includes(f);
466
+ const allowed2 = (f) => app.profileFields.includes(f);
266
467
  const profile = {};
267
468
  for (const f of [...app.required, ...app.optional]) {
268
- if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
469
+ if (IDENTITY_FIELDS.has(f) || !allowed2(f)) continue;
269
470
  const v = fields[f];
270
471
  if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
271
472
  }
272
- if (fields.focus !== void 0 && allowed("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
473
+ if (fields.focus !== void 0 && allowed2("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
273
474
  return Object.keys(profile).length > 0 ? profile : void 0;
274
475
  }
275
476
  async function submitApplication(db, chapter, fields, opts) {
@@ -300,6 +501,15 @@ async function submitApplication(db, chapter, fields, opts) {
300
501
  }
301
502
  if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
302
503
  if (opts.groupId) row.groupId = opts.groupId;
504
+ if (typeof fields.email === "string" && isValidEmail(fields.email.trim())) {
505
+ row.emailNormalized = normalizeAdmissionEmail(fields.email);
506
+ }
507
+ if (opts.admissionGrant?.decision === "waive_interview") {
508
+ row.admissionGrantId = opts.admissionGrant.id;
509
+ row.admissionSource = opts.admissionGrant.sourceScope;
510
+ row.interviewWaivedAt = opts.now;
511
+ }
512
+ if (opts.giftOfferId) row.giftOfferId = opts.giftOfferId;
303
513
  if (typeof fields.tierId === "string" && fields.tierId) {
304
514
  if (opts.tierIds && !opts.tierIds.includes(fields.tierId)) {
305
515
  return { ok: false, error: "tierId is not an offered tier" };
@@ -307,8 +517,17 @@ async function submitApplication(db, chapter, fields, opts) {
307
517
  row.tierId = fields.tierId;
308
518
  }
309
519
  if (acked) row.disclaimerAckAt = opts.now;
520
+ const ops = [
521
+ { t: "update", ns: "applications", id, attrs: row }
522
+ ];
523
+ if (opts.admissionGrant?.decision === "waive_interview") {
524
+ ops.push({ t: "update", ns: "admissionGrants", id: opts.admissionGrant.id, attrs: {
525
+ consumedAt: opts.now,
526
+ consumedApplicationId: id
527
+ } });
528
+ }
310
529
  const { duplicate } = await db.transact(
311
- [{ t: "update", ns: "applications", id, attrs: row }],
530
+ ops,
312
531
  opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
313
532
  );
314
533
  return { ok: true, id, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
@@ -342,8 +561,8 @@ function tierPayable(tier, group, hasSecretKey) {
342
561
  function byOrder(a, b) {
343
562
  return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
344
563
  }
345
- function resolveTiers(group, rows2 = []) {
346
- const stored = rows2.filter((tier) => tier.active);
564
+ function resolveTiers(group, rows4 = []) {
565
+ const stored = rows4.filter((tier) => tier.active);
347
566
  if (stored.length) return [...stored].sort(byOrder);
348
567
  return [{
349
568
  id: "standard",
@@ -389,15 +608,15 @@ function rowToTier(row, groupId) {
389
608
  }
390
609
  async function loadTiers(db, group) {
391
610
  const groupId = String(group.id ?? "");
392
- let rows2;
611
+ let rows4;
393
612
  try {
394
- rows2 = (await db.query({
613
+ rows4 = (await db.query({
395
614
  tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
396
615
  })).tiers;
397
616
  } catch {
398
- rows2 = void 0;
617
+ rows4 = void 0;
399
618
  }
400
- const list = Array.isArray(rows2) ? rows2.map((row) => rowToTier(row, groupId)) : [];
619
+ const list = Array.isArray(rows4) ? rows4.map((row) => rowToTier(row, groupId)) : [];
401
620
  return resolveTiers(group, list);
402
621
  }
403
622
  async function loadOfferableTiers(db, group, hasSecretKey) {
@@ -511,341 +730,252 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
511
730
  };
512
731
  }
513
732
 
514
- // src/pipeline.ts
515
- function canTransition(from, to, p) {
516
- const fi = p.stages.indexOf(from);
517
- const ti = p.stages.indexOf(to);
518
- return fi >= 0 && ti >= 0 && ti >= fi;
733
+ // src/gift-offers.ts
734
+ var GIFT_OFFER_CATALOG_VERSION = 1;
735
+ var MAX_GIFT_OFFERS = 20;
736
+ var ID2 = /^[a-z][a-z0-9-]{1,79}$/;
737
+ var PRICE = /^price_[A-Za-z0-9]{12,}$/;
738
+ var CURRENCY = /^[a-z]{3}$/;
739
+ var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
740
+ var text = (value, path, errors, max) => {
741
+ if (typeof value !== "string" || !value.trim()) {
742
+ errors.push(`${path} is required`);
743
+ return "";
744
+ }
745
+ if (value.trim().length > max) errors.push(`${path} must be at most ${max} characters`);
746
+ return value.trim();
747
+ };
748
+ function validateGiftOfferCatalog(input, tierIds) {
749
+ const errors = [];
750
+ const root = record(input);
751
+ if (!root) return { ok: false, errors: ["document.giftOffers must be an object"] };
752
+ for (const key of Object.keys(root)) if (key !== "schemaVersion" && key !== "offers") errors.push(`document.giftOffers.${key} is not supported`);
753
+ if (root.schemaVersion !== GIFT_OFFER_CATALOG_VERSION) errors.push("document.giftOffers.schemaVersion must be 1");
754
+ if (!Array.isArray(root.offers)) errors.push("document.giftOffers.offers must be an array");
755
+ const rawOffers = Array.isArray(root.offers) ? root.offers : [];
756
+ if (rawOffers.length > MAX_GIFT_OFFERS) errors.push(`document.giftOffers.offers supports at most ${MAX_GIFT_OFFERS} entries`);
757
+ const ids = /* @__PURE__ */ new Set();
758
+ const offers = [];
759
+ for (const [index, raw] of rawOffers.slice(0, MAX_GIFT_OFFERS).entries()) {
760
+ const path = `document.giftOffers.offers[${index}]`;
761
+ const item = record(raw);
762
+ if (!item) {
763
+ errors.push(`${path} must be an object`);
764
+ continue;
765
+ }
766
+ const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
767
+ for (const key of Object.keys(item)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
768
+ const id = text(item.id, `${path}.id`, errors, 80);
769
+ if (id && !ID2.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
770
+ if (ids.has(id)) errors.push(`${path}.id duplicates "${id}"`);
771
+ ids.add(id);
772
+ const targetRaw = record(item.target);
773
+ let target = { type: "entitlement", entitlementKey: "invalid" };
774
+ if (targetRaw?.type === "tier") {
775
+ const tierId = text(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
776
+ if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
777
+ target = { type: "tier", tierId };
778
+ } else if (targetRaw?.type === "entitlement") {
779
+ const entitlementKey = text(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
780
+ if (entitlementKey && !ID2.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
781
+ target = { type: "entitlement", entitlementKey };
782
+ } else errors.push(`${path}.target.type must be tier or entitlement`);
783
+ if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
784
+ const stripePriceId = text(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
785
+ if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
786
+ if (!Number.isSafeInteger(item.priceCents) || Number(item.priceCents) < 1 || Number(item.priceCents) > 1e8) errors.push(`${path}.priceCents must be an integer from 1 to 100,000,000`);
787
+ const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
788
+ if (!CURRENCY.test(currency)) errors.push(`${path}.currency must be a lowercase ISO currency code`);
789
+ const lifetime = Number(item.redemptionLifetimeHours);
790
+ if (!Number.isSafeInteger(lifetime) || lifetime < 1 || lifetime > 8760) errors.push(`${path}.redemptionLifetimeHours must be an integer from 1 to 8760`);
791
+ const activeFrom = item.activeFrom === void 0 ? void 0 : Number(item.activeFrom);
792
+ const activeUntil = item.activeUntil === void 0 ? void 0 : Number(item.activeUntil);
793
+ if (activeFrom !== void 0 && (!Number.isSafeInteger(activeFrom) || activeFrom < 1)) errors.push(`${path}.activeFrom must be epoch milliseconds`);
794
+ if (activeUntil !== void 0 && (!Number.isSafeInteger(activeUntil) || activeUntil < 1)) errors.push(`${path}.activeUntil must be epoch milliseconds`);
795
+ if (activeFrom !== void 0 && activeUntil !== void 0 && activeUntil <= activeFrom) errors.push(`${path}.activeUntil must follow activeFrom`);
796
+ offers.push({
797
+ id,
798
+ target,
799
+ billingMode: item.billingMode,
800
+ stripePriceId,
801
+ priceCents: Number(item.priceCents),
802
+ currency,
803
+ redemptionLifetimeHours: lifetime,
804
+ ...activeFrom !== void 0 ? { activeFrom } : {},
805
+ ...activeUntil !== void 0 ? { activeUntil } : {},
806
+ title: text(item.title, `${path}.title`, errors, 160),
807
+ description: text(item.description, `${path}.description`, errors, 2e3),
808
+ ctaLabel: text(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
809
+ });
810
+ }
811
+ return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
519
812
  }
520
- function canBook(status, p) {
521
- return p.bookableFrom.includes(status);
813
+ var giftOfferActive = (offer, now) => (!offer.activeFrom || offer.activeFrom <= now) && (!offer.activeUntil || offer.activeUntil > now);
814
+ function publicGiftOffers(catalog, now) {
815
+ return (catalog?.offers ?? []).filter((offer) => giftOfferActive(offer, now)).map(({ stripePriceId: _stripe, target: _target, ...offer }) => offer);
522
816
  }
523
- function canApprove(status, p) {
524
- return p.approvableFrom.includes(status);
817
+ function findGiftOffer(catalog, offerId, now) {
818
+ return catalog?.offers.find((offer) => offer.id === offerId && giftOfferActive(offer, now)) ?? null;
525
819
  }
526
820
 
527
- // src/signup-control-store.ts
528
- function asStored(row) {
529
- if (!row || typeof row.document !== "object" || row.document === null) return null;
530
- return row;
821
+ // src/clerk-roles.ts
822
+ var CLERK_API = "https://api.clerk.com";
823
+ var DEFAULT_ROLE = "provisional";
824
+ var PAGE = 100;
825
+ function toRecord(u) {
826
+ if (typeof u.id !== "string") return null;
827
+ const pm = u.public_metadata ?? {};
828
+ const privateMetadata = u.private_metadata ?? {};
829
+ const rawProfile = privateMetadata.profile;
830
+ const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
831
+ const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
832
+ const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
833
+ const email = u.email_addresses?.[0]?.email_address;
834
+ return {
835
+ id: u.id,
836
+ email: typeof email === "string" ? email : void 0,
837
+ role,
838
+ publicMetadata: pm,
839
+ privateMetadata,
840
+ profile,
841
+ ...applicationId ? { applicationId } : {}
842
+ };
531
843
  }
532
- async function hasSignupControl(db, targetId, environment, active) {
533
- const where = { targetId, environment, ...active ? { active: true } : {} };
534
- if (db.aggregate) {
535
- return Number((await db.aggregate("signupControls", { count: true }, { where })).count ?? 0) > 0;
844
+ async function clerkGet(path, secretKey, fetchImpl) {
845
+ const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
846
+ if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
847
+ return res.json();
848
+ }
849
+ async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
850
+ const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
851
+ const user = Array.isArray(data) ? data[0] : void 0;
852
+ return user ? toRecord(user) : null;
853
+ }
854
+ async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
855
+ const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
856
+ return data ? toRecord(data) : null;
857
+ }
858
+ async function clerkListUsers(secretKey, fetchImpl = fetch) {
859
+ const out = [];
860
+ for (let offset = 0; ; offset += PAGE) {
861
+ const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
862
+ const page = Array.isArray(data) ? data : [];
863
+ for (const u of page) {
864
+ const record3 = toRecord(u);
865
+ if (record3) out.push(record3);
866
+ }
867
+ if (page.length < PAGE) break;
536
868
  }
537
- const rows2 = (await db.query({ signupControls: {} })).signupControls;
538
- return Array.isArray(rows2) && rows2.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
869
+ return out;
539
870
  }
540
- async function loadLatestSignupControl(db, targetId, environment) {
541
- if (!await hasSignupControl(db, targetId, environment)) return null;
542
- const rows2 = (await db.query({ signupControls: { $: {
543
- where: { targetId, environment },
544
- order: { revision: "desc" },
545
- limit: 1
546
- } } })).signupControls;
547
- const latest = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
548
- return asStored(latest);
871
+ async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
872
+ const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
873
+ method: "PATCH",
874
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
875
+ body: JSON.stringify({ public_metadata: { role } })
876
+ });
877
+ return res.ok;
549
878
  }
550
- async function loadActiveSignupControl(db, targetId, environment) {
551
- if (!await hasSignupControl(db, targetId, environment, true)) return null;
552
- const rows2 = (await db.query({ signupControls: { $: {
553
- where: { targetId, environment, active: true },
554
- order: { revision: "desc" },
555
- limit: 1
556
- } } })).signupControls;
557
- const active = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
558
- return asStored(active);
879
+
880
+ // src/crm-sync.ts
881
+ var import_crm3 = require("@odla-ai/crm");
882
+
883
+ // src/network.ts
884
+ var import_crm2 = require("@odla-ai/crm");
885
+
886
+ // src/network-contract.ts
887
+ var DEFAULT_SHARE_FIELDS = {
888
+ person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
889
+ company: ["name", "domain", "industry", "location", "linkedin", "notes"]
890
+ };
891
+
892
+ // src/network.ts
893
+ function sharedPersonInput(person) {
894
+ const email = person.email.toLowerCase();
895
+ const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
896
+ const input = { name: person.name ?? fullName ?? email, email };
897
+ if (input.name === "") input.name = email;
898
+ if (person.firstName) input.firstName = person.firstName;
899
+ if (person.lastName) input.lastName = person.lastName;
900
+ if (person.phone) input.phone = person.phone;
901
+ if (person.linkedin) input.linkedin = person.linkedin;
902
+ return input;
559
903
  }
560
- function controlledApplication(base, document) {
561
- const delegated = document?.application;
562
- return delegated ? { ...base, required: delegated.required, optional: delegated.optional, conditions: { ...delegated.conditions ?? {} } } : base;
904
+ function shortHash(value) {
905
+ let a = 2166136261;
906
+ let b = 2654435769;
907
+ for (let i = 0; i < value.length; i += 1) {
908
+ const n = value.charCodeAt(i);
909
+ a = Math.imul(a ^ n, 16777619);
910
+ b = Math.imul(b ^ n, 2246822507);
911
+ }
912
+ return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
563
913
  }
564
- function validateControlledApplication(chapter, document) {
565
- if (!document.application) return [];
566
- const declared = /* @__PURE__ */ new Set([...chapter.application.required, ...chapter.application.optional]);
567
- return [...document.application.required, ...document.application.optional].filter((field) => !declared.has(field)).map((field) => `document.application field "${field}" is not declared by this chapter schema`);
914
+ function networkSourceTag(type, hubRecordId, sourceId) {
915
+ const typeKey = type.toLowerCase();
916
+ const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
917
+ const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
918
+ const raw = `${prefix}:${hubRecordId}`;
919
+ if (readable && raw.length <= 64) return raw;
920
+ return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
568
921
  }
569
- function groupAttrs(document) {
570
- const first = [...document.tiers].filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder)[0];
571
- const firstPaid = [...document.tiers].find((tier) => tier.available && tier.priceCents > 0);
922
+ function normalizeSharedRecord(record3, fallbackSourceId = "legacy-source") {
923
+ if ("input" in record3 && record3.version === 2) {
924
+ return {
925
+ version: 2,
926
+ sourceId: record3.source.siteId,
927
+ sourceRecordId: record3.source.recordId,
928
+ type: record3.type,
929
+ input: record3.input
930
+ };
931
+ }
932
+ if ("input" in record3) {
933
+ return {
934
+ version: 1,
935
+ sourceId: fallbackSourceId,
936
+ sourceRecordId: record3.hubRecordId,
937
+ type: record3.type,
938
+ input: record3.input
939
+ };
940
+ }
941
+ if ("type" in record3 && record3.type === "company") {
942
+ const input = { name: record3.name };
943
+ for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
944
+ if (record3[key]) input[key] = record3[key];
945
+ }
946
+ return {
947
+ version: 1,
948
+ sourceId: fallbackSourceId,
949
+ sourceRecordId: record3.hubRecordId,
950
+ type: "company",
951
+ input
952
+ };
953
+ }
572
954
  return {
573
- ...first ? { standardPriceCents: first.priceCents } : {},
574
- foundingDiscountCents: 0,
575
- ...firstPaid?.stripePriceId ? { stripePriceId: firstPaid.stripePriceId } : {},
576
- ...document.policy ?? {},
577
- ...document.scheduling ? { schedulingJson: document.scheduling } : {}
955
+ version: 1,
956
+ sourceId: fallbackSourceId,
957
+ sourceRecordId: record3.hubRecordId,
958
+ type: "person",
959
+ input: sharedPersonInput(record3)
578
960
  };
579
961
  }
580
- var byPublicId = (ns, value) => ({
581
- ns,
582
- attr: "id",
583
- value
584
- });
585
- async function materializeSignupControl(db, envelope, previous, receivedAt) {
586
- const { document, digest } = envelope;
587
- const currentRows = (await db.query({ tiers: { $: {
588
- where: { groupId: document.target.chapterId },
589
- limit: 100
590
- } } })).tiers ?? [];
591
- const incoming = new Set(document.tiers.map((tier) => tier.id));
592
- const ops = [];
593
- if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
594
- for (const row of currentRows) {
595
- const id2 = String(row.id ?? "");
596
- if (id2 && !incoming.has(id2)) {
597
- ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
598
- }
599
- }
600
- for (const tier of document.tiers) {
601
- const tierRef = byPublicId("tiers", tier.id);
602
- ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
603
- id: tier.id,
604
- groupId: document.target.chapterId,
605
- name: tier.name,
606
- priceCents: tier.priceCents,
607
- ...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
608
- ...tier.blurb ? { blurb: tier.blurb } : {},
609
- sortOrder: tier.sortOrder,
610
- active: tier.available,
611
- createdAt: receivedAt
612
- } });
613
- const retract = [
614
- ...!tier.stripePriceId ? ["stripePriceId"] : [],
615
- ...!tier.blurb ? ["blurb"] : []
616
- ];
617
- if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
618
- }
619
- const groupRef = byPublicId("groups", document.target.chapterId);
620
- const attrs = groupAttrs(document);
621
- ops.push({
622
- t: "update",
623
- ns: "groups",
624
- id: groupRef,
625
- attrs
626
- });
627
- if (!("stripePriceId" in attrs)) {
628
- ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
629
- }
630
- const id = `signup-control:${document.target.environment}:${document.revision}`;
631
- ops.push({ t: "update", ns: "signupControls", id, attrs: {
632
- id,
633
- sourceId: document.source.appId,
634
- targetId: document.target.chapterId,
635
- environment: document.target.environment,
636
- revision: document.revision,
637
- digest,
638
- document,
639
- active: true,
640
- receivedAt
641
- } });
642
- await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
643
- }
644
-
645
- // src/session.ts
646
- function applicationSummary(app) {
647
- return {
648
- id: app.id,
649
- firstName: app.firstName ?? null,
650
- lastName: app.lastName ?? null,
651
- email: app.email ?? null,
652
- status: app.status,
653
- createdAt: app.createdAt ?? null,
654
- meetingLink: app.meetingLink ?? null,
655
- paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
656
- renewalAt: app.renewalAt ?? null,
657
- canceled: app.canceled === true
658
- };
659
- }
660
- function memberApplication(app, meeting, defaultTimezone) {
661
- const summary = applicationSummary(app);
662
- let meetingAt = app.meetingAt ?? null;
663
- let meetUrl = null;
664
- let timezone = defaultTimezone;
665
- if (meeting) {
666
- timezone = meeting.timezone ?? timezone;
667
- if (meeting.status === "scheduled") {
668
- meetingAt = meeting.startAt ?? null;
669
- meetUrl = meeting.meetUrl ?? null;
670
- } else {
671
- meetingAt = null;
672
- }
673
- }
674
- return { ...summary, meetingAt, meetUrl, timezone };
675
- }
676
-
677
- // src/clerk.ts
678
- var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
679
- function clerkInviteRequest(input) {
680
- return {
681
- path: "/v1/invitations",
682
- body: {
683
- email_address: input.email,
684
- notify: true,
685
- ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
686
- ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
687
- }
688
- };
689
- }
690
- async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
691
- const { path, body } = clerkInviteRequest(input);
692
- const res = await fetchImpl(`https://api.clerk.com${path}`, {
693
- method: "POST",
694
- headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
695
- body: JSON.stringify(body)
696
- });
697
- return res.ok ? { ok: true, status: res.status } : heal(res.status);
698
- }
699
- function clerkUserRequest(input) {
700
- return {
701
- path: "/v1/users",
702
- body: {
703
- email_address: [input.email],
704
- skip_password_requirement: true,
705
- ...input.firstName ? { first_name: input.firstName } : {},
706
- ...input.lastName ? { last_name: input.lastName } : {},
707
- ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
708
- ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
709
- }
710
- };
711
- }
712
- async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
713
- const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
714
- method: "PATCH",
715
- headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
716
- body: JSON.stringify({
717
- ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
718
- ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
719
- })
720
- });
721
- return res.ok;
722
- }
723
- async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
724
- const auth = { authorization: `Bearer ${secretKey}` };
725
- const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
726
- if (!found.ok) return false;
727
- const users = await found.json().catch(() => null);
728
- const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
729
- if (!id) return false;
730
- return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
731
- }
732
- async function createClerkUser(secretKey, input, fetchImpl = fetch) {
733
- const { path, body } = clerkUserRequest(input);
734
- const res = await fetchImpl(`https://api.clerk.com${path}`, {
735
- method: "POST",
736
- headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
737
- body: JSON.stringify(body)
738
- });
739
- if (res.ok) return { ok: true, status: res.status };
740
- const healed = heal(res.status);
741
- const repair = input.repairMetadata ?? {
742
- ...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
743
- ...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
744
- };
745
- if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
746
- const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
747
- ...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
748
- ...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
749
- }, fetchImpl).catch(() => false);
750
- return { ...healed, refreshed };
751
- }
752
-
753
- // src/network.ts
754
- var import_crm2 = require("@odla-ai/crm");
755
-
756
- // src/network-contract.ts
757
- var DEFAULT_SHARE_FIELDS = {
758
- person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
759
- company: ["name", "domain", "industry", "location", "linkedin", "notes"]
760
- };
761
-
762
- // src/network.ts
763
- function sharedPersonInput(person) {
764
- const email = person.email.toLowerCase();
765
- const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
766
- const input = { name: person.name ?? fullName ?? email, email };
767
- if (input.name === "") input.name = email;
768
- if (person.firstName) input.firstName = person.firstName;
769
- if (person.lastName) input.lastName = person.lastName;
770
- if (person.phone) input.phone = person.phone;
771
- if (person.linkedin) input.linkedin = person.linkedin;
772
- return input;
773
- }
774
- function shortHash(value) {
775
- let a = 2166136261;
776
- let b = 2654435769;
777
- for (let i = 0; i < value.length; i += 1) {
778
- const n = value.charCodeAt(i);
779
- a = Math.imul(a ^ n, 16777619);
780
- b = Math.imul(b ^ n, 2246822507);
781
- }
782
- return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
783
- }
784
- function networkSourceTag(type, hubRecordId, sourceId) {
785
- const typeKey = type.toLowerCase();
786
- const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
787
- const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
788
- const raw = `${prefix}:${hubRecordId}`;
789
- if (readable && raw.length <= 64) return raw;
790
- return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
791
- }
792
- function normalizeSharedRecord(record, fallbackSourceId = "legacy-source") {
793
- if ("input" in record && record.version === 2) {
794
- return {
795
- version: 2,
796
- sourceId: record.source.siteId,
797
- sourceRecordId: record.source.recordId,
798
- type: record.type,
799
- input: record.input
800
- };
801
- }
802
- if ("input" in record) {
803
- return {
804
- version: 1,
805
- sourceId: fallbackSourceId,
806
- sourceRecordId: record.hubRecordId,
807
- type: record.type,
808
- input: record.input
809
- };
810
- }
811
- if ("type" in record && record.type === "company") {
812
- const input = { name: record.name };
813
- for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
814
- if (record[key]) input[key] = record[key];
815
- }
816
- return {
817
- version: 1,
818
- sourceId: fallbackSourceId,
819
- sourceRecordId: record.hubRecordId,
820
- type: "company",
821
- input
822
- };
823
- }
824
- return {
825
- version: 1,
826
- sourceId: fallbackSourceId,
827
- sourceRecordId: record.hubRecordId,
828
- type: "person",
829
- input: sharedPersonInput(record)
830
- };
831
- }
832
- function sharedRecordFromCrm(crm, record, target, sourceId) {
833
- if (target.fields && !target.fields[record.type]) {
834
- throw new Error(`${target.name} does not accept "${record.type}" records`);
962
+ function sharedRecordFromCrm(crm, record3, target, sourceId) {
963
+ if (target.fields && !target.fields[record3.type]) {
964
+ throw new Error(`${target.name} does not accept "${record3.type}" records`);
835
965
  }
836
- const def = crm.type(record.type);
837
- const fields = target.fields?.[record.type] ?? DEFAULT_SHARE_FIELDS[record.type];
966
+ const def = crm.type(record3.type);
967
+ const fields = target.fields?.[record3.type] ?? DEFAULT_SHARE_FIELDS[record3.type];
838
968
  if (!fields) {
839
- throw new Error(`${target.name} requires an explicit field allowlist for "${record.type}" records`);
969
+ throw new Error(`${target.name} requires an explicit field allowlist for "${record3.type}" records`);
840
970
  }
841
971
  const nameField = def.nameField ?? "name";
842
972
  const input = {};
843
973
  for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
844
- const value = record.fields?.[field];
974
+ const value = record3.fields?.[field];
845
975
  if (value !== void 0) input[field] = value;
846
976
  }
847
- if (input[nameField] === void 0) input[nameField] = record.name;
848
- return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record.id }, type: record.type, input } : { version: 1, type: record.type, hubRecordId: record.id, input };
977
+ if (input[nameField] === void 0) input[nameField] = record3.name;
978
+ return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record3.id }, type: record3.type, input } : { version: 1, type: record3.type, hubRecordId: record3.id, input };
849
979
  }
850
980
  async function upsertPerson(deps, opts) {
851
981
  const email = opts.email.toLowerCase();
@@ -859,11 +989,11 @@ async function upsertPerson(deps, opts) {
859
989
  const created = await (0, import_crm2.createRecord)(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
860
990
  return { recordId: created.id };
861
991
  }
862
- async function findSharedRecord(deps, record, tag) {
992
+ async function findSharedRecord(deps, record3, tag) {
863
993
  const structured = await (0, import_crm2.getRecordByOrigin)(
864
994
  { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
865
- record.sourceId,
866
- record.sourceRecordId
995
+ record3.sourceId,
996
+ record3.sourceRecordId
867
997
  );
868
998
  if (structured) return structured;
869
999
  const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
@@ -872,189 +1002,609 @@ async function findSharedRecord(deps, record, tag) {
872
1002
  const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
873
1003
  if (found.crm_record?.[0]) return found.crm_record[0];
874
1004
  }
875
- const def = deps.crm.type(record.type);
1005
+ const def = deps.crm.type(record3.type);
876
1006
  const emailField = def.emailField;
877
- if (emailField && typeof record.input[emailField] === "string") {
878
- const primaryEmail = record.input[emailField].toLowerCase();
1007
+ if (emailField && typeof record3.input[emailField] === "string") {
1008
+ const primaryEmail = record3.input[emailField].toLowerCase();
879
1009
  const found = await deps.db.query({
880
- crm_record: { $: { where: { type: record.type, primaryEmail }, limit: 1 } }
1010
+ crm_record: { $: { where: { type: record3.type, primaryEmail }, limit: 1 } }
881
1011
  });
882
1012
  if (found.crm_record?.[0]) return found.crm_record[0];
883
1013
  }
884
- const domain = record.input.domain;
1014
+ const domain = record3.input.domain;
885
1015
  const domainSlot = def.fields.domain?.slot;
886
1016
  if (typeof domain === "string" && domainSlot) {
887
1017
  const found = await deps.db.query({
888
- crm_record: { $: { where: { type: record.type, [domainSlot]: domain }, limit: 1 } }
1018
+ crm_record: { $: { where: { type: record3.type, [domainSlot]: domain }, limit: 1 } }
889
1019
  });
890
1020
  if (found.crm_record?.[0]) return found.crm_record[0];
891
1021
  }
892
1022
  const nameField = def.nameField ?? "name";
893
- const name = record.input[nameField];
894
- if (record.type === "company" && typeof name === "string" && name.trim()) {
1023
+ const name = record3.input[nameField];
1024
+ if (record3.type === "company" && typeof name === "string" && name.trim()) {
895
1025
  const found = await deps.db.query({
896
- crm_record: { $: { where: { type: record.type, name: name.trim() }, limit: 1 } }
1026
+ crm_record: { $: { where: { type: record3.type, name: name.trim() }, limit: 1 } }
897
1027
  });
898
1028
  if (found.crm_record?.[0]) return found.crm_record[0];
899
1029
  }
900
1030
  return void 0;
901
1031
  }
902
1032
  async function projectSharedRecord(deps, shared, options = {}) {
903
- const record = normalizeSharedRecord(shared, options.sourceId);
904
- if (!record.type.trim() || !record.sourceId.trim() || !record.sourceRecordId.trim()) {
1033
+ const record3 = normalizeSharedRecord(shared, options.sourceId);
1034
+ if (!record3.type.trim() || !record3.sourceId.trim() || !record3.sourceRecordId.trim()) {
905
1035
  throw new Error("type, source site id, and source record id must be non-empty");
906
1036
  }
907
- if (options.sourceId && options.sourceId !== record.sourceId) {
1037
+ if (options.sourceId && options.sourceId !== record3.sourceId) {
908
1038
  throw new Error("signed sender does not match payload source");
909
1039
  }
910
- const tag = networkSourceTag(record.type, record.sourceRecordId, record.version === 2 ? record.sourceId : void 0);
1040
+ const tag = networkSourceTag(record3.type, record3.sourceRecordId, record3.version === 2 ? record3.sourceId : void 0);
911
1041
  const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
912
- const existing = await findSharedRecord(deps, record, tag);
1042
+ const existing = await findSharedRecord(deps, record3, tag);
913
1043
  let recordId;
914
1044
  if (existing && typeof existing.id === "string") {
915
- await (0, import_crm2.updateRecord)(crmDeps3, { id: existing.id, input: record.input });
1045
+ await (0, import_crm2.updateRecord)(crmDeps3, { id: existing.id, input: record3.input });
916
1046
  recordId = existing.id;
917
1047
  } else {
918
- recordId = `network_${shortHash(`${record.sourceId}\0${record.type}\0${record.sourceRecordId}`)}`;
1048
+ recordId = `network_${shortHash(`${record3.sourceId}\0${record3.type}\0${record3.sourceRecordId}`)}`;
919
1049
  await (0, import_crm2.createRecord)({ ...crmDeps3, newId: () => recordId }, {
920
- type: record.type,
921
- input: record.input,
1050
+ type: record3.type,
1051
+ input: record3.input,
922
1052
  mutationId: `share-create:${tag}`
923
1053
  });
924
1054
  }
925
- await deps.db.transact(
926
- [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
927
- { mutationId: `share-map:${tag}:${recordId}` }
928
- );
929
- await (0, import_crm2.upsertRecordOrigin)(crmDeps3, {
930
- recordId,
931
- sourceId: record.sourceId,
932
- sourceRecordId: record.sourceRecordId,
933
- ...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
934
- payloadVersion: record.version
935
- });
936
- return { recordId };
937
- }
938
- async function projectApplicant(deps, applicant) {
939
- const base = sharedPersonInput({
940
- email: applicant.email,
941
- firstName: applicant.firstName,
942
- lastName: applicant.lastName,
943
- phone: applicant.phone,
944
- linkedin: applicant.linkedin,
945
- hubRecordId: applicant.applicationId
1055
+ await deps.db.transact(
1056
+ [{ t: "update", ns: "crm_tag", id: tag, attrs: { key: `${recordId}:${tag}`, recordId, tag, createdAt: deps.now() } }],
1057
+ { mutationId: `share-map:${tag}:${recordId}` }
1058
+ );
1059
+ await (0, import_crm2.upsertRecordOrigin)(crmDeps3, {
1060
+ recordId,
1061
+ sourceId: record3.sourceId,
1062
+ sourceRecordId: record3.sourceRecordId,
1063
+ ...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
1064
+ payloadVersion: record3.version
1065
+ });
1066
+ return { recordId };
1067
+ }
1068
+ async function projectApplicant(deps, applicant) {
1069
+ const base = sharedPersonInput({
1070
+ email: applicant.email,
1071
+ firstName: applicant.firstName,
1072
+ lastName: applicant.lastName,
1073
+ phone: applicant.phone,
1074
+ linkedin: applicant.linkedin,
1075
+ hubRecordId: applicant.applicationId
1076
+ });
1077
+ const mutationId = `apply:${applicant.applicationId}`;
1078
+ const extra = applicant.extra ?? {};
1079
+ if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
1080
+ try {
1081
+ return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
1082
+ } catch {
1083
+ return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
1084
+ }
1085
+ }
1086
+
1087
+ // src/crm-sync.ts
1088
+ var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
1089
+ var crmDeps = (db, ctx) => ({
1090
+ crm: ctx.chapter.crm,
1091
+ db,
1092
+ now: () => Date.now(),
1093
+ newId: () => crypto.randomUUID(),
1094
+ chapter: ctx.chapter
1095
+ });
1096
+ function personInputFromApp(chapter, app) {
1097
+ const input = sharedPersonInput({
1098
+ email: str(app.email),
1099
+ firstName: str(app.firstName) || void 0,
1100
+ lastName: str(app.lastName) || void 0,
1101
+ phone: str(app.phone) || void 0,
1102
+ linkedin: str(app.linkedin) || void 0,
1103
+ hubRecordId: str(app.id)
1104
+ });
1105
+ for (const f of chapter.application.crmFields) {
1106
+ if (app[f] !== void 0) input[f] = app[f];
1107
+ }
1108
+ if (app.id !== void 0) input.applicationId = str(app.id);
1109
+ return input;
1110
+ }
1111
+ function billingColumns(app) {
1112
+ const status = str(app.status);
1113
+ const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
1114
+ const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
1115
+ const cols = { billingStatus };
1116
+ if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
1117
+ if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
1118
+ if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
1119
+ return cols;
1120
+ }
1121
+ async function syncApplicationToCrm(deps, opts) {
1122
+ const emailKey = str(opts.app.email).toLowerCase();
1123
+ if (!emailKey) return null;
1124
+ const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1125
+ const input = personInputFromApp(deps.chapter, opts.app);
1126
+ const { crm_record } = await deps.db.query({
1127
+ crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
1128
+ });
1129
+ const existing = crm_record?.[0] ?? null;
1130
+ const stage = opts.stage || void 0;
1131
+ let recordId;
1132
+ if (existing && typeof existing.id === "string") {
1133
+ recordId = existing.id;
1134
+ await (0, import_crm3.updateRecord)(recordDeps, { id: recordId, input });
1135
+ } else {
1136
+ const created = await (0, import_crm3.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
1137
+ recordId = created.id;
1138
+ }
1139
+ if (existing && stage && existing.stage !== stage) {
1140
+ await (0, import_crm3.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1141
+ }
1142
+ await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1143
+ await (0, import_crm3.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1144
+ return recordId;
1145
+ }
1146
+ async function backfillCrm(deps) {
1147
+ const [appsRes, usersRes] = await Promise.all([
1148
+ deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
1149
+ deps.db.query({ $users: { $: { limit: 1e3 } } })
1150
+ ]);
1151
+ const seen = /* @__PURE__ */ new Set();
1152
+ let synced = 0;
1153
+ const errors = [];
1154
+ const run = async (app, stage) => {
1155
+ const key = str(app.email).toLowerCase();
1156
+ if (!key || seen.has(key)) return;
1157
+ seen.add(key);
1158
+ try {
1159
+ await syncApplicationToCrm(deps, { app, stage });
1160
+ synced += 1;
1161
+ } catch (err) {
1162
+ errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
1163
+ }
1164
+ };
1165
+ for (const a of appsRes.applications ?? []) await run(a, str(a.status));
1166
+ for (const u of usersRes.$users ?? []) {
1167
+ if (u.deleted === true) continue;
1168
+ await run({ email: u.email, firstName: str(u.name) });
1169
+ }
1170
+ return { synced, errors };
1171
+ }
1172
+
1173
+ // src/email.ts
1174
+ function render(template, vars) {
1175
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
1176
+ }
1177
+ function groupVars(group, vars) {
1178
+ return {
1179
+ ...vars,
1180
+ refundPolicyText: group.refundPolicyText ?? "",
1181
+ commitmentText: group.commitmentText ?? "",
1182
+ normsText: group.normsText ?? ""
1183
+ };
1184
+ }
1185
+ function renderTemplateBody(group, template, vars) {
1186
+ const tpl = group.emailTemplates?.[template];
1187
+ if (!tpl) return null;
1188
+ return render(tpl.text, groupVars(group, vars));
1189
+ }
1190
+ function isAlreadySent(priorRows) {
1191
+ return priorRows.some((row) => !row.error);
1192
+ }
1193
+ function planDelivery(input) {
1194
+ const tpl = input.group.emailTemplates?.[input.template];
1195
+ if (!tpl) return { deliver: false, reason: "template-missing" };
1196
+ if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
1197
+ const vars = groupVars(input.group, input.vars);
1198
+ const isProd = input.envName === "prod";
1199
+ const redirect = !isProd && !!input.group.debugEmail;
1200
+ const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
1201
+ const to = redirect ? input.group.debugEmail : input.to;
1202
+ const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
1203
+ const text3 = redirect ? `(dev redirect; original recipient: ${input.to})
1204
+
1205
+ ` + render(tpl.text, vars) : render(tpl.text, vars);
1206
+ return { deliver: true, transport, to, subject, text: text3, redirected: redirect };
1207
+ }
1208
+
1209
+ // src/notify.ts
1210
+ var EMAIL_TEMPLATE_NAMES = ["adminNotification", "paymentConfirmation", "prepEmail", "onboardingInvite"];
1211
+ async function sendTemplated(deps, input) {
1212
+ const { emailLog } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
1213
+ const prior = Array.isArray(emailLog) ? emailLog : [];
1214
+ if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
1215
+ const cloudflareReady = Boolean(deps.sender && deps.from);
1216
+ const decision = planDelivery({
1217
+ envName: deps.envName,
1218
+ group: input.group,
1219
+ template: input.template,
1220
+ to: input.to,
1221
+ vars: input.vars,
1222
+ cloudflareReady,
1223
+ force: input.force
1224
+ });
1225
+ if (!decision.deliver) return { sent: false, reason: decision.reason };
1226
+ let error;
1227
+ let messageId;
1228
+ if (decision.transport === "cloudflare" && deps.sender && deps.from) {
1229
+ try {
1230
+ const res = await deps.sender.send({
1231
+ from: deps.from,
1232
+ to: [decision.to],
1233
+ subject: decision.subject,
1234
+ text: decision.text,
1235
+ replyTo: input.group.replyTo
1236
+ });
1237
+ messageId = res.messageId;
1238
+ } catch (e) {
1239
+ error = e instanceof Error ? e.message : String(e);
1240
+ }
1241
+ }
1242
+ const id = deps.newId();
1243
+ const row = {
1244
+ id,
1245
+ groupId: input.group.id,
1246
+ to: decision.to,
1247
+ template: input.template,
1248
+ subject: decision.subject,
1249
+ body: decision.text,
1250
+ transport: decision.transport,
1251
+ redirected: decision.redirected,
1252
+ dedupeKey: input.dedupeKey,
1253
+ sentAt: deps.now(),
1254
+ ...input.applicationId ? { applicationId: input.applicationId } : {},
1255
+ ...messageId ? { messageId } : {},
1256
+ ...error ? { error } : {}
1257
+ };
1258
+ await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
1259
+ return error ? { sent: false, reason: error } : { sent: true };
1260
+ }
1261
+ function emailGroupFrom(row) {
1262
+ const str3 = (v) => typeof v === "string" ? v : void 0;
1263
+ const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
1264
+ return {
1265
+ id: String(row.id),
1266
+ name: String(row.name ?? ""),
1267
+ replyTo: str3(row.replyTo) ?? "",
1268
+ debugEmail: str3(row.debugEmail),
1269
+ refundPolicyText: str3(row.refundPolicyText),
1270
+ commitmentText: str3(row.commitmentText),
1271
+ normsText: str3(row.normsText),
1272
+ emailTemplates: templates
1273
+ };
1274
+ }
1275
+
1276
+ // src/pipeline.ts
1277
+ function canTransition(from, to, p) {
1278
+ const fi = p.stages.indexOf(from);
1279
+ const ti = p.stages.indexOf(to);
1280
+ return fi >= 0 && ti >= 0 && ti >= fi;
1281
+ }
1282
+ function canBook(status, p) {
1283
+ return p.bookableFrom.includes(status);
1284
+ }
1285
+ function canApprove(status, p) {
1286
+ return p.approvableFrom.includes(status);
1287
+ }
1288
+
1289
+ // src/approve-application.ts
1290
+ async function approveApplication(input) {
1291
+ const { db, env, ctx, app } = input;
1292
+ const id = String(app.id ?? "");
1293
+ const status = String(app.status ?? "");
1294
+ if (!id) return { ok: false, status: 404, error: "not found" };
1295
+ if (input.reason === "manual" && status !== "approved" && !canApprove(status, ctx.chapter.pipeline)) {
1296
+ return { ok: false, status: 409, error: `cannot approve from status "${status}"` };
1297
+ }
1298
+ if (input.reason === "interview_waiver") {
1299
+ if (typeof app.interviewWaivedAt !== "number") return { ok: false, status: 409, error: "interview waiver required" };
1300
+ if (status !== "approved" && !canTransition(status, "approved", ctx.chapter.pipeline)) {
1301
+ return { ok: false, status: 409, error: `cannot approve from status "${status}"` };
1302
+ }
1303
+ }
1304
+ const write = await db.transact(
1305
+ [{ t: "update", ns: "applications", id, attrs: { status: "approved" } }],
1306
+ { mutationId: `approve:${id}` }
1307
+ );
1308
+ const approved = { ...app, status: "approved" };
1309
+ await syncApplicationToCrm(crmDeps(db, ctx), { app: approved, stage: "approved" }).catch(() => void 0);
1310
+ const { promoteTo, send } = ctx.chapter.operations.onApprove;
1311
+ let rolePromoted = false;
1312
+ const secret = await getVaultSecret(db, "clerk_secret_key");
1313
+ if (promoteTo !== false && secret) {
1314
+ let userId = typeof app.clerkUserId === "string" ? app.clerkUserId : null;
1315
+ if (!userId && typeof app.email === "string") {
1316
+ const found = await clerkGetUserByEmail(secret, app.email);
1317
+ userId = found?.id ?? null;
1318
+ if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
1319
+ }
1320
+ if (userId) rolePromoted = await clerkSetRole(secret, userId, promoteTo);
1321
+ }
1322
+ let emailLogged = false;
1323
+ const groups = (await db.query({ groups: { $: { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 } } })).groups;
1324
+ const group = Array.isArray(groups) ? groups[0] : void 0;
1325
+ if (send !== false && group && typeof app.email === "string") {
1326
+ const result = await sendTemplated(
1327
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
1328
+ {
1329
+ group: emailGroupFrom(group),
1330
+ template: send,
1331
+ to: app.email,
1332
+ vars: { firstName: String(app.firstName ?? ""), membersUrl: `${input.origin}/members/` },
1333
+ applicationId: id,
1334
+ dedupeKey: `approve:${id}`
1335
+ }
1336
+ );
1337
+ emailLogged = result.sent;
1338
+ }
1339
+ return { ok: true, status: 200, rolePromoted, emailLogged, duplicate: write.duplicate };
1340
+ }
1341
+
1342
+ // src/join-continuation.ts
1343
+ function resolveJoinContinuation(input) {
1344
+ if (input.status === "approved") return "accepted";
1345
+ if (input.interviewWaived) {
1346
+ if (!input.payable) return "approve";
1347
+ if (input.status === input.initialStatus) {
1348
+ return input.subscriptionStarted ? "paymentPending" : "payment";
1349
+ }
1350
+ return "approve";
1351
+ }
1352
+ if (!input.payable) return "booking";
1353
+ if (input.status === input.initialStatus) {
1354
+ return input.subscriptionStarted ? "paymentPending" : "payment";
1355
+ }
1356
+ if (canBook(input.status, input.pipeline)) return "booking";
1357
+ return null;
1358
+ }
1359
+
1360
+ // src/join-continuation-server.ts
1361
+ async function continueJoin(db, ctx, env, origin, app, group) {
1362
+ if (typeof app.giftOfferId === "string") {
1363
+ if (typeof app.giftClaimId === "string") return "gift";
1364
+ return typeof app.giftCheckoutStartedAt === "number" ? "paymentPending" : "payment";
1365
+ }
1366
+ const stripeKey = await getVaultSecret(db, "stripe_secret_key");
1367
+ const offered = await loadOfferableTiers(db, group, Boolean(stripeKey));
1368
+ const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
1369
+ const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
1370
+ const state = resolveJoinContinuation({
1371
+ status: String(app.status ?? ""),
1372
+ initialStatus: ctx.chapter.pipeline.initial,
1373
+ pipeline: ctx.chapter.pipeline,
1374
+ payable,
1375
+ subscriptionStarted: typeof app.stripeSubscriptionId === "string" && app.stripeSubscriptionId !== "",
1376
+ interviewWaived: typeof app.interviewWaivedAt === "number"
1377
+ });
1378
+ if (state !== "approve") return state;
1379
+ const approved = await approveApplication({ db, env, ctx, app, origin, reason: "interview_waiver" });
1380
+ return approved.ok ? "accepted" : null;
1381
+ }
1382
+
1383
+ // src/signup-control-store.ts
1384
+ function asStored(row) {
1385
+ if (!row || typeof row.document !== "object" || row.document === null) return null;
1386
+ return row;
1387
+ }
1388
+ async function hasSignupControl(db, targetId, environment, active) {
1389
+ const where = { targetId, environment, ...active ? { active: true } : {} };
1390
+ if (db.aggregate) {
1391
+ return Number((await db.aggregate("signupControls", { count: true }, { where })).count ?? 0) > 0;
1392
+ }
1393
+ const rows4 = (await db.query({ signupControls: {} })).signupControls;
1394
+ return Array.isArray(rows4) && rows4.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
1395
+ }
1396
+ async function loadLatestSignupControl(db, targetId, environment) {
1397
+ if (!await hasSignupControl(db, targetId, environment)) return null;
1398
+ const rows4 = (await db.query({ signupControls: { $: {
1399
+ where: { targetId, environment },
1400
+ order: { revision: "desc" },
1401
+ limit: 1
1402
+ } } })).signupControls;
1403
+ const latest = Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
1404
+ return asStored(latest);
1405
+ }
1406
+ async function loadActiveSignupControl(db, targetId, environment) {
1407
+ if (!await hasSignupControl(db, targetId, environment, true)) return null;
1408
+ const rows4 = (await db.query({ signupControls: { $: {
1409
+ where: { targetId, environment, active: true },
1410
+ order: { revision: "desc" },
1411
+ limit: 1
1412
+ } } })).signupControls;
1413
+ const active = Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
1414
+ return asStored(active);
1415
+ }
1416
+ function controlledApplication(base, document) {
1417
+ const delegated = document?.application;
1418
+ return delegated ? { ...base, required: delegated.required, optional: delegated.optional, conditions: { ...delegated.conditions ?? {} } } : base;
1419
+ }
1420
+ function validateControlledApplication(chapter, document) {
1421
+ if (!document.application) return [];
1422
+ const declared = /* @__PURE__ */ new Set([...chapter.application.required, ...chapter.application.optional]);
1423
+ return [...document.application.required, ...document.application.optional].filter((field) => !declared.has(field)).map((field) => `document.application field "${field}" is not declared by this chapter schema`);
1424
+ }
1425
+ function groupAttrs(document) {
1426
+ const first4 = [...document.tiers].filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder)[0];
1427
+ const firstPaid = [...document.tiers].find((tier) => tier.available && tier.priceCents > 0);
1428
+ return {
1429
+ ...first4 ? { standardPriceCents: first4.priceCents } : {},
1430
+ foundingDiscountCents: 0,
1431
+ ...firstPaid?.stripePriceId ? { stripePriceId: firstPaid.stripePriceId } : {},
1432
+ ...document.policy ?? {},
1433
+ ...document.scheduling ? { schedulingJson: document.scheduling } : {}
1434
+ };
1435
+ }
1436
+ var byPublicId = (ns, value) => ({
1437
+ ns,
1438
+ attr: "id",
1439
+ value
1440
+ });
1441
+ async function materializeSignupControl(db, envelope, previous, receivedAt) {
1442
+ const { document, digest } = envelope;
1443
+ const currentRows = (await db.query({ tiers: { $: {
1444
+ where: { groupId: document.target.chapterId },
1445
+ limit: 100
1446
+ } } })).tiers ?? [];
1447
+ const incoming = new Set(document.tiers.map((tier) => tier.id));
1448
+ const ops = [];
1449
+ if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
1450
+ for (const row of currentRows) {
1451
+ const id2 = String(row.id ?? "");
1452
+ if (id2 && !incoming.has(id2)) {
1453
+ ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
1454
+ }
1455
+ }
1456
+ for (const tier of document.tiers) {
1457
+ const tierRef = byPublicId("tiers", tier.id);
1458
+ ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
1459
+ id: tier.id,
1460
+ groupId: document.target.chapterId,
1461
+ name: tier.name,
1462
+ priceCents: tier.priceCents,
1463
+ ...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
1464
+ ...tier.blurb ? { blurb: tier.blurb } : {},
1465
+ sortOrder: tier.sortOrder,
1466
+ active: tier.available,
1467
+ createdAt: receivedAt
1468
+ } });
1469
+ const retract = [
1470
+ ...!tier.stripePriceId ? ["stripePriceId"] : [],
1471
+ ...!tier.blurb ? ["blurb"] : []
1472
+ ];
1473
+ if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
1474
+ }
1475
+ const groupRef = byPublicId("groups", document.target.chapterId);
1476
+ const attrs = groupAttrs(document);
1477
+ ops.push({
1478
+ t: "update",
1479
+ ns: "groups",
1480
+ id: groupRef,
1481
+ attrs
946
1482
  });
947
- const mutationId = `apply:${applicant.applicationId}`;
948
- const extra = applicant.extra ?? {};
949
- if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
950
- try {
951
- return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
952
- } catch {
953
- return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
1483
+ if (!("stripePriceId" in attrs)) {
1484
+ ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
954
1485
  }
1486
+ const id = `signup-control:${document.target.environment}:${document.revision}`;
1487
+ ops.push({ t: "update", ns: "signupControls", id, attrs: {
1488
+ id,
1489
+ sourceId: document.source.appId,
1490
+ targetId: document.target.chapterId,
1491
+ environment: document.target.environment,
1492
+ revision: document.revision,
1493
+ digest,
1494
+ document,
1495
+ active: true,
1496
+ receivedAt
1497
+ } });
1498
+ await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
955
1499
  }
956
1500
 
957
- // src/email.ts
958
- function render(template, vars) {
959
- return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
960
- }
961
- function groupVars(group, vars) {
1501
+ // src/session.ts
1502
+ function applicationSummary(app) {
962
1503
  return {
963
- ...vars,
964
- refundPolicyText: group.refundPolicyText ?? "",
965
- commitmentText: group.commitmentText ?? "",
966
- normsText: group.normsText ?? ""
1504
+ id: app.id,
1505
+ firstName: app.firstName ?? null,
1506
+ lastName: app.lastName ?? null,
1507
+ email: app.email ?? null,
1508
+ status: app.status,
1509
+ createdAt: app.createdAt ?? null,
1510
+ meetingLink: app.meetingLink ?? null,
1511
+ paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
1512
+ renewalAt: app.renewalAt ?? null,
1513
+ canceled: app.canceled === true,
1514
+ tierId: app.tierId ?? null
967
1515
  };
968
1516
  }
969
- function renderTemplateBody(group, template, vars) {
970
- const tpl = group.emailTemplates?.[template];
971
- if (!tpl) return null;
972
- return render(tpl.text, groupVars(group, vars));
973
- }
974
- function isAlreadySent(priorRows) {
975
- return priorRows.some((row) => !row.error);
1517
+ function memberApplication(app, meeting, defaultTimezone) {
1518
+ const summary = applicationSummary(app);
1519
+ let meetingAt = app.meetingAt ?? null;
1520
+ let meetUrl = null;
1521
+ let timezone = defaultTimezone;
1522
+ if (meeting) {
1523
+ timezone = meeting.timezone ?? timezone;
1524
+ if (meeting.status === "scheduled") {
1525
+ meetingAt = meeting.startAt ?? null;
1526
+ meetUrl = meeting.meetUrl ?? null;
1527
+ } else {
1528
+ meetingAt = null;
1529
+ }
1530
+ }
1531
+ return { ...summary, meetingAt, meetUrl, timezone };
976
1532
  }
977
- function planDelivery(input) {
978
- const tpl = input.group.emailTemplates?.[input.template];
979
- if (!tpl) return { deliver: false, reason: "template-missing" };
980
- if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
981
- const vars = groupVars(input.group, input.vars);
982
- const isProd = input.envName === "prod";
983
- const redirect = !isProd && !!input.group.debugEmail;
984
- const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
985
- const to = redirect ? input.group.debugEmail : input.to;
986
- const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
987
- const text2 = redirect ? `(dev redirect; original recipient: ${input.to})
988
1533
 
989
- ` + render(tpl.text, vars) : render(tpl.text, vars);
990
- return { deliver: true, transport, to, subject, text: text2, redirected: redirect };
1534
+ // src/clerk.ts
1535
+ var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
1536
+ function clerkInviteRequest(input) {
1537
+ return {
1538
+ path: "/v1/invitations",
1539
+ body: {
1540
+ email_address: input.email,
1541
+ notify: true,
1542
+ ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
1543
+ ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
1544
+ }
1545
+ };
991
1546
  }
992
-
993
- // src/notify.ts
994
- var EMAIL_TEMPLATE_NAMES = ["adminNotification", "paymentConfirmation", "prepEmail", "onboardingInvite"];
995
- async function sendTemplated(deps, input) {
996
- const { emailLog } = await deps.db.query({ emailLog: { $: { where: { dedupeKey: input.dedupeKey } } } });
997
- const prior = Array.isArray(emailLog) ? emailLog : [];
998
- if (isAlreadySent(prior)) return { sent: true, reason: "already-sent" };
999
- const cloudflareReady = Boolean(deps.sender && deps.from);
1000
- const decision = planDelivery({
1001
- envName: deps.envName,
1002
- group: input.group,
1003
- template: input.template,
1004
- to: input.to,
1005
- vars: input.vars,
1006
- cloudflareReady,
1007
- force: input.force
1547
+ async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
1548
+ const { path, body } = clerkInviteRequest(input);
1549
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
1550
+ method: "POST",
1551
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1552
+ body: JSON.stringify(body)
1008
1553
  });
1009
- if (!decision.deliver) return { sent: false, reason: decision.reason };
1010
- let error;
1011
- let messageId;
1012
- if (decision.transport === "cloudflare" && deps.sender && deps.from) {
1013
- try {
1014
- const res = await deps.sender.send({
1015
- from: deps.from,
1016
- to: [decision.to],
1017
- subject: decision.subject,
1018
- text: decision.text,
1019
- replyTo: input.group.replyTo
1020
- });
1021
- messageId = res.messageId;
1022
- } catch (e) {
1023
- error = e instanceof Error ? e.message : String(e);
1554
+ return res.ok ? { ok: true, status: res.status } : heal(res.status);
1555
+ }
1556
+ function clerkUserRequest(input) {
1557
+ return {
1558
+ path: "/v1/users",
1559
+ body: {
1560
+ email_address: [input.email],
1561
+ skip_password_requirement: true,
1562
+ ...input.firstName ? { first_name: input.firstName } : {},
1563
+ ...input.lastName ? { last_name: input.lastName } : {},
1564
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
1565
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
1024
1566
  }
1025
- }
1026
- const id = deps.newId();
1027
- const row = {
1028
- id,
1029
- groupId: input.group.id,
1030
- to: decision.to,
1031
- template: input.template,
1032
- subject: decision.subject,
1033
- body: decision.text,
1034
- transport: decision.transport,
1035
- redirected: decision.redirected,
1036
- dedupeKey: input.dedupeKey,
1037
- sentAt: deps.now(),
1038
- ...input.applicationId ? { applicationId: input.applicationId } : {},
1039
- ...messageId ? { messageId } : {},
1040
- ...error ? { error } : {}
1041
1567
  };
1042
- await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
1043
- return error ? { sent: false, reason: error } : { sent: true };
1044
1568
  }
1045
- function emailGroupFrom(row) {
1046
- const str3 = (v) => typeof v === "string" ? v : void 0;
1047
- const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
1048
- return {
1049
- id: String(row.id),
1050
- name: String(row.name ?? ""),
1051
- replyTo: str3(row.replyTo) ?? "",
1052
- debugEmail: str3(row.debugEmail),
1053
- refundPolicyText: str3(row.refundPolicyText),
1054
- commitmentText: str3(row.commitmentText),
1055
- normsText: str3(row.normsText),
1056
- emailTemplates: templates
1569
+ async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
1570
+ const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
1571
+ method: "PATCH",
1572
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1573
+ body: JSON.stringify({
1574
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
1575
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
1576
+ })
1577
+ });
1578
+ return res.ok;
1579
+ }
1580
+ async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
1581
+ const auth = { authorization: `Bearer ${secretKey}` };
1582
+ const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
1583
+ if (!found.ok) return false;
1584
+ const users = await found.json().catch(() => null);
1585
+ const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
1586
+ if (!id) return false;
1587
+ return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
1588
+ }
1589
+ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
1590
+ const { path, body } = clerkUserRequest(input);
1591
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
1592
+ method: "POST",
1593
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1594
+ body: JSON.stringify(body)
1595
+ });
1596
+ if (res.ok) return { ok: true, status: res.status };
1597
+ const healed = heal(res.status);
1598
+ const repair = input.repairMetadata ?? {
1599
+ ...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
1600
+ ...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
1057
1601
  };
1602
+ if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
1603
+ const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
1604
+ ...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
1605
+ ...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
1606
+ }, fetchImpl).catch(() => false);
1607
+ return { ...healed, refreshed };
1058
1608
  }
1059
1609
 
1060
1610
  // src/worker-provisioning.ts
@@ -1184,8 +1734,12 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
1184
1734
 
1185
1735
  // src/worker-routes.ts
1186
1736
  async function memberSessionApplication(db, chapterId, email) {
1187
- const apps = (await db.query({ applications: { $: { where: { email }, order: { createdAt: "desc" }, limit: 1 } } })).applications;
1188
- const app = Array.isArray(apps) ? apps[0] : void 0;
1737
+ let apps = (await db.query({ applications: { $: {
1738
+ where: { emailNormalized: normalizeAdmissionEmail(email) },
1739
+ limit: 20
1740
+ } } })).applications;
1741
+ if (!apps?.length) apps = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
1742
+ const app = Array.isArray(apps) ? [...apps].sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0] : void 0;
1189
1743
  if (!app) return null;
1190
1744
  const meetings = (await db.query({ meetings: { $: { where: { applicationId: app.id, status: "scheduled" }, order: { createdAt: "desc" }, limit: 1 } } })).meetings;
1191
1745
  const meeting = Array.isArray(meetings) ? meetings[0] : void 0;
@@ -1241,7 +1795,7 @@ var handleCrm = async (req, url, env, ctx) => {
1241
1795
  const crmBase = ctx.crmBase;
1242
1796
  if (url.pathname !== crmBase && !url.pathname.startsWith(crmBase + "/")) return null;
1243
1797
  const db = ctx.makeDb(env);
1244
- const routes = (0, import_crm3.createCrmRoutes)({
1798
+ const routes = (0, import_crm4.createCrmRoutes)({
1245
1799
  crm: ctx.chapter.crm,
1246
1800
  db,
1247
1801
  authorize: async (r) => {
@@ -1287,7 +1841,8 @@ var handleMember = async (req, url, env, ctx) => {
1287
1841
  membershipPage: activeControl.document.membershipPage,
1288
1842
  application: activeControl.document.application,
1289
1843
  theme: activeControl.document.theme
1290
- } } : {}
1844
+ } } : {},
1845
+ giftOffers: publicGiftOffers(activeControl?.document.giftOffers, Date.now())
1291
1846
  });
1292
1847
  }
1293
1848
  if (req.method === "GET" && url.pathname === "/api/join/resume") {
@@ -1326,18 +1881,9 @@ var handleMember = async (req, url, env, ctx) => {
1326
1881
  })).groups;
1327
1882
  const group = Array.isArray(groups) ? groups[0] : void 0;
1328
1883
  if (!group) return json({ error: "not found" }, 404);
1329
- const stripeKey = await getVaultSecret(db, "stripe_secret_key");
1330
- const status = String(app.status ?? "");
1331
- const offered = await loadOfferableTiers(db, group, Boolean(stripeKey));
1332
- const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
1333
- const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
1334
- if (!payable) return json({ state: "booking", applicationId });
1335
- if (status === chapter.pipeline.initial && app.stripeSubscriptionId) {
1336
- return json({ state: "paymentPending", applicationId });
1337
- }
1338
- if (status === chapter.pipeline.initial) return json({ state: "payment", applicationId });
1339
- if (canBook(status, chapter.pipeline)) return json({ state: "booking", applicationId });
1340
- return json({ error: `application cannot resume from status "${status}"` }, 409);
1884
+ const state = await continueJoin(db, ctx, env, url.origin, app, group);
1885
+ if (state) return json({ state, applicationId, ...state === "gift" ? { claimId: app.giftClaimId } : {} });
1886
+ return json({ error: `application cannot resume from status "${String(app.status ?? "")}"` }, 409);
1341
1887
  }
1342
1888
  if (req.method === "POST" && url.pathname === "/api/applications") {
1343
1889
  const raw = await req.text();
@@ -1353,12 +1899,19 @@ var handleMember = async (req, url, env, ctx) => {
1353
1899
  const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
1354
1900
  const effectiveChapter = activeControl ? { ...chapter, application: controlledApplication(chapter.application, activeControl.document) } : chapter;
1355
1901
  const offeredTierIds = typeof parsed.tierId === "string" && parsed.tierId ? (await loadTiers(db, { id: chapter.id })).map((tier) => tier.id) : void 0;
1902
+ const now = Date.now();
1903
+ const requestedGiftId = typeof parsed.giftOfferId === "string" ? parsed.giftOfferId : "";
1904
+ const giftOffer = requestedGiftId ? findGiftOffer(activeControl?.document.giftOffers, requestedGiftId, now) : null;
1905
+ if (requestedGiftId && !giftOffer) return json({ error: "gift offer is unavailable" }, 400);
1906
+ const effectiveGrant = !giftOffer && typeof parsed.email === "string" ? await findAdmissionGrant(db, chapter.id, parsed.email, now) : null;
1356
1907
  const result = await submitApplication(db, effectiveChapter, parsed, {
1357
1908
  submissionId,
1358
1909
  groupId: chapter.id,
1359
- now: Date.now(),
1910
+ now,
1360
1911
  newId: () => crypto.randomUUID(),
1361
- ...offeredTierIds ? { tierIds: offeredTierIds } : {}
1912
+ ...offeredTierIds ? { tierIds: offeredTierIds } : {},
1913
+ ...effectiveGrant?.decision === "waive_interview" ? { admissionGrant: effectiveGrant } : {},
1914
+ ...giftOffer ? { giftOfferId: giftOffer.id } : {}
1362
1915
  });
1363
1916
  if (!result.ok) return json({ error: result.error }, 400);
1364
1917
  if (!result.duplicate) {
@@ -1367,10 +1920,16 @@ var handleMember = async (req, url, env, ctx) => {
1367
1920
  }
1368
1921
  await provisionApplicant(db, effectiveChapter, result.id, parsed);
1369
1922
  }
1923
+ const apps = (await db.query({ applications: { $: { where: { id: result.id }, limit: 1 } } })).applications;
1924
+ const canonical2 = Array.isArray(apps) ? apps[0] : void 0;
1925
+ const groups = (await db.query({ groups: { $: { where: { id: chapter.id }, limit: 1 } } })).groups;
1926
+ const group = Array.isArray(groups) ? groups[0] : void 0;
1927
+ const state = canonical2 && group ? await continueJoin(db, ctx, env, url.origin, canonical2, group) : null;
1370
1928
  return json({
1371
1929
  id: result.id,
1372
1930
  duplicate: result.duplicate,
1373
1931
  status: result.status,
1932
+ ...state ? { state } : {},
1374
1933
  // Echoed so a site can see (and assert in an integration test) whether its
1375
1934
  // join page actually posted the ack. Absent consent is otherwise invisible.
1376
1935
  disclaimerAckAt: result.disclaimerAckAt
@@ -1426,8 +1985,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
1426
1985
  payload,
1427
1986
  sourceId ? { sourceId } : {}
1428
1987
  );
1429
- const record = normalizeSharedRecord(payload, sourceId);
1430
- return json({ recordId, type: record.type });
1988
+ const record3 = normalizeSharedRecord(payload, sourceId);
1989
+ return json({ recordId, type: record3.type });
1431
1990
  } catch (error) {
1432
1991
  return json({
1433
1992
  error: error instanceof Error ? error.message : "invalid shared record"
@@ -1435,31 +1994,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
1435
1994
  }
1436
1995
  };
1437
1996
 
1438
- // src/worker-routes-signup-control.ts
1439
- var import_db3 = require("@odla-ai/db");
1440
-
1441
- // src/signup-control-canonical.ts
1442
- var encoder = new TextEncoder();
1443
- function canonicalValue(value) {
1444
- if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
1445
- return JSON.stringify(value);
1446
- }
1447
- if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
1448
- if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
1449
- const record = value;
1450
- const entries = Object.keys(record).sort().flatMap((key) => {
1451
- const item = record[key];
1452
- return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
1453
- });
1454
- return `{${entries.join(",")}}`;
1455
- }
1456
- function canonicalSignupControl(value) {
1457
- return canonicalValue(value);
1458
- }
1459
- async function signupControlDigest(document) {
1460
- const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
1461
- return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
1462
- }
1997
+ // src/worker-routes-signup-control.ts
1998
+ var import_db3 = require("@odla-ai/db");
1463
1999
 
1464
2000
  // src/signup-control-contract.ts
1465
2001
  var SIGNUP_CONTROL_PATH = "/api/signup-control";
@@ -1490,9 +2026,9 @@ var MAX_BENEFITS = 24;
1490
2026
  var MAX_MARKDOWN = 65536;
1491
2027
  var MAX_LABEL = 160;
1492
2028
  var objectRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1493
- function rejectUnknownKeys(value, allowed, path, errors) {
2029
+ function rejectUnknownKeys(value, allowed2, path, errors) {
1494
2030
  for (const key of Object.keys(value)) {
1495
- if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
2031
+ if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
1496
2032
  }
1497
2033
  }
1498
2034
  function boundedString(value, path, errors, max, required = false) {
@@ -1504,10 +2040,10 @@ function boundedString(value, path, errors, max, required = false) {
1504
2040
  errors.push(`${path} must be a string`);
1505
2041
  return void 0;
1506
2042
  }
1507
- const text2 = value.trim();
1508
- if (required && !text2) errors.push(`${path} is required`);
1509
- if (text2.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
1510
- return text2;
2043
+ const text3 = value.trim();
2044
+ if (required && !text3) errors.push(`${path} is required`);
2045
+ if (text3.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
2046
+ return text3;
1511
2047
  }
1512
2048
  function validateMembershipPageDocument(input, options = {}) {
1513
2049
  const errors = [];
@@ -1556,8 +2092,8 @@ function validateMembershipPageDocument(input, options = {}) {
1556
2092
  else {
1557
2093
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
1558
2094
  benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
1559
- const text2 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
1560
- return text2 ? [text2] : [];
2095
+ const text3 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
2096
+ return text3 ? [text3] : [];
1561
2097
  });
1562
2098
  }
1563
2099
  }
@@ -1631,7 +2167,7 @@ function validateMembershipPageDocument(input, options = {}) {
1631
2167
  var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
1632
2168
  var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
1633
2169
  var TIER = /^[a-z][a-z0-9-]{0,79}$/;
1634
- var PRICE = /^price_[A-Za-z0-9]{12,}$/;
2170
+ var PRICE2 = /^price_[A-Za-z0-9]{12,}$/;
1635
2171
  var ROOT_KEYS2 = /* @__PURE__ */ new Set([
1636
2172
  "schemaVersion",
1637
2173
  "revision",
@@ -1643,14 +2179,15 @@ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
1643
2179
  "application",
1644
2180
  "policy",
1645
2181
  "scheduling",
1646
- "theme"
2182
+ "theme",
2183
+ "giftOffers"
1647
2184
  ]);
1648
2185
  var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
1649
2186
  var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1650
- function unknownKeys(value, allowed, path, errors) {
1651
- for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
2187
+ function unknownKeys(value, allowed2, path, errors) {
2188
+ for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
1652
2189
  }
1653
- function text(value, path, errors, max = 65536) {
2190
+ function text2(value, path, errors, max = 65536) {
1654
2191
  if (typeof value !== "string" || !value.trim()) {
1655
2192
  errors.push(`${path} is required`);
1656
2193
  return "";
@@ -1673,23 +2210,23 @@ function tiersOf(value, errors) {
1673
2210
  return [];
1674
2211
  }
1675
2212
  unknownKeys(item, TIER_KEYS2, path, errors);
1676
- const id = text(item.id, `${path}.id`, errors, 80);
2213
+ const id = text2(item.id, `${path}.id`, errors, 80);
1677
2214
  if (id && !TIER.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
1678
2215
  if (seen.has(id)) errors.push(`${path}.id duplicates "${id}"`);
1679
2216
  seen.add(id);
1680
- const name = text(item.name, `${path}.name`, errors, 160);
2217
+ const name = text2(item.name, `${path}.name`, errors, 160);
1681
2218
  if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
1682
2219
  if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
1683
2220
  if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
1684
2221
  errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
1685
2222
  }
1686
- if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE.test(item.stripePriceId))) {
2223
+ if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE2.test(item.stripePriceId))) {
1687
2224
  errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
1688
2225
  }
1689
2226
  if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
1690
2227
  errors.push(`${path}.stripePriceId is required for an available paid tier`);
1691
2228
  }
1692
- const blurb = item.blurb === void 0 ? void 0 : text(item.blurb, `${path}.blurb`, errors, 4e3);
2229
+ const blurb = item.blurb === void 0 ? void 0 : text2(item.blurb, `${path}.blurb`, errors, 4e3);
1693
2230
  return [{
1694
2231
  id,
1695
2232
  name,
@@ -1715,138 +2252,776 @@ function applicationOf(value, errors) {
1715
2252
  if (input.copy !== void 0 && (!copy || Object.values(copy).some((item) => typeof item !== "string"))) {
1716
2253
  errors.push("document.application.copy must contain only string values");
1717
2254
  }
1718
- return {
1719
- required: resolved.required,
1720
- optional: resolved.optional,
1721
- ...Object.keys(resolved.conditions).length ? { conditions: resolved.conditions } : {},
1722
- ...copy ? { copy } : {}
1723
- };
1724
- } catch (error) {
1725
- errors.push(error instanceof Error ? error.message : "document.application is invalid");
1726
- return void 0;
2255
+ return {
2256
+ required: resolved.required,
2257
+ optional: resolved.optional,
2258
+ ...Object.keys(resolved.conditions).length ? { conditions: resolved.conditions } : {},
2259
+ ...copy ? { copy } : {}
2260
+ };
2261
+ } catch (error) {
2262
+ errors.push(error instanceof Error ? error.message : "document.application is invalid");
2263
+ return void 0;
2264
+ }
2265
+ }
2266
+ function policyOf(value, errors) {
2267
+ if (value === void 0) return void 0;
2268
+ const input = recordOf(value);
2269
+ if (!input) {
2270
+ errors.push("document.policy must be an object");
2271
+ return void 0;
2272
+ }
2273
+ const allowed2 = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
2274
+ unknownKeys(input, allowed2, "document.policy", errors);
2275
+ const result = {};
2276
+ for (const key of allowed2) {
2277
+ if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
2278
+ result[key] = text2(input[key], `document.policy.${key}`, errors);
2279
+ }
2280
+ return result;
2281
+ }
2282
+ function themeOf(value, errors) {
2283
+ if (value === void 0) return void 0;
2284
+ const input = recordOf(value);
2285
+ const tokens = recordOf(input?.tokens);
2286
+ if (!input || !tokens) {
2287
+ errors.push("document.theme.tokens must be an object");
2288
+ return void 0;
2289
+ }
2290
+ unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
2291
+ if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
2292
+ let serializedLength = 0;
2293
+ for (const [key, token] of Object.entries(tokens)) {
2294
+ if (!/^--ui-[a-z0-9][a-z0-9-]{0,94}$/.test(key)) {
2295
+ errors.push(`document.theme.tokens.${key} must be an allowlisted --ui-* custom property`);
2296
+ }
2297
+ const rendered = String(token).trim();
2298
+ serializedLength += key.length + rendered.length + 4;
2299
+ if (typeof token !== "string" && typeof token !== "number" || typeof token === "number" && !Number.isFinite(token) || !rendered || rendered.length > 1e3 || /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i.test(rendered)) {
2300
+ errors.push(`document.theme.tokens.${key} must be a bounded breakout-safe CSS value`);
2301
+ }
2302
+ }
2303
+ if (serializedLength > 65536) errors.push("document.theme.tokens exceeds the serialized size bound");
2304
+ return { tokens };
2305
+ }
2306
+ function validateSignupControlDocument(input) {
2307
+ const errors = [];
2308
+ const root = recordOf(input);
2309
+ if (!root) return { ok: false, errors: ["document must be an object"] };
2310
+ unknownKeys(root, ROOT_KEYS2, "document", errors);
2311
+ if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
2312
+ if (!Number.isSafeInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
2313
+ if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
2314
+ const source = recordOf(root.source);
2315
+ const target = recordOf(root.target);
2316
+ const appId = text2(source?.appId, "document.source.appId", errors, 63);
2317
+ const chapterId = text2(target?.chapterId, "document.target.chapterId", errors, 63);
2318
+ const environment = text2(target?.environment, "document.target.environment", errors, 63);
2319
+ if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
2320
+ if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
2321
+ if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
2322
+ if (target?.stripeMode !== "test" && target?.stripeMode !== "live") errors.push("document.target.stripeMode must be test or live");
2323
+ const tiers = tiersOf(root.tiers, errors);
2324
+ const available = tiers.filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder);
2325
+ const page = validateMembershipPageDocument(root.membershipPage, { tiers: available });
2326
+ if (!page.ok) errors.push(...page.errors);
2327
+ else if (page.value.tiers.some((tier, index) => tier.tierId !== available[index]?.id)) {
2328
+ errors.push("document.membershipPage.tiers must follow the available tier display order");
2329
+ }
2330
+ const application = applicationOf(root.application, errors);
2331
+ const policy = policyOf(root.policy, errors);
2332
+ let scheduling;
2333
+ if (root.scheduling !== void 0) {
2334
+ const checked = validateScheduling(root.scheduling);
2335
+ if (!checked.ok) errors.push(...Object.entries(checked.errors).map(([key, value]) => `document.scheduling.${key}: ${value}`));
2336
+ else scheduling = root.scheduling;
2337
+ }
2338
+ const theme = themeOf(root.theme, errors);
2339
+ const giftOffers = root.giftOffers === void 0 ? void 0 : validateGiftOfferCatalog(root.giftOffers, new Set(tiers.map((tier) => tier.id)));
2340
+ if (giftOffers && !giftOffers.ok) errors.push(...giftOffers.errors);
2341
+ if (errors.length || !page.ok) return { ok: false, errors };
2342
+ return { ok: true, value: {
2343
+ schemaVersion: 1,
2344
+ revision: Number(root.revision),
2345
+ generatedAt: Number(root.generatedAt),
2346
+ source: { appId },
2347
+ target: { chapterId, environment, stripeMode: target?.stripeMode },
2348
+ tiers,
2349
+ membershipPage: page.value,
2350
+ ...application ? { application } : {},
2351
+ ...policy ? { policy } : {},
2352
+ ...scheduling ? { scheduling } : {},
2353
+ ...theme ? { theme } : {},
2354
+ ...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
2355
+ } };
2356
+ }
2357
+
2358
+ // src/worker-routes-signup-control.ts
2359
+ var reject = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
2360
+ var handleSignupControl = async (req, url, env, ctx) => {
2361
+ if (req.method !== "POST" || url.pathname !== SIGNUP_CONTROL_PATH) return null;
2362
+ const authority = ctx.chapter.signupControl;
2363
+ if (!authority) return json({ error: "not found" }, 404);
2364
+ const db = ctx.makeDb(env);
2365
+ const secret = await getVaultSecret(db, authority.secretName);
2366
+ if (!secret) return json({ error: "unauthorized" }, 401);
2367
+ const verified = await (0, import_db3.verifyFederatedRequest)(req, { secret, senders: [authority.sourceId] });
2368
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
2369
+ let input;
2370
+ try {
2371
+ input = JSON.parse(verified.body);
2372
+ } catch {
2373
+ return reject("invalid_document", 400, ["request body must be JSON"]);
2374
+ }
2375
+ const digest = typeof input.digest === "string" ? input.digest : "";
2376
+ const checked = validateSignupControlDocument(input.document);
2377
+ if (!checked.ok) return reject("invalid_document", 400, checked.errors);
2378
+ const document = checked.value;
2379
+ if (document.source.appId !== authority.sourceId) return reject("wrong_source", 403);
2380
+ if (document.target.chapterId !== ctx.chapter.id) return reject("wrong_target", 409);
2381
+ if (document.target.environment !== env.ODLA_ENV) return reject("wrong_environment", 409);
2382
+ if (document.target.stripeMode !== authority.stripeMode) return reject("wrong_stripe_mode", 409);
2383
+ const applicationErrors = validateControlledApplication(ctx.chapter, document);
2384
+ if (applicationErrors.length) return reject("invalid_document", 400, applicationErrors);
2385
+ if (!digest || await signupControlDigest(document) !== digest) return reject("invalid_digest", 400);
2386
+ const latest = await loadLatestSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
2387
+ if (latest && document.revision < latest.revision) return reject("stale_revision", 409);
2388
+ if (latest && document.revision === latest.revision) {
2389
+ if (latest.digest !== digest) return reject("conflicting_revision", 409);
2390
+ return json({
2391
+ ok: true,
2392
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
2393
+ revision: latest.revision,
2394
+ digest: latest.digest,
2395
+ status: "replayed",
2396
+ acknowledgedAt: latest.receivedAt
2397
+ });
2398
+ }
2399
+ const receivedAt = Date.now();
2400
+ try {
2401
+ await materializeSignupControl(db, { digest, document }, latest, receivedAt);
2402
+ } catch {
2403
+ return reject("materialization_failed", 503);
2404
+ }
2405
+ return json({
2406
+ ok: true,
2407
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
2408
+ revision: document.revision,
2409
+ digest,
2410
+ status: "accepted",
2411
+ acknowledgedAt: receivedAt
2412
+ });
2413
+ };
2414
+
2415
+ // src/worker-routes-member-content.ts
2416
+ var import_db4 = require("@odla-ai/db");
2417
+
2418
+ // src/member-content-canonical.ts
2419
+ var encoder2 = new TextEncoder();
2420
+ function canonicalValue2(value) {
2421
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
2422
+ return JSON.stringify(value);
2423
+ }
2424
+ if (Array.isArray(value)) return `[${value.map(canonicalValue2).join(",")}]`;
2425
+ if (typeof value !== "object") throw new TypeError("member content contains an unsupported value");
2426
+ const record3 = value;
2427
+ const entries = Object.keys(record3).sort().flatMap((key) => {
2428
+ const item = record3[key];
2429
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue2(item)}`];
2430
+ });
2431
+ return `{${entries.join(",")}}`;
2432
+ }
2433
+ function canonicalMemberContent(value) {
2434
+ return canonicalValue2(value);
2435
+ }
2436
+ async function memberContentDigest(document) {
2437
+ const digest = await crypto.subtle.digest("SHA-256", encoder2.encode(canonicalMemberContent(document)));
2438
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
2439
+ }
2440
+
2441
+ // src/member-content-validation-helpers.ts
2442
+ var ID3 = /^[a-z][a-z0-9-]{0,79}$/;
2443
+ var SLUG2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2444
+ var ROOT_KEYS3 = /* @__PURE__ */ new Set(["schemaVersion", "revision", "generatedAt", "source", "target", "pages", "upsellOverrides"]);
2445
+ var SOURCE_KEYS = /* @__PURE__ */ new Set(["type", "appId"]);
2446
+ var TARGET_KEYS = /* @__PURE__ */ new Set(["chapterId", "environment"]);
2447
+ var PAGE_KEYS = /* @__PURE__ */ new Set(["id", "slug", "title", "summary", "navigation", "audience", "teaserMarkdown", "bodyMarkdown", "upsell"]);
2448
+ var NAV_KEYS = /* @__PURE__ */ new Set(["section", "order", "parentId"]);
2449
+ var AUDIENCE_KEYS = /* @__PURE__ */ new Set(["kind", "tierIds"]);
2450
+ var UPSELL_KEYS = /* @__PURE__ */ new Set(["owner", "offer"]);
2451
+ var OFFER_KEYS = /* @__PURE__ */ new Set(["tierId", "label", "bodyMarkdown", "ctaLabel"]);
2452
+ var OVERRIDE_KEYS = /* @__PURE__ */ new Set(["pageId", "offer"]);
2453
+ var MAX_PAGES = 100;
2454
+ var MAX_MARKDOWN2 = 65536;
2455
+ var MAX_TEXT = 240;
2456
+ var record2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
2457
+ function unknownKeys2(value, allowed2, path, errors) {
2458
+ for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
2459
+ }
2460
+ function textValue(value, path, errors, max = MAX_TEXT, required = false) {
2461
+ if (value === void 0) {
2462
+ if (required) errors.push(`${path} is required`);
2463
+ return void 0;
2464
+ }
2465
+ if (typeof value !== "string") {
2466
+ errors.push(`${path} must be a string`);
2467
+ return void 0;
2468
+ }
2469
+ const valueText = value.trim();
2470
+ if (required && !valueText) errors.push(`${path} is required`);
2471
+ if (valueText.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
2472
+ return valueText;
2473
+ }
2474
+ function offerValue(value, path, errors, tiers, audience) {
2475
+ const raw = record2(value);
2476
+ if (!raw) {
2477
+ errors.push(`${path} must be an object`);
2478
+ return void 0;
2479
+ }
2480
+ unknownKeys2(raw, OFFER_KEYS, path, errors);
2481
+ const tierId = textValue(raw.tierId, `${path}.tierId`, errors, 80, true) ?? "";
2482
+ if (tierId && !ID3.test(tierId)) errors.push(`${path}.tierId must be a lowercase kebab-case id`);
2483
+ if (tiers.size && !tiers.has(tierId)) errors.push(`${path}.tierId must reference a declared tier`);
2484
+ if (audience?.kind === "tiers" && tierId && !audience.tierIds.includes(tierId)) {
2485
+ errors.push(`${path}.tierId must grant access to this page`);
2486
+ }
2487
+ const label = textValue(raw.label, `${path}.label`, errors);
2488
+ const bodyMarkdown = textValue(raw.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2);
2489
+ const ctaLabel = textValue(raw.ctaLabel, `${path}.ctaLabel`, errors);
2490
+ return {
2491
+ tierId,
2492
+ ...label ? { label } : {},
2493
+ ...bodyMarkdown ? { bodyMarkdown } : {},
2494
+ ...ctaLabel ? { ctaLabel } : {}
2495
+ };
2496
+ }
2497
+
2498
+ // src/member-content-validation.ts
2499
+ function validateMemberContentDocument(input, options = {}) {
2500
+ const errors = [];
2501
+ const root = record2(input);
2502
+ if (!root) return { ok: false, errors: ["document must be an object"] };
2503
+ unknownKeys2(root, ROOT_KEYS3, "document", errors);
2504
+ if (root.schemaVersion !== MEMBER_CONTENT_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
2505
+ if (!Number.isInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
2506
+ if (!Number.isInteger(root.generatedAt) || Number(root.generatedAt) < 0) errors.push("document.generatedAt must be a non-negative integer timestamp");
2507
+ const sourceRaw = record2(root.source);
2508
+ let source = { type: "chapter", appId: "invalid" };
2509
+ if (!sourceRaw) errors.push("document.source must be an object");
2510
+ else {
2511
+ unknownKeys2(sourceRaw, SOURCE_KEYS, "document.source", errors);
2512
+ const type = sourceRaw.type;
2513
+ const appId = textValue(sourceRaw.appId, "document.source.appId", errors, 63, true) ?? "";
2514
+ if (type !== "parent" && type !== "chapter") errors.push("document.source.type must be parent or chapter");
2515
+ if (appId && !ID3.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
2516
+ if (options.sourceType && type !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
2517
+ if (options.sourceAppId && appId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
2518
+ if (type === "parent" || type === "chapter") source = { type, appId };
2519
+ }
2520
+ let target;
2521
+ if (root.target !== void 0) {
2522
+ const targetRaw = record2(root.target);
2523
+ if (!targetRaw) errors.push("document.target must be an object");
2524
+ else {
2525
+ unknownKeys2(targetRaw, TARGET_KEYS, "document.target", errors);
2526
+ const chapterId = textValue(targetRaw.chapterId, "document.target.chapterId", errors, 63, true) ?? "";
2527
+ const environment = textValue(targetRaw.environment, "document.target.environment", errors, 80, true) ?? "";
2528
+ target = { chapterId, environment };
2529
+ }
2530
+ }
2531
+ if (source.type === "parent" && !target) errors.push("document.target is required for parent content");
2532
+ if (source.type === "chapter" && target) errors.push("document.target is not supported for chapter content");
2533
+ if (!Array.isArray(root.pages)) errors.push("document.pages must be an array");
2534
+ const rawPages = Array.isArray(root.pages) ? root.pages : [];
2535
+ if (rawPages.length > MAX_PAGES) errors.push(`document.pages supports at most ${MAX_PAGES} entries`);
2536
+ const tierIds = new Set((options.tiers ?? []).map((tier) => tier.id));
2537
+ const knownParentIds = new Set((options.parentPages ?? []).map((page) => page.id));
2538
+ const ids = /* @__PURE__ */ new Set();
2539
+ const slugs = /* @__PURE__ */ new Set();
2540
+ const pages = [];
2541
+ for (const [index, value] of rawPages.slice(0, MAX_PAGES).entries()) {
2542
+ const path = `document.pages[${index}]`;
2543
+ const raw = record2(value);
2544
+ if (!raw) {
2545
+ errors.push(`${path} must be an object`);
2546
+ continue;
2547
+ }
2548
+ unknownKeys2(raw, PAGE_KEYS, path, errors);
2549
+ const id = textValue(raw.id, `${path}.id`, errors, 80, true) ?? "";
2550
+ const slug = textValue(raw.slug, `${path}.slug`, errors, 120, true) ?? "";
2551
+ const title = textValue(raw.title, `${path}.title`, errors, MAX_TEXT, true) ?? "";
2552
+ if (id && !ID3.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
2553
+ if (slug && !SLUG2.test(slug)) errors.push(`${path}.slug must be a lowercase URL slug`);
2554
+ if (ids.has(id) || knownParentIds.has(id)) errors.push(`${path}.id duplicates page "${id}"`);
2555
+ if (slugs.has(slug) || (options.parentPages ?? []).some((page) => page.slug === slug)) errors.push(`${path}.slug duplicates "${slug}"`);
2556
+ ids.add(id);
2557
+ slugs.add(slug);
2558
+ const navRaw = record2(raw.navigation);
2559
+ let navigation = { section: "General", order: index };
2560
+ if (!navRaw) errors.push(`${path}.navigation must be an object`);
2561
+ else {
2562
+ unknownKeys2(navRaw, NAV_KEYS, `${path}.navigation`, errors);
2563
+ const section = textValue(navRaw.section, `${path}.navigation.section`, errors, 120, true) ?? "";
2564
+ const order = navRaw.order;
2565
+ if (!Number.isInteger(order) || Number(order) < 0 || Number(order) > 1e6) {
2566
+ errors.push(`${path}.navigation.order must be an integer from 0 to 1,000,000`);
2567
+ }
2568
+ const parentId = textValue(navRaw.parentId, `${path}.navigation.parentId`, errors, 80);
2569
+ navigation = { section, order: Number.isInteger(order) ? Number(order) : index, ...parentId ? { parentId } : {} };
2570
+ }
2571
+ const audienceRaw = record2(raw.audience);
2572
+ let audience = { kind: "all-members" };
2573
+ if (!audienceRaw) errors.push(`${path}.audience must be an object`);
2574
+ else {
2575
+ unknownKeys2(audienceRaw, AUDIENCE_KEYS, `${path}.audience`, errors);
2576
+ if (audienceRaw.kind === "all-members") {
2577
+ if (audienceRaw.tierIds !== void 0) errors.push(`${path}.audience.tierIds is not supported for all-members`);
2578
+ } else if (audienceRaw.kind === "tiers") {
2579
+ if (!Array.isArray(audienceRaw.tierIds) || audienceRaw.tierIds.length === 0) {
2580
+ errors.push(`${path}.audience.tierIds must be a non-empty array`);
2581
+ }
2582
+ const values = Array.isArray(audienceRaw.tierIds) ? audienceRaw.tierIds : [];
2583
+ const normalized = [];
2584
+ for (const [tierIndex, value2] of values.entries()) {
2585
+ const tier = textValue(value2, `${path}.audience.tierIds[${tierIndex}]`, errors, 80, true) ?? "";
2586
+ if (normalized.includes(tier)) errors.push(`${path}.audience.tierIds duplicates "${tier}"`);
2587
+ if (tierIds.size && !tierIds.has(tier)) errors.push(`${path}.audience.tierIds references undeclared tier "${tier}"`);
2588
+ normalized.push(tier);
2589
+ }
2590
+ audience = { kind: "tiers", tierIds: normalized };
2591
+ } else errors.push(`${path}.audience.kind must be all-members or tiers`);
2592
+ }
2593
+ const summary = textValue(raw.summary, `${path}.summary`, errors, 2e3);
2594
+ const teaserMarkdown = textValue(raw.teaserMarkdown, `${path}.teaserMarkdown`, errors, MAX_MARKDOWN2);
2595
+ const bodyMarkdown = textValue(raw.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2, true) ?? "";
2596
+ let upsell;
2597
+ if (raw.upsell !== void 0) {
2598
+ const upsellRaw = record2(raw.upsell);
2599
+ if (!upsellRaw) errors.push(`${path}.upsell must be an object`);
2600
+ else {
2601
+ unknownKeys2(upsellRaw, UPSELL_KEYS, `${path}.upsell`, errors);
2602
+ const owner = upsellRaw.owner;
2603
+ if (owner !== "parent" && owner !== "chapter") errors.push(`${path}.upsell.owner must be parent or chapter`);
2604
+ if (source.type === "chapter" && owner === "parent") errors.push(`${path}.upsell.owner cannot be parent on a chapter page`);
2605
+ if (source.type === "parent" && owner === "parent" && upsellRaw.offer === void 0) errors.push(`${path}.upsell.offer is required when the parent owns the offer`);
2606
+ if (source.type === "parent" && owner === "chapter" && upsellRaw.offer !== void 0) errors.push(`${path}.upsell.offer must be set by the chapter after delegation`);
2607
+ const offer = upsellRaw.offer === void 0 ? void 0 : offerValue(upsellRaw.offer, `${path}.upsell.offer`, errors, tierIds, audience);
2608
+ if (owner === "parent") upsell = { owner, offer: offer ?? { tierId: "" } };
2609
+ else if (owner === "chapter") upsell = { owner, ...offer ? { offer } : {} };
2610
+ }
2611
+ }
2612
+ pages.push({ id, slug, title, ...summary ? { summary } : {}, navigation, audience, ...teaserMarkdown ? { teaserMarkdown } : {}, bodyMarkdown, ...upsell ? { upsell } : {} });
2613
+ }
2614
+ const allIds = /* @__PURE__ */ new Set([...knownParentIds, ...ids]);
2615
+ for (const [index, page] of pages.entries()) {
2616
+ const parentId = page.navigation.parentId;
2617
+ if (parentId && !allIds.has(parentId)) errors.push(`document.pages[${index}].navigation.parentId references unknown page "${parentId}"`);
2618
+ if (parentId === page.id) errors.push(`document.pages[${index}].navigation.parentId cannot reference itself`);
2619
+ }
2620
+ for (const page of pages) {
2621
+ const seen = /* @__PURE__ */ new Set([page.id]);
2622
+ let parentId = page.navigation.parentId;
2623
+ while (parentId) {
2624
+ if (seen.has(parentId)) {
2625
+ errors.push(`document.pages contains a navigation cycle at "${page.id}"`);
2626
+ break;
2627
+ }
2628
+ seen.add(parentId);
2629
+ parentId = pages.find((candidate) => candidate.id === parentId)?.navigation.parentId;
2630
+ }
2631
+ }
2632
+ let upsellOverrides;
2633
+ if (root.upsellOverrides !== void 0) {
2634
+ if (source.type !== "chapter") errors.push("document.upsellOverrides is supported only for chapter content");
2635
+ if (!Array.isArray(root.upsellOverrides)) errors.push("document.upsellOverrides must be an array");
2636
+ const values = Array.isArray(root.upsellOverrides) ? root.upsellOverrides : [];
2637
+ const seen = /* @__PURE__ */ new Set();
2638
+ upsellOverrides = [];
2639
+ for (const [index, value] of values.entries()) {
2640
+ const path = `document.upsellOverrides[${index}]`;
2641
+ const raw = record2(value);
2642
+ if (!raw) {
2643
+ errors.push(`${path} must be an object`);
2644
+ continue;
2645
+ }
2646
+ unknownKeys2(raw, OVERRIDE_KEYS, path, errors);
2647
+ const pageId = textValue(raw.pageId, `${path}.pageId`, errors, 80, true) ?? "";
2648
+ if (seen.has(pageId)) errors.push(`${path}.pageId duplicates "${pageId}"`);
2649
+ seen.add(pageId);
2650
+ const parentPage = (options.parentPages ?? []).find((page) => page.id === pageId);
2651
+ if (!parentPage) errors.push(`${path}.pageId must reference a parent page`);
2652
+ else if (parentPage.upsell?.owner !== "chapter") errors.push(`${path}.pageId does not delegate upsell authority to the chapter`);
2653
+ const offer = offerValue(raw.offer, `${path}.offer`, errors, tierIds, parentPage?.audience);
2654
+ if (offer) upsellOverrides.push({ pageId, offer });
2655
+ }
2656
+ }
2657
+ if (errors.length) return { ok: false, errors };
2658
+ return { ok: true, value: {
2659
+ schemaVersion: MEMBER_CONTENT_SCHEMA_VERSION,
2660
+ revision: Number(root.revision),
2661
+ generatedAt: Number(root.generatedAt),
2662
+ source,
2663
+ ...target ? { target } : {},
2664
+ pages,
2665
+ ...upsellOverrides ? { upsellOverrides } : {}
2666
+ } };
2667
+ }
2668
+
2669
+ // src/member-content.ts
2670
+ var MEMBER_CONTENT_SCHEMA_VERSION = 1;
2671
+ var MEMBER_CONTENT_PATH = "/api/member-content";
2672
+ var MEMBER_CONTENT_CONTROL_PATH = "/api/member-content-control";
2673
+ var pageOrder = (a, b) => a.navigation.section.localeCompare(b.navigation.section) || a.navigation.order - b.navigation.order || a.title.localeCompare(b.title) || a.id.localeCompare(b.id);
2674
+ function composeMemberContent(parent, chapter) {
2675
+ const pages = [];
2676
+ const parentPages = /* @__PURE__ */ new Map();
2677
+ for (const page of parent?.pages ?? []) {
2678
+ const effective = { ...page, source: parent.source };
2679
+ parentPages.set(page.id, effective);
2680
+ pages.push(effective);
2681
+ }
2682
+ for (const override of chapter?.upsellOverrides ?? []) {
2683
+ const page = parentPages.get(override.pageId);
2684
+ if (page?.upsell?.owner === "chapter") page.upsell = { owner: "chapter", offer: override.offer };
2685
+ }
2686
+ for (const page of chapter?.pages ?? []) {
2687
+ if (parentPages.has(page.id) || pages.some((candidate) => candidate.slug === page.slug)) {
2688
+ throw new TypeError(`Chapter member page "${page.id}" conflicts with parent content`);
2689
+ }
2690
+ pages.push({ ...page, source: chapter.source });
2691
+ }
2692
+ return {
2693
+ pages: pages.sort(pageOrder),
2694
+ parentRevision: parent?.revision ?? null,
2695
+ chapterRevision: chapter?.revision ?? null
2696
+ };
2697
+ }
2698
+ var allowed = (page, viewer) => viewer.admin || page.audience.kind === "all-members" || viewer.tierId !== null && page.audience.tierIds.includes(viewer.tierId);
2699
+ function projectMemberContent(content, viewer, tiers) {
2700
+ const authority = new Map(tiers.filter((tier) => tier.active !== false).map((tier) => [tier.id, tier]));
2701
+ return {
2702
+ viewer: { ...viewer },
2703
+ revisions: { parent: content.parentRevision, chapter: content.chapterRevision },
2704
+ pages: content.pages.map((page) => {
2705
+ const canRead = allowed(page, viewer);
2706
+ const offer = !canRead ? page.upsell?.offer : void 0;
2707
+ const tier = offer ? authority.get(offer.tierId) : void 0;
2708
+ const upsell = offer && tier && typeof tier.name === "string" && typeof tier.priceCents === "number" ? { ...offer, tierName: tier.name, priceCents: tier.priceCents } : void 0;
2709
+ return {
2710
+ id: page.id,
2711
+ slug: page.slug,
2712
+ title: page.title,
2713
+ ...page.summary ? { summary: page.summary } : {},
2714
+ navigation: page.navigation,
2715
+ audience: page.audience,
2716
+ source: page.source,
2717
+ access: canRead ? "allowed" : "locked",
2718
+ ...page.teaserMarkdown ? { teaserMarkdown: page.teaserMarkdown } : {},
2719
+ ...canRead ? { bodyMarkdown: page.bodyMarkdown } : {},
2720
+ ...upsell ? { upsell } : {}
2721
+ };
2722
+ })
2723
+ };
2724
+ }
2725
+
2726
+ // src/member-content-store.ts
2727
+ var control = (row) => row && row.document && typeof row.document === "object" ? row : null;
2728
+ var local = (row) => row && row.document && typeof row.document === "object" ? row : null;
2729
+ async function exists(db, ns, where) {
2730
+ if (db.aggregate) return Number((await db.aggregate(ns, { count: true }, { where })).count ?? 0) > 0;
2731
+ const rows4 = (await db.query({ [ns]: {} }))[ns] ?? [];
2732
+ return rows4.some((row) => Object.entries(where).every(([key, value]) => row[key] === value));
2733
+ }
2734
+ async function loadActiveMemberContentControl(db, targetId, environment) {
2735
+ const where = { targetId, environment, active: true };
2736
+ if (!await exists(db, "memberContentControls", where)) return null;
2737
+ const rows4 = (await db.query({ memberContentControls: { $: {
2738
+ where,
2739
+ order: { revision: "desc" },
2740
+ limit: 1
2741
+ } } })).memberContentControls;
2742
+ return control(Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision) - Number(a.revision))[0] : void 0);
2743
+ }
2744
+ async function loadLatestMemberContentControl(db, targetId, environment) {
2745
+ const where = { targetId, environment };
2746
+ if (!await exists(db, "memberContentControls", where)) return null;
2747
+ const rows4 = (await db.query({ memberContentControls: { $: {
2748
+ where,
2749
+ order: { revision: "desc" },
2750
+ limit: 1
2751
+ } } })).memberContentControls;
2752
+ return control(Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision) - Number(a.revision))[0] : void 0);
2753
+ }
2754
+ async function loadLocalMemberContent(db, chapterId, environment) {
2755
+ const id = `member-content-local:${environment}`;
2756
+ const rows4 = (await db.query({ memberContentLocal: { $: { where: { id }, limit: 1 } } })).memberContentLocal;
2757
+ return local(Array.isArray(rows4) ? rows4[0] : void 0);
2758
+ }
2759
+ async function materializeMemberContentControl(db, envelope, previous, receivedAt) {
2760
+ const { document, digest } = envelope;
2761
+ const target = document.target;
2762
+ const id = `member-content-control:${target.environment}:${document.revision}`;
2763
+ const ops = [];
2764
+ if (previous?.active) {
2765
+ ops.push({ t: "update", ns: "memberContentControls", id: previous.id, attrs: { active: false } });
2766
+ }
2767
+ ops.push({ t: "update", ns: "memberContentControls", id, attrs: {
2768
+ id,
2769
+ sourceId: document.source.appId,
2770
+ targetId: target.chapterId,
2771
+ environment: target.environment,
2772
+ revision: document.revision,
2773
+ digest,
2774
+ document,
2775
+ active: true,
2776
+ receivedAt
2777
+ } });
2778
+ await db.transact(ops, { mutationId: `member-content-control:${target.environment}:${document.revision}:${digest}` });
2779
+ }
2780
+ async function saveLocalMemberContent(db, document, environment, expectedRevision, updatedAt, updatedBy) {
2781
+ const id = `member-content-local:${environment}`;
2782
+ const current = await loadLocalMemberContent(db, document.source.appId, environment);
2783
+ if ((current?.revision ?? 0) !== expectedRevision) throw new Error("member_content_revision_conflict");
2784
+ const row = {
2785
+ id,
2786
+ chapterId: document.source.appId,
2787
+ environment,
2788
+ revision: document.revision,
2789
+ document,
2790
+ updatedAt,
2791
+ ...updatedBy ? { updatedBy } : {}
2792
+ };
2793
+ await db.transact([{ t: "update", ns: "memberContentLocal", id, attrs: row }], {
2794
+ mutationId: `member-content-local:${environment}:${document.revision}`,
2795
+ guards: current ? [{ ns: "memberContentLocal", id, exists: true, equals: { revision: expectedRevision } }] : [{ ns: "memberContentLocal", id, exists: false }]
2796
+ });
2797
+ return row;
2798
+ }
2799
+
2800
+ // src/worker-routes-member-content.ts
2801
+ var ADMIN_PATH = "/api/admin/member-content";
2802
+ var BODY_CAP = 1e6;
2803
+ var reject2 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
2804
+ async function groupAndTiers(db, chapterId) {
2805
+ const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
2806
+ const group = Array.isArray(groups) ? groups[0] : void 0;
2807
+ if (!group) return { group: null, tiers: [] };
2808
+ return { group, tiers: await loadTiers(db, group) };
2809
+ }
2810
+ async function memberTierId(db, chapterId, email) {
2811
+ if (!email) return null;
2812
+ let rows4 = (await db.query({ applications: { $: {
2813
+ where: { emailNormalized: normalizeAdmissionEmail(email) },
2814
+ limit: 20
2815
+ } } })).applications;
2816
+ if (!rows4?.length) rows4 = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
2817
+ const latest = Array.isArray(rows4) ? [...rows4].filter((row) => String(row.groupId ?? chapterId) === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0] : void 0;
2818
+ return typeof latest?.tierId === "string" && latest.tierId ? latest.tierId : null;
2819
+ }
2820
+ async function activeDocuments(db, chapterId, environment) {
2821
+ const [parent, chapter] = await Promise.all([
2822
+ loadActiveMemberContentControl(db, chapterId, environment),
2823
+ loadLocalMemberContent(db, chapterId, environment)
2824
+ ]);
2825
+ return { parent, chapter };
2826
+ }
2827
+ var handleMemberContentControl = async (req, url, env, ctx) => {
2828
+ if (req.method !== "POST" || url.pathname !== MEMBER_CONTENT_CONTROL_PATH) return null;
2829
+ const settings = ctx.chapter.memberContent?.parent;
2830
+ if (!settings) return json({ error: "not found" }, 404);
2831
+ const db = ctx.makeDb(env);
2832
+ const secret = await getVaultSecret(db, settings.secretName);
2833
+ if (!secret) return json({ error: "unauthorized" }, 401);
2834
+ const verified = await (0, import_db4.verifyFederatedRequest)(req, { secret, senders: [settings.sourceId] });
2835
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
2836
+ let input;
2837
+ try {
2838
+ input = JSON.parse(verified.body);
2839
+ } catch {
2840
+ return reject2("invalid_document", 400, ["request body must be JSON"]);
2841
+ }
2842
+ const digest = typeof input.digest === "string" ? input.digest : "";
2843
+ const initial = validateMemberContentDocument(input.document, {
2844
+ sourceType: "parent"
2845
+ });
2846
+ if (!initial.ok) return reject2("invalid_document", 400, initial.errors);
2847
+ const document = initial.value;
2848
+ if (document.source.appId !== settings.sourceId) return reject2("wrong_source", 403);
2849
+ if (document.target?.chapterId !== ctx.chapter.id) return reject2("wrong_target", 409);
2850
+ if (document.target.environment !== env.ODLA_ENV) return reject2("wrong_environment", 409);
2851
+ const { tiers } = await groupAndTiers(db, ctx.chapter.id);
2852
+ const checked = validateMemberContentDocument(document, {
2853
+ sourceType: "parent",
2854
+ sourceAppId: settings.sourceId,
2855
+ tiers
2856
+ });
2857
+ if (!checked.ok) return reject2("invalid_document", 400, checked.errors);
2858
+ const local2 = await loadLocalMemberContent(db, ctx.chapter.id, env.ODLA_ENV);
2859
+ if (local2) {
2860
+ const localCheck = validateMemberContentDocument(local2.document, {
2861
+ sourceType: "chapter",
2862
+ sourceAppId: ctx.chapter.id,
2863
+ tiers,
2864
+ parentPages: document.pages
2865
+ });
2866
+ if (!localCheck.ok) {
2867
+ return reject2("invalid_document", 409, localCheck.errors.map((error) => `existing chapter content: ${error}`));
2868
+ }
1727
2869
  }
1728
- }
1729
- function policyOf(value, errors) {
1730
- if (value === void 0) return void 0;
1731
- const input = recordOf(value);
1732
- if (!input) {
1733
- errors.push("document.policy must be an object");
1734
- return void 0;
2870
+ if (!digest || await memberContentDigest(document) !== digest) return reject2("invalid_digest", 400);
2871
+ const latest = await loadLatestMemberContentControl(db, ctx.chapter.id, env.ODLA_ENV);
2872
+ if (latest && document.revision < latest.revision) return reject2("stale_revision", 409);
2873
+ if (latest && document.revision === latest.revision) {
2874
+ if (latest.digest !== digest) return reject2("conflicting_revision", 409);
2875
+ return json({
2876
+ ok: true,
2877
+ target: document.target,
2878
+ revision: latest.revision,
2879
+ digest: latest.digest,
2880
+ status: "replayed",
2881
+ acknowledgedAt: latest.receivedAt
2882
+ });
1735
2883
  }
1736
- const allowed = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
1737
- unknownKeys(input, allowed, "document.policy", errors);
1738
- const result = {};
1739
- for (const key of allowed) {
1740
- if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
1741
- result[key] = text(input[key], `document.policy.${key}`, errors);
2884
+ const receivedAt = Date.now();
2885
+ try {
2886
+ await materializeMemberContentControl(db, { digest, document }, latest, receivedAt);
2887
+ } catch {
2888
+ return reject2("materialization_failed", 503);
1742
2889
  }
1743
- return result;
1744
- }
1745
- function themeOf(value, errors) {
1746
- if (value === void 0) return void 0;
1747
- const input = recordOf(value);
1748
- const tokens = recordOf(input?.tokens);
1749
- if (!input || !tokens) {
1750
- errors.push("document.theme.tokens must be an object");
1751
- return void 0;
2890
+ return json({
2891
+ ok: true,
2892
+ target: document.target,
2893
+ revision: document.revision,
2894
+ digest,
2895
+ status: "accepted",
2896
+ acknowledgedAt: receivedAt
2897
+ });
2898
+ };
2899
+ var handleMemberContent = async (req, url, env, ctx) => {
2900
+ if (req.method !== "GET" || url.pathname !== MEMBER_CONTENT_PATH) return null;
2901
+ if (!ctx.chapter.memberContent) return json({ error: "not found" }, 404);
2902
+ const user = await ctx.verifyUser(req, env);
2903
+ if (!user) return json({ error: "unauthorized" }, 401);
2904
+ const db = ctx.makeDb(env);
2905
+ const role = await ctx.roleFor(db, user);
2906
+ const admin = await ctx.isAdmin(db, user);
2907
+ const roleIndex = ctx.chapter.auth.ladder.indexOf(role);
2908
+ const memberIndex = Math.max(0, ctx.chapter.auth.ladder.length - 2);
2909
+ if (!admin && roleIndex < memberIndex) return json({ error: "member access required" }, 403);
2910
+ const [{ parent, chapter }, { group, tiers }, tierId] = await Promise.all([
2911
+ activeDocuments(db, ctx.chapter.id, env.ODLA_ENV),
2912
+ groupAndTiers(db, ctx.chapter.id),
2913
+ memberTierId(db, ctx.chapter.id, user.email)
2914
+ ]);
2915
+ const effective = composeMemberContent(parent?.document, chapter?.document);
2916
+ let offerable = tiers;
2917
+ if (group) {
2918
+ const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
2919
+ offerable = await loadOfferableTiers(db, group, Boolean(stripeSecret));
1752
2920
  }
1753
- unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
1754
- if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
1755
- let serializedLength = 0;
1756
- for (const [key, token] of Object.entries(tokens)) {
1757
- if (!/^--ui-[a-z0-9][a-z0-9-]{0,94}$/.test(key)) {
1758
- errors.push(`document.theme.tokens.${key} must be an allowlisted --ui-* custom property`);
1759
- }
1760
- const rendered = String(token).trim();
1761
- serializedLength += key.length + rendered.length + 4;
1762
- if (typeof token !== "string" && typeof token !== "number" || typeof token === "number" && !Number.isFinite(token) || !rendered || rendered.length > 1e3 || /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i.test(rendered)) {
1763
- errors.push(`document.theme.tokens.${key} must be a bounded breakout-safe CSS value`);
1764
- }
2921
+ return json(projectMemberContent(effective, { tierId, admin }, offerable));
2922
+ };
2923
+ var handleAdminMemberContent = async (req, url, env, ctx) => {
2924
+ if (url.pathname !== ADMIN_PATH) return null;
2925
+ if (!ctx.chapter.memberContent) return json({ error: "not found" }, 404);
2926
+ const user = await ctx.verifyUser(req, env);
2927
+ if (!user) return json({ error: "unauthorized" }, 401);
2928
+ const db = ctx.makeDb(env);
2929
+ if (!await ctx.isAdmin(db, user)) return json({ error: "forbidden" }, 403);
2930
+ const [{ parent, chapter }, { tiers }] = await Promise.all([
2931
+ activeDocuments(db, ctx.chapter.id, env.ODLA_ENV),
2932
+ groupAndTiers(db, ctx.chapter.id)
2933
+ ]);
2934
+ if (req.method === "GET") {
2935
+ const effective = composeMemberContent(parent?.document, chapter?.document);
2936
+ return json({
2937
+ parent: parent ? { revision: parent.revision, digest: parent.digest, document: parent.document } : null,
2938
+ chapter: chapter ? { revision: chapter.revision, document: chapter.document } : null,
2939
+ effective,
2940
+ tiers: tiers.map(({ id, name, priceCents, active }) => ({ id, name, priceCents, active })),
2941
+ localAuthoring: ctx.chapter.memberContent.localAuthoring
2942
+ });
1765
2943
  }
1766
- if (serializedLength > 65536) errors.push("document.theme.tokens exceeds the serialized size bound");
1767
- return { tokens };
1768
- }
1769
- function validateSignupControlDocument(input) {
1770
- const errors = [];
1771
- const root = recordOf(input);
1772
- if (!root) return { ok: false, errors: ["document must be an object"] };
1773
- unknownKeys(root, ROOT_KEYS2, "document", errors);
1774
- if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
1775
- if (!Number.isSafeInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
1776
- if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
1777
- const source = recordOf(root.source);
1778
- const target = recordOf(root.target);
1779
- const appId = text(source?.appId, "document.source.appId", errors, 63);
1780
- const chapterId = text(target?.chapterId, "document.target.chapterId", errors, 63);
1781
- const environment = text(target?.environment, "document.target.environment", errors, 63);
1782
- if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
1783
- if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
1784
- if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
1785
- if (target?.stripeMode !== "test" && target?.stripeMode !== "live") errors.push("document.target.stripeMode must be test or live");
1786
- const tiers = tiersOf(root.tiers, errors);
1787
- const available = tiers.filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder);
1788
- const page = validateMembershipPageDocument(root.membershipPage, { tiers: available });
1789
- if (!page.ok) errors.push(...page.errors);
1790
- else if (page.value.tiers.some((tier, index) => tier.tierId !== available[index]?.id)) {
1791
- errors.push("document.membershipPage.tiers must follow the available tier display order");
2944
+ if (req.method !== "PUT") return json({ error: "method not allowed" }, 405);
2945
+ if (!ctx.chapter.memberContent.localAuthoring) return json({ error: "local authoring is disabled" }, 403);
2946
+ const raw = await req.text();
2947
+ if (raw.length > BODY_CAP) return json({ error: "request body too large" }, 413);
2948
+ let input;
2949
+ try {
2950
+ input = JSON.parse(raw);
2951
+ } catch {
2952
+ return json({ error: "invalid JSON body" }, 400);
1792
2953
  }
1793
- const application = applicationOf(root.application, errors);
1794
- const policy = policyOf(root.policy, errors);
1795
- let scheduling;
1796
- if (root.scheduling !== void 0) {
1797
- const checked = validateScheduling(root.scheduling);
1798
- if (!checked.ok) errors.push(...Object.entries(checked.errors).map(([key, value]) => `document.scheduling.${key}: ${value}`));
1799
- else scheduling = root.scheduling;
2954
+ const expectedRevision = Number(input.expectedRevision);
2955
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
2956
+ return json({ error: "expectedRevision must be a non-negative integer" }, 400);
1800
2957
  }
1801
- const theme = themeOf(root.theme, errors);
1802
- if (errors.length || !page.ok) return { ok: false, errors };
1803
- return { ok: true, value: {
1804
- schemaVersion: 1,
1805
- revision: Number(root.revision),
1806
- generatedAt: Number(root.generatedAt),
1807
- source: { appId },
1808
- target: { chapterId, environment, stripeMode: target?.stripeMode },
2958
+ const now = Date.now();
2959
+ const documentInput = withLocalAuthority(input.document, expectedRevision + 1, now, ctx.chapter.id);
2960
+ const checked = validateMemberContentDocument(documentInput, {
2961
+ sourceType: "chapter",
2962
+ sourceAppId: ctx.chapter.id,
1809
2963
  tiers,
1810
- membershipPage: page.value,
1811
- ...application ? { application } : {},
1812
- ...policy ? { policy } : {},
1813
- ...scheduling ? { scheduling } : {},
1814
- ...theme ? { theme } : {}
1815
- } };
2964
+ parentPages: parent?.document.pages ?? []
2965
+ });
2966
+ if (!checked.ok) return json({ error: "invalid member content", errors: checked.errors }, 400);
2967
+ try {
2968
+ const saved = await saveLocalMemberContent(
2969
+ db,
2970
+ checked.value,
2971
+ env.ODLA_ENV,
2972
+ expectedRevision,
2973
+ now,
2974
+ user.email ?? user.userId
2975
+ );
2976
+ return json({ ok: true, revision: saved.revision, document: saved.document });
2977
+ } catch (error) {
2978
+ if (error instanceof Error && error.message === "member_content_revision_conflict") {
2979
+ return json({ error: "member content changed; reload before saving" }, 409);
2980
+ }
2981
+ return json({ error: "member content could not be saved" }, 503);
2982
+ }
2983
+ };
2984
+ function withLocalAuthority(value, revision, generatedAt, appId) {
2985
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
2986
+ return {
2987
+ ...value,
2988
+ schemaVersion: 1,
2989
+ revision,
2990
+ generatedAt,
2991
+ source: { type: "chapter", appId }
2992
+ };
1816
2993
  }
1817
2994
 
1818
- // src/worker-routes-signup-control.ts
1819
- var reject = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
1820
- var handleSignupControl = async (req, url, env, ctx) => {
1821
- if (req.method !== "POST" || url.pathname !== SIGNUP_CONTROL_PATH) return null;
2995
+ // src/worker-routes-admission-grants.ts
2996
+ var import_db5 = require("@odla-ai/db");
2997
+ var reject3 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
2998
+ var handleAdmissionGrants = async (req, url, env, ctx) => {
2999
+ if (req.method !== "POST" || url.pathname !== ADMISSION_GRANTS_PATH) return null;
1822
3000
  const authority = ctx.chapter.signupControl;
1823
3001
  if (!authority) return json({ error: "not found" }, 404);
1824
3002
  const db = ctx.makeDb(env);
1825
3003
  const secret = await getVaultSecret(db, authority.secretName);
1826
3004
  if (!secret) return json({ error: "unauthorized" }, 401);
1827
- const verified = await (0, import_db3.verifyFederatedRequest)(req, { secret, senders: [authority.sourceId] });
3005
+ const verified = await (0, import_db5.verifyFederatedRequest)(req, { secret, senders: [authority.sourceId] });
1828
3006
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
1829
3007
  let input;
1830
3008
  try {
1831
3009
  input = JSON.parse(verified.body);
1832
3010
  } catch {
1833
- return reject("invalid_document", 400, ["request body must be JSON"]);
3011
+ return reject3("invalid_document", 400, ["request body must be JSON"]);
1834
3012
  }
1835
3013
  const digest = typeof input.digest === "string" ? input.digest : "";
1836
- const checked = validateSignupControlDocument(input.document);
1837
- if (!checked.ok) return reject("invalid_document", 400, checked.errors);
3014
+ const checked = validateAdmissionGrantDocument(input.document);
3015
+ if (!checked.ok) return reject3("invalid_document", 400, checked.errors);
1838
3016
  const document = checked.value;
1839
- if (document.source.appId !== authority.sourceId) return reject("wrong_source", 403);
1840
- if (document.target.chapterId !== ctx.chapter.id) return reject("wrong_target", 409);
1841
- if (document.target.environment !== env.ODLA_ENV) return reject("wrong_environment", 409);
1842
- if (document.target.stripeMode !== authority.stripeMode) return reject("wrong_stripe_mode", 409);
1843
- const applicationErrors = validateControlledApplication(ctx.chapter, document);
1844
- if (applicationErrors.length) return reject("invalid_document", 400, applicationErrors);
1845
- if (!digest || await signupControlDigest(document) !== digest) return reject("invalid_digest", 400);
1846
- const latest = await loadLatestSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
1847
- if (latest && document.revision < latest.revision) return reject("stale_revision", 409);
3017
+ if (document.source.appId !== authority.sourceId) return reject3("wrong_source", 403);
3018
+ if (document.target.chapterId !== ctx.chapter.id) return reject3("wrong_target", 409);
3019
+ if (document.target.environment !== env.ODLA_ENV) return reject3("wrong_environment", 409);
3020
+ if (!digest || await signupControlDigest(document) !== digest) return reject3("invalid_digest", 400);
3021
+ const latest = await loadLatestAdmissionRevision(db, authority.sourceId, ctx.chapter.id, env.ODLA_ENV);
3022
+ if (latest && document.revision < latest.revision) return reject3("stale_revision", 409);
1848
3023
  if (latest && document.revision === latest.revision) {
1849
- if (latest.digest !== digest) return reject("conflicting_revision", 409);
3024
+ if (latest.digest !== digest) return reject3("conflicting_revision", 409);
1850
3025
  return json({
1851
3026
  ok: true,
1852
3027
  target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
@@ -1858,9 +3033,9 @@ var handleSignupControl = async (req, url, env, ctx) => {
1858
3033
  }
1859
3034
  const receivedAt = Date.now();
1860
3035
  try {
1861
- await materializeSignupControl(db, { digest, document }, latest, receivedAt);
3036
+ await materializeAdmissionGrants(db, { digest, document }, latest, receivedAt);
1862
3037
  } catch {
1863
- return reject("materialization_failed", 503);
3038
+ return reject3("materialization_failed", 503);
1864
3039
  }
1865
3040
  return json({
1866
3041
  ok: true,
@@ -1872,92 +3047,101 @@ var handleSignupControl = async (req, url, env, ctx) => {
1872
3047
  });
1873
3048
  };
1874
3049
 
1875
- // src/crm-sync.ts
1876
- var import_crm4 = require("@odla-ai/crm");
1877
- var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
1878
- var crmDeps = (db, ctx) => ({
1879
- crm: ctx.chapter.crm,
1880
- db,
1881
- now: () => Date.now(),
1882
- newId: () => crypto.randomUUID(),
1883
- chapter: ctx.chapter
1884
- });
1885
- function personInputFromApp(chapter, app) {
1886
- const input = sharedPersonInput({
1887
- email: str(app.email),
1888
- firstName: str(app.firstName) || void 0,
1889
- lastName: str(app.lastName) || void 0,
1890
- phone: str(app.phone) || void 0,
1891
- linkedin: str(app.linkedin) || void 0,
1892
- hubRecordId: str(app.id)
1893
- });
1894
- for (const f of chapter.application.crmFields) {
1895
- if (app[f] !== void 0) input[f] = app[f];
1896
- }
1897
- if (app.id !== void 0) input.applicationId = str(app.id);
1898
- return input;
1899
- }
1900
- function billingColumns(app) {
1901
- const status = str(app.status);
1902
- const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
1903
- const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
1904
- const cols = { billingStatus };
1905
- if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
1906
- if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
1907
- if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
1908
- return cols;
1909
- }
1910
- async function syncApplicationToCrm(deps, opts) {
1911
- const emailKey = str(opts.app.email).toLowerCase();
1912
- if (!emailKey) return null;
1913
- const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1914
- const input = personInputFromApp(deps.chapter, opts.app);
1915
- const { crm_record } = await deps.db.query({
1916
- crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
1917
- });
1918
- const existing = crm_record?.[0] ?? null;
1919
- const stage = opts.stage || void 0;
1920
- let recordId;
1921
- if (existing && typeof existing.id === "string") {
1922
- recordId = existing.id;
1923
- await (0, import_crm4.updateRecord)(recordDeps, { id: recordId, input });
1924
- } else {
1925
- const created = await (0, import_crm4.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
1926
- recordId = created.id;
1927
- }
1928
- if (existing && stage && existing.stage !== stage) {
1929
- await (0, import_crm4.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1930
- }
1931
- await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1932
- await (0, import_crm4.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1933
- return recordId;
1934
- }
1935
- async function backfillCrm(deps) {
1936
- const [appsRes, usersRes] = await Promise.all([
1937
- deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
1938
- deps.db.query({ $users: { $: { limit: 1e3 } } })
1939
- ]);
1940
- const seen = /* @__PURE__ */ new Set();
1941
- let synced = 0;
1942
- const errors = [];
1943
- const run = async (app, stage) => {
1944
- const key = str(app.email).toLowerCase();
1945
- if (!key || seen.has(key)) return;
1946
- seen.add(key);
3050
+ // src/worker-routes-admin-admission.ts
3051
+ async function gate(req, env, ctx) {
3052
+ const rawDb = ctx.makeDb(env);
3053
+ const user = await ctx.verifyUser(req, env);
3054
+ if (!user) return json({ error: "unauthorized" }, 401);
3055
+ if (!await ctx.isAdmin(rawDb, user)) return json({ error: "forbidden" }, 403);
3056
+ return { db: rawDb, user };
3057
+ }
3058
+ var handleAdminAdmissionGrants = async (req, url, env, ctx) => {
3059
+ if (url.pathname === "/api/admin/admission-grants" && req.method === "GET") {
3060
+ const auth = await gate(req, env, ctx);
3061
+ if (auth instanceof Response) return auth;
3062
+ const result = await auth.db.query({ admissionGrants: { $: {
3063
+ where: { targetId: ctx.chapter.id },
3064
+ order: { createdAt: "desc" },
3065
+ limit: 500
3066
+ } } });
3067
+ const grants = Array.isArray(result.admissionGrants) ? result.admissionGrants.map((row) => ({ ...row, emailNormalized: void 0 })) : [];
3068
+ return json({ grants });
3069
+ }
3070
+ if (url.pathname === "/api/admin/admission-grants" && req.method === "POST") {
3071
+ const auth = await gate(req, env, ctx);
3072
+ if (auth instanceof Response) return auth;
3073
+ let body;
1947
3074
  try {
1948
- await syncApplicationToCrm(deps, { app, stage });
1949
- synced += 1;
1950
- } catch (err) {
1951
- errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
3075
+ body = JSON.parse(await req.text());
3076
+ } catch {
3077
+ return json({ error: "invalid JSON body" }, 400);
3078
+ }
3079
+ const email = typeof body.email === "string" ? body.email.trim() : "";
3080
+ const emailNormalized = normalizeAdmissionEmail(email);
3081
+ if (!isValidEmail(emailNormalized)) return json({ error: "email must be valid" }, 400);
3082
+ const decision = body.decision ?? "waive_interview";
3083
+ if (decision !== "waive_interview" && decision !== "require_interview") {
3084
+ return json({ error: "decision must be waive_interview or require_interview" }, 400);
3085
+ }
3086
+ const note = typeof body.note === "string" ? body.note.trim() : "";
3087
+ if (note.length > 2e3) return json({ error: "note is too long" }, 400);
3088
+ const expiresAt = body.expiresAt === void 0 || body.expiresAt === null ? void 0 : Number(body.expiresAt);
3089
+ if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) {
3090
+ return json({ error: "expiresAt must be a future epoch timestamp" }, 400);
3091
+ }
3092
+ const sourceGrantId = crypto.randomUUID();
3093
+ const id = `admission:local:${ctx.chapter.id}:${sourceGrantId}`;
3094
+ const createdAt = Date.now();
3095
+ const actor = auth.user.email ?? auth.user.userId;
3096
+ await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
3097
+ id,
3098
+ sourceGrantId,
3099
+ sourceScope: "local",
3100
+ sourceId: ctx.chapter.id,
3101
+ targetId: ctx.chapter.id,
3102
+ email,
3103
+ emailNormalized,
3104
+ decision,
3105
+ ...note ? { note } : {},
3106
+ ...expiresAt !== void 0 ? { expiresAt } : {},
3107
+ active: true,
3108
+ createdAt,
3109
+ createdBy: actor,
3110
+ updatedAt: createdAt,
3111
+ updatedBy: actor
3112
+ } }], { mutationId: `admission-local-create:${sourceGrantId}` });
3113
+ return json({ grant: { id, sourceGrantId, sourceScope: "local", email, decision, active: true, createdAt, ...note ? { note } : {}, ...expiresAt ? { expiresAt } : {} } }, 201);
3114
+ }
3115
+ const prefix = "/api/admin/admission-grants/";
3116
+ let revokeId = "";
3117
+ if (url.pathname.startsWith(prefix)) {
3118
+ try {
3119
+ revokeId = decodeURIComponent(url.pathname.slice(prefix.length));
3120
+ } catch {
3121
+ revokeId = "";
1952
3122
  }
1953
- };
1954
- for (const a of appsRes.applications ?? []) await run(a, str(a.status));
1955
- for (const u of usersRes.$users ?? []) {
1956
- if (u.deleted === true) continue;
1957
- await run({ email: u.email, firstName: str(u.name) });
1958
3123
  }
1959
- return { synced, errors };
1960
- }
3124
+ if (/^admission:local:[a-z0-9-]+:[0-9a-f-]+$/.test(revokeId) && req.method === "DELETE") {
3125
+ const auth = await gate(req, env, ctx);
3126
+ if (auth instanceof Response) return auth;
3127
+ const id = revokeId;
3128
+ const found = await auth.db.query({ admissionGrants: { $: { where: { id }, limit: 1 } } });
3129
+ const row = Array.isArray(found.admissionGrants) ? found.admissionGrants[0] : void 0;
3130
+ if (!row || row.targetId !== ctx.chapter.id || row.sourceScope !== "local") return json({ error: "not found" }, 404);
3131
+ if (row.active !== true) return json({ ok: true, replayed: true });
3132
+ const revokedAt = Date.now();
3133
+ const actor = auth.user.email ?? auth.user.userId;
3134
+ await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
3135
+ active: false,
3136
+ revokedAt,
3137
+ revokedBy: actor,
3138
+ updatedAt: revokedAt,
3139
+ updatedBy: actor
3140
+ } }], { mutationId: `admission-local-revoke:${id}` });
3141
+ return json({ ok: true, replayed: false });
3142
+ }
3143
+ return null;
3144
+ };
1961
3145
 
1962
3146
  // src/worker-routes-schedule.ts
1963
3147
  var import_calendar = require("@odla-ai/calendar");
@@ -1973,8 +3157,8 @@ function makeCalendar(env) {
1973
3157
  }
1974
3158
  async function firstRow(db, ns, q) {
1975
3159
  const res = await db.query({ [ns]: { $: q } });
1976
- const rows2 = res[ns];
1977
- return Array.isArray(rows2) ? rows2[0] : void 0;
3160
+ const rows4 = res[ns];
3161
+ return Array.isArray(rows4) ? rows4[0] : void 0;
1978
3162
  }
1979
3163
  async function computeSlots(cal, cfg) {
1980
3164
  const { from, to } = slotWindow(Date.now(), cfg.windowDays);
@@ -2111,8 +3295,26 @@ function findApplicationRef(obj) {
2111
3295
  const customerId = typeof obj.customer === "string" ? obj.customer : void 0;
2112
3296
  return { ...applicationId ? { applicationId } : {}, ...customerId ? { customerId } : {} };
2113
3297
  }
3298
+ function findGiftRef(obj) {
3299
+ const metaOf = (value) => value && typeof value === "object" ? value.metadata ?? {} : {};
3300
+ const pick = (metadata) => typeof metadata.giftIntentId === "string" ? metadata.giftIntentId : void 0;
3301
+ const parent = obj.parent;
3302
+ const subscription = obj.subscription_details ?? parent?.subscription_details;
3303
+ const giftIntentId = pick(metaOf(obj)) ?? pick(metaOf(subscription));
3304
+ const subscriptionId = typeof obj.subscription === "string" ? obj.subscription : typeof subscription?.subscription === "string" ? String(subscription.subscription) : void 0;
3305
+ return { ...giftIntentId ? { giftIntentId } : {}, providerObjectId: subscriptionId ?? String(obj.id ?? "") };
3306
+ }
2114
3307
  function normalizeWebhookEvent(event) {
2115
3308
  const obj = event.data?.object ?? {};
3309
+ const gift = findGiftRef(obj);
3310
+ if (gift.giftIntentId) {
3311
+ if (event.type === "payment_intent.succeeded" || event.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
3312
+ return { kind: "gift_paid", giftIntentId: gift.giftIntentId, providerObjectId: gift.providerObjectId };
3313
+ }
3314
+ if (event.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "refunded" };
3315
+ if (event.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "canceled" };
3316
+ return { kind: "ignored", type: event.type };
3317
+ }
2116
3318
  const ref = findApplicationRef(obj);
2117
3319
  switch (event.type) {
2118
3320
  case "invoice.paid": {
@@ -2213,9 +3415,9 @@ function createStripeProvider(config) {
2213
3415
  `sub:${input.applicationId}`
2214
3416
  );
2215
3417
  if (!sub.ok) fail("subscription create", sub);
2216
- const clientSecret = clientSecretOf(sub.body);
2217
- if (!clientSecret) fail("subscription confirmation-secret missing", sub);
2218
- return { customerId, subscriptionId: String(sub.body.id), clientSecret };
3418
+ const clientSecret2 = clientSecretOf(sub.body);
3419
+ if (!clientSecret2) fail("subscription confirmation-secret missing", sub);
3420
+ return { customerId, subscriptionId: String(sub.body.id), clientSecret: clientSecret2 };
2219
3421
  },
2220
3422
  async ingestWebhook(rawBody, sigHeader) {
2221
3423
  if (!webhookSecret || !await (0, import_stripe2.verifyStripeSignature)(rawBody, sigHeader, webhookSecret)) {
@@ -2228,8 +3430,8 @@ function createStripeProvider(config) {
2228
3430
  const sk = requireSecret(secretKey);
2229
3431
  const charges = await stripeCall(sk, "GET", "/v1/charges", { customer: input.customerId, limit: 100 });
2230
3432
  if (!charges.ok) fail("charges list", charges);
2231
- const rows2 = charges.body.data ?? [];
2232
- const paid = rows2.filter((c) => c.status === "succeeded" && c.refunded !== true);
3433
+ const rows4 = charges.body.data ?? [];
3434
+ const paid = rows4.filter((c) => c.status === "succeeded" && c.refunded !== true);
2233
3435
  const charge = paid[paid.length - 1];
2234
3436
  if (!charge) {
2235
3437
  const err = new Error("no paid charge to refund");
@@ -2245,12 +3447,337 @@ function createStripeProvider(config) {
2245
3447
  };
2246
3448
  }
2247
3449
 
3450
+ // src/gift-codes.ts
3451
+ var GIFT_CODE_VERSION = 1;
3452
+ var GIFT_CODE_BYTES = 32;
3453
+ var base64url = (bytes) => {
3454
+ let binary = "";
3455
+ for (const byte of bytes) binary += String.fromCharCode(byte);
3456
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
3457
+ };
3458
+ function createGiftCode() {
3459
+ const bytes = new Uint8Array(GIFT_CODE_BYTES);
3460
+ crypto.getRandomValues(bytes);
3461
+ return `gift${GIFT_CODE_VERSION}_${base64url(bytes)}`;
3462
+ }
3463
+ async function hashGiftCode(chapterId, code, version = GIFT_CODE_VERSION) {
3464
+ const bytes = new TextEncoder().encode(`odla-gift:${version}:${chapterId}:${code}`);
3465
+ return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
3466
+ }
3467
+ async function giftRequesterBucket(chapterId, requester) {
3468
+ const bytes = new TextEncoder().encode(`odla-gift-rate:${chapterId}:${requester}`);
3469
+ return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))).slice(0, 32);
3470
+ }
3471
+
3472
+ // src/gift-store.ts
3473
+ var first = (value) => Array.isArray(value) ? value[0] : void 0;
3474
+ async function prepareGiftCode(chapterId) {
3475
+ const code = createGiftCode();
3476
+ return { code, codeHash: await hashGiftCode(chapterId, code), codeVersion: GIFT_CODE_VERSION };
3477
+ }
3478
+ async function issueGiftClaim(db, intent, provider, now) {
3479
+ const existing = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
3480
+ if (existing) return { claimId: String(existing.id), orderId: String(existing.orderId), duplicate: true };
3481
+ const offer = intent.offerSnapshot;
3482
+ const orderId = `gift-order:${intent.id}`;
3483
+ const claimId = `gift-claim:${intent.id}`;
3484
+ const shared = {
3485
+ chapterId: intent.chapterId,
3486
+ offerId: intent.offerId,
3487
+ ...intent.purchaserApplicationId ? { purchaserApplicationId: intent.purchaserApplicationId } : {},
3488
+ ...intent.purchaserUserId ? { purchaserUserId: intent.purchaserUserId } : {},
3489
+ purchaserEmailNormalized: intent.purchaserEmailNormalized
3490
+ };
3491
+ const ops = [
3492
+ { t: "update", ns: "giftOrders", id: orderId, attrs: {
3493
+ id: orderId,
3494
+ intentId: intent.id,
3495
+ ...shared,
3496
+ environment: intent.environment,
3497
+ offerSnapshot: offer,
3498
+ purchaserType: intent.purchaserType,
3499
+ providerEventId: provider.eventId,
3500
+ providerObjectId: provider.objectId,
3501
+ amountCents: offer.priceCents,
3502
+ currency: offer.currency,
3503
+ billingMode: offer.billingMode,
3504
+ createdAt: now
3505
+ } },
3506
+ { t: "update", ns: "giftClaims", id: claimId, attrs: {
3507
+ id: claimId,
3508
+ orderId,
3509
+ intentId: intent.id,
3510
+ ...shared,
3511
+ target: offer.target,
3512
+ codeHash: intent.codeHash,
3513
+ codeVersion: intent.codeVersion,
3514
+ rotationCount: 0,
3515
+ codeIssuedAt: intent.codeIssuedAt,
3516
+ codeExpiresAt: now + offer.redemptionLifetimeHours * 36e5,
3517
+ attemptCount: 0,
3518
+ attemptWindowStartedAt: now,
3519
+ status: "pending",
3520
+ createdAt: now
3521
+ } },
3522
+ { t: "update", ns: "giftPurchaseIntents", id: intent.id, attrs: { status: "paid", paidAt: now, claimId, updatedAt: now } }
3523
+ ];
3524
+ if (intent.purchaserApplicationId) ops.push({
3525
+ t: "update",
3526
+ ns: "applications",
3527
+ id: intent.purchaserApplicationId,
3528
+ attrs: { giftPaidAt: now, giftClaimId: claimId }
3529
+ });
3530
+ try {
3531
+ const result = await db.transact(ops, {
3532
+ mutationId: `gift-paid:${provider.eventId}`,
3533
+ guards: [{ ns: "giftPurchaseIntents", id: intent.id, exists: true, equals: { status: "checkout_ready" } }]
3534
+ });
3535
+ return { claimId, orderId, duplicate: result.duplicate };
3536
+ } catch {
3537
+ const raced = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
3538
+ if (raced) return { claimId: String(raced.id), orderId: String(raced.orderId), duplicate: true };
3539
+ throw new Error("gift issuance conflict");
3540
+ }
3541
+ }
3542
+ function buyerGiftClaims(rows4, now) {
3543
+ return rows4.map((row) => {
3544
+ const stored = String(row.status ?? "pending");
3545
+ const status = stored === "pending" && Number(row.codeExpiresAt) <= now ? "expired" : stored;
3546
+ return {
3547
+ id: String(row.id),
3548
+ offerId: String(row.offerId),
3549
+ status,
3550
+ createdAt: Number(row.createdAt),
3551
+ codeExpiresAt: Number(row.codeExpiresAt),
3552
+ ...typeof row.redeemedAt === "number" ? { redeemedAt: row.redeemedAt } : {},
3553
+ ...typeof row.revokedAt === "number" ? { revokedAt: row.revokedAt } : {}
3554
+ };
3555
+ });
3556
+ }
3557
+ async function rotateGiftClaim(db, claim, now) {
3558
+ if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
3559
+ const prepared = await prepareGiftCode(String(claim.chapterId));
3560
+ const currentRotation = Number(claim.rotationCount ?? 0);
3561
+ try {
3562
+ const write = await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
3563
+ codeHash: prepared.codeHash,
3564
+ codeVersion: prepared.codeVersion,
3565
+ rotationCount: currentRotation + 1,
3566
+ codeIssuedAt: now,
3567
+ rotatedAt: now,
3568
+ attemptCount: 0,
3569
+ attemptWindowStartedAt: now
3570
+ } }], {
3571
+ mutationId: `gift-rotate:${String(claim.id)}:${currentRotation + 1}`,
3572
+ guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: "pending", rotationCount: currentRotation } }]
3573
+ });
3574
+ return write.duplicate ? null : { code: prepared.code };
3575
+ } catch {
3576
+ return null;
3577
+ }
3578
+ }
3579
+ async function redeemGiftClaim(db, claim, beneficiary, now) {
3580
+ if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
3581
+ const chapterId = String(claim.chapterId);
3582
+ const emailNormalized = normalizeAdmissionEmail(beneficiary.email);
3583
+ const target = claim.target;
3584
+ const ops = [];
3585
+ let result;
3586
+ if (target.type === "tier") {
3587
+ const apps = (await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications;
3588
+ const existing = apps?.filter((row) => row.groupId === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
3589
+ const id = existing ? String(existing.id) : `gift-application:${String(claim.id)}`;
3590
+ const status = existing && existing.status !== "submitted" ? String(existing.status) : "paid_pending_vetting";
3591
+ ops.push({ t: "update", ns: "applications", id, attrs: {
3592
+ id,
3593
+ groupId: chapterId,
3594
+ email: beneficiary.email,
3595
+ emailNormalized,
3596
+ tierId: target.tierId,
3597
+ status,
3598
+ giftClaimId: String(claim.id),
3599
+ giftOrderId: String(claim.orderId),
3600
+ clerkUserId: beneficiary.userId,
3601
+ ...existing ? {} : { createdAt: now }
3602
+ } });
3603
+ result = { kind: "application", id };
3604
+ } else {
3605
+ const id = `gift-entitlement:${String(claim.id)}`;
3606
+ ops.push({ t: "update", ns: "giftEntitlements", id, attrs: {
3607
+ id,
3608
+ claimId: String(claim.id),
3609
+ orderId: String(claim.orderId),
3610
+ chapterId,
3611
+ entitlementKey: target.entitlementKey,
3612
+ beneficiaryEmailNormalized: emailNormalized,
3613
+ beneficiaryUserId: beneficiary.userId,
3614
+ active: true,
3615
+ createdAt: now
3616
+ } });
3617
+ result = { kind: "entitlement", id };
3618
+ }
3619
+ ops.unshift({ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
3620
+ status: "redeemed",
3621
+ redeemedAt: now,
3622
+ beneficiaryEmailNormalized: emailNormalized,
3623
+ beneficiaryUserId: beneficiary.userId,
3624
+ ...result.kind === "application" ? { applicationId: result.id } : { entitlementId: result.id },
3625
+ attemptCount: Number(claim.attemptCount ?? 0) + 1
3626
+ } });
3627
+ try {
3628
+ await db.transact(ops, {
3629
+ mutationId: `gift-redeem:${String(claim.id)}:${beneficiary.userId}`,
3630
+ guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: {
3631
+ status: "pending",
3632
+ codeVersion: Number(claim.codeVersion),
3633
+ codeHash: String(claim.codeHash)
3634
+ } }]
3635
+ });
3636
+ return result;
3637
+ } catch {
3638
+ return null;
3639
+ }
3640
+ }
3641
+ async function revokeGiftClaim(db, claim, reason, now) {
3642
+ if (claim.status === "revoked") return false;
3643
+ const ops = [{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
3644
+ status: "revoked",
3645
+ revokedAt: now,
3646
+ revokedReason: reason
3647
+ } }];
3648
+ if (claim.entitlementId) ops.push({ t: "update", ns: "giftEntitlements", id: String(claim.entitlementId), attrs: { active: false, revokedAt: now } });
3649
+ if (claim.applicationId) ops.push({ t: "update", ns: "applications", id: String(claim.applicationId), attrs: {
3650
+ giftRevokedAt: now,
3651
+ ...reason === "refunded" ? { status: "refunded" } : { canceled: true }
3652
+ } });
3653
+ try {
3654
+ await db.transact(ops, {
3655
+ mutationId: `gift-revoke:${String(claim.id)}:${reason}`,
3656
+ guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: String(claim.status) } }]
3657
+ });
3658
+ return true;
3659
+ } catch {
3660
+ return false;
3661
+ }
3662
+ }
3663
+
3664
+ // src/gift-checkout.ts
3665
+ var clientSecret = (value) => {
3666
+ const direct = value.client_secret;
3667
+ if (typeof direct === "string" && direct) return direct;
3668
+ const invoice = value.latest_invoice;
3669
+ const confirmation = invoice?.confirmation_secret;
3670
+ const intent = invoice?.payment_intent;
3671
+ const nested = confirmation?.client_secret ?? intent?.client_secret;
3672
+ return typeof nested === "string" && nested ? nested : null;
3673
+ };
3674
+ async function startGiftProviderCheckout(secretKey, intent, email, existingCustomerId) {
3675
+ const offer = intent.offerSnapshot;
3676
+ const metadata = {
3677
+ giftIntentId: intent.id,
3678
+ chapterId: intent.chapterId,
3679
+ environment: intent.environment,
3680
+ offerId: intent.offerId
3681
+ };
3682
+ let customerId = existingCustomerId;
3683
+ if (!customerId) {
3684
+ const customer = await stripeCall(secretKey, "POST", "/v1/customers", { email, metadata }, `gift-customer:${intent.id}`);
3685
+ if (!customer.ok) throw new Error(`gift customer create failed (${customer.status})`);
3686
+ customerId = String(customer.body.id);
3687
+ }
3688
+ if (offer.billingMode === "subscription") {
3689
+ const created2 = await stripeCall(secretKey, "POST", "/v1/subscriptions", {
3690
+ customer: customerId,
3691
+ "items[0][price]": offer.stripePriceId,
3692
+ payment_behavior: "default_incomplete",
3693
+ "payment_settings[save_default_payment_method]": "on_subscription",
3694
+ "payment_settings[payment_method_types][0]": "card",
3695
+ "expand[0]": "latest_invoice.confirmation_secret",
3696
+ metadata
3697
+ }, `gift-subscription:${intent.id}`);
3698
+ const secret2 = created2.ok ? clientSecret(created2.body) : null;
3699
+ if (!created2.ok || !secret2) throw new Error(`gift subscription create failed (${created2.status})`);
3700
+ return { clientSecret: secret2, providerObjectId: String(created2.body.id), customerId };
3701
+ }
3702
+ const price = await stripeCall(secretKey, "GET", `/v1/prices/${encodeURIComponent(offer.stripePriceId)}`);
3703
+ if (!price.ok || price.body.active === false || price.body.unit_amount !== offer.priceCents || price.body.currency !== offer.currency) {
3704
+ throw new Error("gift price does not match the signed offer");
3705
+ }
3706
+ const created = await stripeCall(secretKey, "POST", "/v1/payment_intents", {
3707
+ customer: customerId,
3708
+ amount: offer.priceCents,
3709
+ currency: offer.currency,
3710
+ "automatic_payment_methods[enabled]": true,
3711
+ metadata
3712
+ }, `gift-payment:${intent.id}`);
3713
+ const secret = created.ok ? clientSecret(created.body) : null;
3714
+ if (!created.ok || !secret) throw new Error(`gift payment create failed (${created.status})`);
3715
+ return { clientSecret: secret, providerObjectId: String(created.body.id), customerId };
3716
+ }
3717
+
3718
+ // src/gift-intents.ts
3719
+ var first2 = (value) => Array.isArray(value) ? value[0] : void 0;
3720
+ async function startGiftIntent(db, input) {
3721
+ let stored = first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents);
3722
+ let giftCode;
3723
+ if (!stored) {
3724
+ const prepared = await prepareGiftCode(input.chapterId);
3725
+ const attrs = {
3726
+ id: input.id,
3727
+ chapterId: input.chapterId,
3728
+ environment: input.environment,
3729
+ purchaserType: input.purchaserType,
3730
+ purchaserEmailNormalized: input.purchaserEmailNormalized,
3731
+ ...input.purchaserApplicationId ? { purchaserApplicationId: input.purchaserApplicationId } : {},
3732
+ ...input.purchaserUserId ? { purchaserUserId: input.purchaserUserId } : {},
3733
+ offerId: input.offer.id,
3734
+ offerSnapshot: input.offer,
3735
+ status: "created",
3736
+ codeHash: prepared.codeHash,
3737
+ codeVersion: prepared.codeVersion,
3738
+ codeIssuedAt: input.now,
3739
+ createdAt: input.now,
3740
+ updatedAt: input.now
3741
+ };
3742
+ const write = await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs }], {
3743
+ mutationId: `gift-intent:${input.id}`
3744
+ });
3745
+ stored = write.duplicate ? first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents) : attrs;
3746
+ if (!write.duplicate) giftCode = prepared.code;
3747
+ }
3748
+ if (!stored || stored.chapterId !== input.chapterId || stored.environment !== input.environment || stored.offerId !== input.offer.id || stored.purchaserEmailNormalized !== input.purchaserEmailNormalized) {
3749
+ throw new Error("gift purchase intent does not match this checkout");
3750
+ }
3751
+ if (stored.status === "paid") throw new Error("gift purchase is already paid");
3752
+ const checkout = await startGiftProviderCheckout(
3753
+ input.stripeSecret,
3754
+ stored,
3755
+ input.purchaserEmail,
3756
+ typeof stored.providerCustomerId === "string" ? stored.providerCustomerId : input.existingCustomerId
3757
+ );
3758
+ await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs: {
3759
+ status: "checkout_ready",
3760
+ providerObjectId: checkout.providerObjectId,
3761
+ ...checkout.customerId ? { providerCustomerId: checkout.customerId } : {},
3762
+ checkoutStartedAt: input.now,
3763
+ updatedAt: input.now
3764
+ } }], { mutationId: `gift-checkout-ready:${input.id}:${checkout.providerObjectId}` });
3765
+ return {
3766
+ gift: true,
3767
+ intentId: input.id,
3768
+ clientSecret: checkout.clientSecret,
3769
+ publishableKey: input.publishableKey,
3770
+ lineItems: { standardCents: input.offer.priceCents, discountCents: 0, dueTodayCents: input.offer.priceCents },
3771
+ ...giftCode ? { giftCode } : {}
3772
+ };
3773
+ }
3774
+
2248
3775
  // src/worker-routes-payments.ts
2249
3776
  var codeOf = (err) => err && typeof err === "object" && typeof err.code === "string" ? err.code : "unknown";
2250
3777
  async function firstRow2(db, ns, q) {
2251
3778
  const res = await db.query({ [ns]: { $: q } });
2252
- const rows2 = res[ns];
2253
- return Array.isArray(rows2) ? rows2[0] : void 0;
3779
+ const rows4 = res[ns];
3780
+ return Array.isArray(rows4) ? rows4[0] : void 0;
2254
3781
  }
2255
3782
  function lineItems(group) {
2256
3783
  const standard = Number(group.standardPriceCents ?? 0);
@@ -2335,6 +3862,36 @@ async function startSubscription(req, env, ctx) {
2335
3862
  const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
2336
3863
  const secretKey = await getVaultSecret(db, "stripe_secret_key");
2337
3864
  if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
3865
+ if (typeof app.giftOfferId === "string") {
3866
+ const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
3867
+ const offer = findGiftOffer(control2?.document.giftOffers, app.giftOfferId, Date.now());
3868
+ if (!offer || typeof group.stripePublishableKey !== "string" || !group.stripePublishableKey) {
3869
+ return json({ error: "gift offer is unavailable" }, 409);
3870
+ }
3871
+ try {
3872
+ const started = await startGiftIntent(db, {
3873
+ id: typeof app.giftIntentId === "string" ? app.giftIntentId : `gift-intent:${applicationId}`,
3874
+ chapterId: ctx.chapter.id,
3875
+ environment: env.ODLA_ENV,
3876
+ offer,
3877
+ purchaserType: "application",
3878
+ purchaserEmail: String(app.email ?? ""),
3879
+ purchaserEmailNormalized: normalizeAdmissionEmail(String(app.email ?? "")),
3880
+ purchaserApplicationId: applicationId,
3881
+ stripeSecret: secretKey,
3882
+ publishableKey: group.stripePublishableKey,
3883
+ now: Date.now()
3884
+ });
3885
+ await db.transact([{ t: "update", ns: "applications", id: applicationId, attrs: {
3886
+ giftIntentId: started.intentId,
3887
+ giftCheckoutStartedAt: Date.now(),
3888
+ refundPolicyAckAt: Date.now()
3889
+ } }]);
3890
+ return json(started);
3891
+ } catch (error) {
3892
+ return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
3893
+ }
3894
+ }
2338
3895
  const tier = findTier(
2339
3896
  await loadTiers(db, group),
2340
3897
  typeof app.tierId === "string" ? app.tierId : null
@@ -2376,17 +3933,54 @@ async function ingestWebhook(req, env, ctx) {
2376
3933
  if (!ingest.ok) return json({ error: "invalid signature" }, 400);
2377
3934
  const { eventId, event } = ingest;
2378
3935
  if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
3936
+ if (event.kind === "gift_paid" || event.kind === "gift_revoked") {
3937
+ const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
3938
+ if (!intent || intent.chapterId !== ctx.chapter.id || intent.environment !== env.ODLA_ENV) {
3939
+ return json({ ok: true, matched: false });
3940
+ }
3941
+ if (event.kind === "gift_paid") {
3942
+ if (intent.providerObjectId !== event.providerObjectId) return json({ ok: true, matched: false });
3943
+ if (intent.status === "refunded" || intent.status === "canceled") return json({ ok: true, matched: false });
3944
+ try {
3945
+ const issued = await issueGiftClaim(db, intent, {
3946
+ eventId,
3947
+ objectId: event.providerObjectId
3948
+ }, Date.now());
3949
+ return json({ ok: true, matched: true, claimId: issued.claimId });
3950
+ } catch {
3951
+ return json({ error: "gift issuance failed" }, 503);
3952
+ }
3953
+ }
3954
+ await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: event.giftIntentId, attrs: {
3955
+ status: event.reason,
3956
+ updatedAt: Date.now()
3957
+ } }], { mutationId: `gift-revoked-intent:${eventId}` });
3958
+ const claim = await firstRow2(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
3959
+ if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
3960
+ return json({ ok: true, matched: Boolean(claim) });
3961
+ }
2379
3962
  const app = await findApplication(db, event);
2380
3963
  if (!app) return json({ ok: true, matched: false });
2381
3964
  const patch = webhookPatch(event, String(app.status ?? ""));
3965
+ let next = app;
2382
3966
  if (Object.keys(patch).length) {
2383
3967
  await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
2384
- const next = { ...app, ...patch };
3968
+ next = { ...app, ...patch };
2385
3969
  await syncApplicationToCrm(crmDeps(db, ctx), { app: next, stage: String(next.status ?? "") }).catch(() => void 0);
2386
3970
  }
2387
3971
  if (event.kind === "first_payment") {
2388
3972
  await notifyPaymentConfirmed(db, env, eventId, app);
2389
3973
  if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
3974
+ if (typeof next.interviewWaivedAt === "number") {
3975
+ await approveApplication({
3976
+ db,
3977
+ env,
3978
+ ctx,
3979
+ app: next,
3980
+ origin: new URL(req.url).origin,
3981
+ reason: "interview_waiver"
3982
+ });
3983
+ }
2390
3984
  }
2391
3985
  return json({ ok: true });
2392
3986
  }
@@ -2397,6 +3991,144 @@ var handlePayments = async (req, url, env, ctx) => {
2397
3991
  return null;
2398
3992
  };
2399
3993
 
3994
+ // src/worker-routes-gifts.ts
3995
+ var first3 = (value) => Array.isArray(value) ? value[0] : void 0;
3996
+ var rows2 = (value) => Array.isArray(value) ? value : [];
3997
+ var invalid = () => json({ error: "gift code is invalid or unavailable" }, 400);
3998
+ var userFor = async (req, env, ctx) => {
3999
+ const user = await ctx.verifyUser(req, env);
4000
+ return user?.email ? user : null;
4001
+ };
4002
+ var loadClaim = async (db, id) => first3((await db.query({ giftClaims: { $: { where: { id }, limit: 1 } } })).giftClaims);
4003
+ async function consumeRateLimit(db, chapterId, requester, now) {
4004
+ const bucket = await giftRequesterBucket(chapterId, requester);
4005
+ const id = `gift-rate:${chapterId}:${bucket}`;
4006
+ const current = first3((await db.query({ giftRedemptionAttempts: { $: { where: { id }, limit: 1 } } })).giftRedemptionAttempts);
4007
+ const within = Boolean(current && now - Number(current.windowStartedAt) < 15 * 6e4);
4008
+ const attemptCount = within ? Number(current?.attemptCount ?? 0) + 1 : 1;
4009
+ if (attemptCount > 10) return false;
4010
+ await db.transact([{ t: "update", ns: "giftRedemptionAttempts", id, attrs: {
4011
+ id,
4012
+ chapterId,
4013
+ bucket,
4014
+ attemptCount,
4015
+ windowStartedAt: within ? Number(current?.windowStartedAt) : now,
4016
+ updatedAt: now
4017
+ } }]);
4018
+ return true;
4019
+ }
4020
+ var handleGifts = async (req, url, env, ctx) => {
4021
+ if (ctx.chapter.mode !== "chapter" || !url.pathname.startsWith("/api/gifts")) return null;
4022
+ const db = ctx.makeDb(env);
4023
+ if (req.method === "GET" && url.pathname === "/api/gifts") {
4024
+ const user = await userFor(req, env, ctx);
4025
+ if (!user) return json({ error: "unauthorized" }, 401);
4026
+ const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
4027
+ const found = await db.query({ giftClaims: { $: { where: {
4028
+ purchaserEmailNormalized: normalizeAdmissionEmail(user.email),
4029
+ chapterId: ctx.chapter.id
4030
+ }, limit: 200 } } });
4031
+ const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
4032
+ return json({
4033
+ offers: publicGiftOffers(control2?.document.giftOffers, Date.now()),
4034
+ claims: buyerGiftClaims(rows2(found.giftClaims).sort((a, b) => Number(b.createdAt) - Number(a.createdAt)), Date.now()),
4035
+ refundPolicyText: typeof group?.refundPolicyText === "string" ? group.refundPolicyText : ""
4036
+ });
4037
+ }
4038
+ if (req.method === "POST" && url.pathname === "/api/gifts/checkout") {
4039
+ const user = await userFor(req, env, ctx);
4040
+ if (!user) return json({ error: "unauthorized" }, 401);
4041
+ let body;
4042
+ try {
4043
+ body = JSON.parse(await req.text());
4044
+ } catch {
4045
+ return json({ error: "invalid JSON body" }, 400);
4046
+ }
4047
+ const purchaseId = typeof body.purchaseId === "string" ? body.purchaseId : "";
4048
+ if (!/^[A-Za-z0-9_-]{16,128}$/.test(purchaseId)) return json({ error: "purchaseId is invalid" }, 400);
4049
+ const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
4050
+ const offer = findGiftOffer(control2?.document.giftOffers, typeof body.offerId === "string" ? body.offerId : "", Date.now());
4051
+ if (!offer) return json({ error: "gift offer is unavailable" }, 409);
4052
+ const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
4053
+ const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
4054
+ if (!group || !stripeSecret || typeof group.stripePublishableKey !== "string") return json({ error: "payments not configured" }, 503);
4055
+ const emailNormalized = normalizeAdmissionEmail(user.email);
4056
+ const applications = rows2((await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications);
4057
+ const app = applications.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
4058
+ try {
4059
+ return json(await startGiftIntent(db, {
4060
+ id: `gift-intent:${ctx.chapter.id}:${purchaseId}`,
4061
+ chapterId: ctx.chapter.id,
4062
+ environment: env.ODLA_ENV,
4063
+ offer,
4064
+ purchaserType: "member",
4065
+ purchaserEmail: user.email,
4066
+ purchaserEmailNormalized: emailNormalized,
4067
+ purchaserUserId: user.userId,
4068
+ ...typeof app?.stripeCustomerId === "string" ? { existingCustomerId: app.stripeCustomerId } : {},
4069
+ stripeSecret,
4070
+ publishableKey: group.stripePublishableKey,
4071
+ now: Date.now()
4072
+ }));
4073
+ } catch (error) {
4074
+ return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
4075
+ }
4076
+ }
4077
+ const rotateMatch = url.pathname.match(/^\/api\/gifts\/([^/]+)\/rotate$/);
4078
+ if (req.method === "POST" && rotateMatch) {
4079
+ let claimId = "";
4080
+ try {
4081
+ claimId = decodeURIComponent(rotateMatch[1]);
4082
+ } catch {
4083
+ return invalid();
4084
+ }
4085
+ if (!claimId.startsWith("gift-claim:")) return invalid();
4086
+ const claim = await loadClaim(db, claimId);
4087
+ if (!claim || claim.chapterId !== ctx.chapter.id || Number(claim.rotationCount ?? 0) >= 10) return invalid();
4088
+ const user = await userFor(req, env, ctx);
4089
+ let applicationId = "";
4090
+ if (!user) {
4091
+ try {
4092
+ applicationId = String(JSON.parse(await req.text()).applicationId ?? "");
4093
+ } catch {
4094
+ return invalid();
4095
+ }
4096
+ }
4097
+ const owns = user ? claim.purchaserEmailNormalized === normalizeAdmissionEmail(user.email) : claim.purchaserApplicationId === applicationId;
4098
+ if (!owns) return invalid();
4099
+ const rotated = await rotateGiftClaim(db, claim, Date.now());
4100
+ return rotated ? json(rotated) : invalid();
4101
+ }
4102
+ if (req.method === "POST" && url.pathname === "/api/gifts/redeem") {
4103
+ const user = await userFor(req, env, ctx);
4104
+ if (!user) return json({ error: "unauthorized" }, 401);
4105
+ const now = Date.now();
4106
+ if (!await consumeRateLimit(db, ctx.chapter.id, `${user.userId}:${req.headers.get("cf-connecting-ip") ?? "no-ip"}`, now)) {
4107
+ return json({ error: "too many attempts" }, 429);
4108
+ }
4109
+ let code = "";
4110
+ try {
4111
+ code = String(JSON.parse(await req.text()).code ?? "").trim();
4112
+ } catch {
4113
+ return invalid();
4114
+ }
4115
+ if (!/^gift1_[A-Za-z0-9_-]{43}$/.test(code)) return invalid();
4116
+ const codeHash = await hashGiftCode(ctx.chapter.id, code);
4117
+ const claim = first3((await db.query({ giftClaims: { $: { where: { codeHash }, limit: 1 } } })).giftClaims);
4118
+ if (!claim || claim.chapterId !== ctx.chapter.id) return invalid();
4119
+ if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) {
4120
+ await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
4121
+ attemptCount: Number(claim.attemptCount ?? 0) + 1,
4122
+ ...claim.status === "pending" ? { status: "expired" } : {}
4123
+ } }]).catch(() => void 0);
4124
+ return invalid();
4125
+ }
4126
+ const redeemed = await redeemGiftClaim(db, claim, { email: user.email, userId: user.userId }, now);
4127
+ return redeemed ? json({ ok: true, ...redeemed }) : invalid();
4128
+ }
4129
+ return null;
4130
+ };
4131
+
2400
4132
  // src/worker-routes-admin.ts
2401
4133
  var import_calendar2 = require("@odla-ai/calendar");
2402
4134
 
@@ -2468,8 +4200,8 @@ async function upcomingEvents(env) {
2468
4200
  const res = await cal.availability.upcoming();
2469
4201
  return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
2470
4202
  }
2471
- function toReconcile(rows2) {
2472
- return rows2.map((m) => ({
4203
+ function toReconcile(rows4) {
4204
+ return rows4.map((m) => ({
2473
4205
  id: String(m.id),
2474
4206
  applicationId: String(m.applicationId),
2475
4207
  googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
@@ -2491,12 +4223,12 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
2491
4223
  const query = await db.query({
2492
4224
  meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
2493
4225
  });
2494
- let rows2 = Array.isArray(query.meetings) ? query.meetings : [];
2495
- if (Number.isFinite(from)) rows2 = rows2.filter((m) => Number(m.startAt ?? 0) >= from);
2496
- if (Number.isFinite(to)) rows2 = rows2.filter((m) => Number(m.startAt ?? 0) <= to);
4226
+ let rows4 = Array.isArray(query.meetings) ? query.meetings : [];
4227
+ if (Number.isFinite(from)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) >= from);
4228
+ if (Number.isFinite(to)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) <= to);
2497
4229
  let decisions = [];
2498
4230
  try {
2499
- decisions = reconcileMeetings(toReconcile(rows2), await upcomingEvents(env), Date.now());
4231
+ decisions = reconcileMeetings(toReconcile(rows4), await upcomingEvents(env), Date.now());
2500
4232
  } catch {
2501
4233
  }
2502
4234
  const patched = /* @__PURE__ */ new Map();
@@ -2523,69 +4255,10 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
2523
4255
  };
2524
4256
  const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
2525
4257
  const timezone = resolveScheduling(group?.schedulingJson).timezone;
2526
- const meetings = rows2.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
4258
+ const meetings = rows4.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
2527
4259
  return json({ meetings, adopted: decisions.length, timezone });
2528
4260
  };
2529
4261
 
2530
- // src/clerk-roles.ts
2531
- var CLERK_API = "https://api.clerk.com";
2532
- var DEFAULT_ROLE = "provisional";
2533
- var PAGE = 100;
2534
- function toRecord(u) {
2535
- if (typeof u.id !== "string") return null;
2536
- const pm = u.public_metadata ?? {};
2537
- const privateMetadata = u.private_metadata ?? {};
2538
- const rawProfile = privateMetadata.profile;
2539
- const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
2540
- const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
2541
- const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
2542
- const email = u.email_addresses?.[0]?.email_address;
2543
- return {
2544
- id: u.id,
2545
- email: typeof email === "string" ? email : void 0,
2546
- role,
2547
- publicMetadata: pm,
2548
- privateMetadata,
2549
- profile,
2550
- ...applicationId ? { applicationId } : {}
2551
- };
2552
- }
2553
- async function clerkGet(path, secretKey, fetchImpl) {
2554
- const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
2555
- if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
2556
- return res.json();
2557
- }
2558
- async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
2559
- const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
2560
- const user = Array.isArray(data) ? data[0] : void 0;
2561
- return user ? toRecord(user) : null;
2562
- }
2563
- async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
2564
- const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
2565
- return data ? toRecord(data) : null;
2566
- }
2567
- async function clerkListUsers(secretKey, fetchImpl = fetch) {
2568
- const out = [];
2569
- for (let offset = 0; ; offset += PAGE) {
2570
- const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
2571
- const page = Array.isArray(data) ? data : [];
2572
- for (const u of page) {
2573
- const record = toRecord(u);
2574
- if (record) out.push(record);
2575
- }
2576
- if (page.length < PAGE) break;
2577
- }
2578
- return out;
2579
- }
2580
- async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
2581
- const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
2582
- method: "PATCH",
2583
- headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
2584
- body: JSON.stringify({ public_metadata: { role } })
2585
- });
2586
- return res.ok;
2587
- }
2588
-
2589
4262
  // src/worker-routes-admin-people.ts
2590
4263
  async function adminGate(req, env, ctx) {
2591
4264
  const rawDb = ctx.makeDb(env);
@@ -2603,16 +4276,16 @@ var crmDeps2 = (db, ctx) => ({
2603
4276
  });
2604
4277
  var handleAdminCrmSync = async (req, url, env, ctx) => {
2605
4278
  if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
2606
- const gate8 = await adminGate(req, env, ctx);
2607
- if (gate8 instanceof Response) return gate8;
2608
- const result = await backfillCrm(crmDeps2(gate8.db, ctx));
4279
+ const gate9 = await adminGate(req, env, ctx);
4280
+ if (gate9 instanceof Response) return gate9;
4281
+ const result = await backfillCrm(crmDeps2(gate9.db, ctx));
2609
4282
  return json({ ok: true, ...result });
2610
4283
  };
2611
4284
  var handleAdminPeople = async (req, url, env, ctx) => {
2612
4285
  if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
2613
- const gate8 = await adminGate(req, env, ctx);
2614
- if (gate8 instanceof Response) return gate8;
2615
- const { db } = gate8;
4286
+ const gate9 = await adminGate(req, env, ctx);
4287
+ if (gate9 instanceof Response) return gate9;
4288
+ const { db } = gate9;
2616
4289
  const sk = await getVaultSecret(db, "clerk_secret_key");
2617
4290
  const [appsRes, usersRes, roleList] = await Promise.all([
2618
4291
  db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
@@ -2656,16 +4329,16 @@ var handleAdminPeople = async (req, url, env, ctx) => {
2656
4329
  });
2657
4330
  }
2658
4331
  }
2659
- const rows2 = [...people.values()].sort(
4332
+ const rows4 = [...people.values()].sort(
2660
4333
  (x, y) => (y.application?.createdAt ?? -1) - (x.application?.createdAt ?? -1)
2661
4334
  );
2662
- return json({ people: rows2 });
4335
+ return json({ people: rows4 });
2663
4336
  };
2664
4337
  var handleAdminPeopleAccess = async (req, url, env, ctx) => {
2665
4338
  if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
2666
- const gate8 = await adminGate(req, env, ctx);
2667
- if (gate8 instanceof Response) return gate8;
2668
- const { db } = gate8;
4339
+ const gate9 = await adminGate(req, env, ctx);
4340
+ if (gate9 instanceof Response) return gate9;
4341
+ const { db } = gate9;
2669
4342
  const targetId = url.searchParams.get("userId") ?? "";
2670
4343
  if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
2671
4344
  const sk = await getVaultSecret(db, "clerk_secret_key");
@@ -2676,9 +4349,9 @@ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
2676
4349
  };
2677
4350
  var handleAdminPeopleRole = async (req, url, env, ctx) => {
2678
4351
  if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
2679
- const gate8 = await adminGate(req, env, ctx);
2680
- if (gate8 instanceof Response) return gate8;
2681
- const { db, actor } = gate8;
4352
+ const gate9 = await adminGate(req, env, ctx);
4353
+ if (gate9 instanceof Response) return gate9;
4354
+ const { db, actor } = gate9;
2682
4355
  let body;
2683
4356
  try {
2684
4357
  body = await req.json();
@@ -2825,17 +4498,17 @@ function subAnnualCents(sub) {
2825
4498
  }
2826
4499
 
2827
4500
  // src/worker-routes-admin-dashboard.ts
2828
- async function gate(req, env, ctx) {
4501
+ async function gate2(req, env, ctx) {
2829
4502
  const rawDb = ctx.makeDb(env);
2830
4503
  const u = await ctx.verifyUser(req, env);
2831
4504
  if (!u) return json({ error: "unauthorized" }, 401);
2832
4505
  if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
2833
4506
  return rawDb;
2834
4507
  }
2835
- var rows = (v) => Array.isArray(v) ? v : [];
4508
+ var rows3 = (v) => Array.isArray(v) ? v : [];
2836
4509
  var handleAdminDashboard = async (req, url, env, ctx) => {
2837
4510
  if (req.method !== "GET" || url.pathname !== "/api/admin/dashboard") return null;
2838
- const got = await gate(req, env, ctx);
4511
+ const got = await gate2(req, env, ctx);
2839
4512
  if (got instanceof Response) return got;
2840
4513
  const db = got;
2841
4514
  const now = Date.now();
@@ -2847,7 +4520,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2847
4520
  db.query({ crm_record: { $: { where: { type: "person" }, limit: 1e3 } } }),
2848
4521
  db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })
2849
4522
  ]);
2850
- const apps = rows(appsRes.applications);
4523
+ const apps = rows3(appsRes.applications);
2851
4524
  const applications = {
2852
4525
  total: apps.length,
2853
4526
  last7: apps.filter((a) => a.createdAt >= d7).length,
@@ -2859,7 +4532,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2859
4532
  pipeline[s] = 0;
2860
4533
  pipelineDelta[s] = 0;
2861
4534
  }
2862
- for (const r of rows(recsRes.crm_record)) {
4535
+ for (const r of rows3(recsRes.crm_record)) {
2863
4536
  const s = r.stage;
2864
4537
  if (s in pipeline) {
2865
4538
  pipeline[s] = (pipeline[s] ?? 0) + 1;
@@ -2867,7 +4540,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2867
4540
  if (Number.isFinite(sc) && sc >= d7) pipelineDelta[s] = (pipelineDelta[s] ?? 0) + 1;
2868
4541
  }
2869
4542
  }
2870
- const meetings = rows(meetingsRes.meetings);
4543
+ const meetings = rows3(meetingsRes.meetings);
2871
4544
  const appById = new Map(apps.map((a) => [a.id, a]));
2872
4545
  const upcoming = meetings.filter((m) => m.startAt >= now - 36e5);
2873
4546
  const calls = { upcoming: upcoming.length, needsAttention: meetings.filter((m) => m.drift && m.drift !== "none").length };
@@ -2892,7 +4565,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2892
4565
  if (sk) {
2893
4566
  const subsRes = await stripeCall(sk, "GET", "/v1/subscriptions", { limit: 100, status: "all" });
2894
4567
  if (subsRes.ok) {
2895
- const subs = rows(subsRes.body.data);
4568
+ const subs = rows3(subsRes.body.data);
2896
4569
  const subMs = (s) => (s.created ?? 0) * 1e3;
2897
4570
  membersSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: 1 })), now);
2898
4571
  revenueSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: subAnnualCents(s) })), now);
@@ -2912,7 +4585,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2912
4585
  };
2913
4586
  var handleAdminBilling = async (req, url, env, ctx) => {
2914
4587
  if (req.method !== "GET" || url.pathname !== "/api/admin/billing") return null;
2915
- const got = await gate(req, env, ctx);
4588
+ const got = await gate2(req, env, ctx);
2916
4589
  if (got instanceof Response) return got;
2917
4590
  const db = got;
2918
4591
  const sk = await getVaultSecret(db, "stripe_secret_key");
@@ -2924,9 +4597,9 @@ var handleAdminBilling = async (req, url, env, ctx) => {
2924
4597
  ]);
2925
4598
  const testMode = String(groupRes.groups?.[0]?.stripePublishableKey ?? "").startsWith("pk_test");
2926
4599
  if (!subsRes.ok) return json({ error: "billing lookup failed upstream" }, 502);
2927
- const subById = new Map(rows(subsRes.body.data).map((s) => [s.id, s]));
4600
+ const subById = new Map(rows3(subsRes.body.data).map((s) => [s.id, s]));
2928
4601
  const billingRows = [];
2929
- for (const a of rows(appsRes.applications)) {
4602
+ for (const a of rows3(appsRes.applications)) {
2930
4603
  if (!a.stripeCustomerId && !a.stripeSubscriptionId) continue;
2931
4604
  const sub = a.stripeSubscriptionId ? subById.get(a.stripeSubscriptionId) : void 0;
2932
4605
  const items = sub?.items?.data ?? [];
@@ -2970,7 +4643,7 @@ var handleAdminBilling = async (req, url, env, ctx) => {
2970
4643
 
2971
4644
  // src/worker-routes-admin-lifecycle.ts
2972
4645
  var import_calendar3 = require("@odla-ai/calendar");
2973
- async function gate2(req, env, ctx) {
4646
+ async function gate3(req, env, ctx) {
2974
4647
  const rawDb = ctx.makeDb(env);
2975
4648
  const u = await ctx.verifyUser(req, env);
2976
4649
  if (!u) return json({ error: "unauthorized" }, 401);
@@ -2996,7 +4669,7 @@ async function loadGroup(db, id) {
2996
4669
  var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
2997
4670
  const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/reschedule$/);
2998
4671
  if (req.method !== "POST" || !m) return null;
2999
- const got = await gate2(req, env, ctx);
4672
+ const got = await gate3(req, env, ctx);
3000
4673
  if (got instanceof Response) return got;
3001
4674
  const db = got;
3002
4675
  const body = await readJson(req);
@@ -3037,7 +4710,7 @@ var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
3037
4710
  var handleAdminMeetingCancel = async (req, url, env, ctx) => {
3038
4711
  const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/cancel$/);
3039
4712
  if (req.method !== "POST" || !m) return null;
3040
- const got = await gate2(req, env, ctx);
4713
+ const got = await gate3(req, env, ctx);
3041
4714
  if (got instanceof Response) return got;
3042
4715
  const db = got;
3043
4716
  const { meetings } = await db.query({ meetings: { $: { where: { id: m[1] }, limit: 1 } } });
@@ -3060,43 +4733,20 @@ var handleAdminMeetingCancel = async (req, url, env, ctx) => {
3060
4733
  var handleAdminApprove = async (req, url, env, ctx) => {
3061
4734
  const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/approve$/);
3062
4735
  if (req.method !== "POST" || !m) return null;
3063
- const got = await gate2(req, env, ctx);
4736
+ const got = await gate3(req, env, ctx);
3064
4737
  if (got instanceof Response) return got;
3065
4738
  const db = got;
3066
4739
  const id = m[1];
3067
4740
  const app = await loadApp(db, id);
3068
4741
  if (!app) return json({ error: "not found" }, 404);
3069
- if (!canApprove(String(app.status), ctx.chapter.pipeline)) return json({ error: `cannot approve from status "${String(app.status)}"` }, 409);
3070
- const target = "approved";
3071
- await db.transact([{ t: "update", ns: "applications", id, attrs: { status: target } }]);
3072
- await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
3073
- const { promoteTo, send } = ctx.chapter.operations.onApprove;
3074
- let rolePromoted = false;
3075
- const sk = await getVaultSecret(db, "clerk_secret_key");
3076
- if (promoteTo !== false && sk) {
3077
- let userId = app.clerkUserId || null;
3078
- if (!userId) {
3079
- const found = await clerkGetUserByEmail(sk, String(app.email));
3080
- userId = found?.id ?? null;
3081
- if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
3082
- }
3083
- if (userId) rolePromoted = await clerkSetRole(sk, userId, promoteTo);
3084
- }
3085
- let emailLogged = false;
3086
- const group = await loadGroup(db, String(app.groupId ?? ctx.chapter.id));
3087
- if (send !== false && group) {
3088
- const res = await sendTemplated(
3089
- { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
3090
- { group: emailGroupFrom(group), template: send, to: String(app.email), vars: { firstName: String(app.firstName ?? ""), membersUrl: `${url.origin}/members/` }, applicationId: id, dedupeKey: `approve:${id}` }
3091
- );
3092
- emailLogged = res.sent;
3093
- }
3094
- return json({ ok: true, status: target, rolePromoted, emailLogged });
4742
+ const result = await approveApplication({ db, env, ctx, app, origin: url.origin, reason: "manual" });
4743
+ if (!result.ok) return json({ error: result.error }, result.status);
4744
+ return json({ ok: true, status: "approved", rolePromoted: result.rolePromoted, emailLogged: result.emailLogged });
3095
4745
  };
3096
4746
  var handleAdminRefund = async (req, url, env, ctx) => {
3097
4747
  const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/refund$/);
3098
4748
  if (req.method !== "POST" || !m) return null;
3099
- const got = await gate2(req, env, ctx);
4749
+ const got = await gate3(req, env, ctx);
3100
4750
  if (got instanceof Response) return got;
3101
4751
  const db = got;
3102
4752
  const app = await loadApp(db, m[1]);
@@ -3128,7 +4778,7 @@ var handleAdminRefund = async (req, url, env, ctx) => {
3128
4778
  var handleAdminApplicationPatch = async (req, url, env, ctx) => {
3129
4779
  const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)$/);
3130
4780
  if (req.method !== "PATCH" || !m) return null;
3131
- const got = await gate2(req, env, ctx);
4781
+ const got = await gate3(req, env, ctx);
3132
4782
  if (got instanceof Response) return got;
3133
4783
  const db = got;
3134
4784
  const id = m[1];
@@ -3156,7 +4806,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
3156
4806
  };
3157
4807
 
3158
4808
  // src/worker-routes-admin-comms.ts
3159
- async function gate3(req, env, ctx) {
4809
+ async function gate4(req, env, ctx) {
3160
4810
  const rawDb = ctx.makeDb(env);
3161
4811
  const u = await ctx.verifyUser(req, env);
3162
4812
  if (!u) return json({ error: "unauthorized" }, 401);
@@ -3178,7 +4828,7 @@ var notifyDeps = (db, env) => ({
3178
4828
  });
3179
4829
  var handleAdminGroupEmail = async (req, url, env, ctx) => {
3180
4830
  if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
3181
- const got = await gate3(req, env, ctx);
4831
+ const got = await gate4(req, env, ctx);
3182
4832
  if (got instanceof Response) return got;
3183
4833
  const db = got;
3184
4834
  const group = await loadGroup2(db, ctx.chapter.id);
@@ -3219,11 +4869,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
3219
4869
  for (const key of EMAIL_TEMPLATE_NAMES) {
3220
4870
  const t = templates[key];
3221
4871
  const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
3222
- const text2 = typeof t?.text === "string" ? t.text : "";
3223
- if (!subject || !text2.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
4872
+ const text3 = typeof t?.text === "string" ? t.text : "";
4873
+ if (!subject || !text3.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
3224
4874
  if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key}" subject must be a single line under 200 characters` }, 400);
3225
- if (text2.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
3226
- clean[key] = { subject, text: text2, enabled: t?.enabled !== false };
4875
+ if (text3.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
4876
+ clean[key] = { subject, text: text3, enabled: t?.enabled !== false };
3227
4877
  }
3228
4878
  const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3229
4879
  const notificationEmail = str2(body.notificationEmail).trim();
@@ -3240,7 +4890,7 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
3240
4890
  };
3241
4891
  var handleAdminEmailLog = async (req, url, env, ctx) => {
3242
4892
  if (req.method !== "GET" || url.pathname !== "/api/admin/email/log") return null;
3243
- const got = await gate3(req, env, ctx);
4893
+ const got = await gate4(req, env, ctx);
3244
4894
  if (got instanceof Response) return got;
3245
4895
  const { emailLog } = await got.query({ emailLog: { $: { order: { sentAt: "desc" }, limit: 50 } } });
3246
4896
  const sends = (emailLog ?? []).map((r) => ({
@@ -3257,7 +4907,7 @@ var handleAdminEmailLog = async (req, url, env, ctx) => {
3257
4907
  };
3258
4908
  var handleAdminEmailTest = async (req, url, env, ctx) => {
3259
4909
  if (req.method !== "POST" || url.pathname !== "/api/admin/email/test") return null;
3260
- const got = await gate3(req, env, ctx);
4910
+ const got = await gate4(req, env, ctx);
3261
4911
  if (got instanceof Response) return got;
3262
4912
  const db = got;
3263
4913
  let body;
@@ -3285,7 +4935,7 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
3285
4935
  var handleAdminComms = async (req, url, env, ctx) => {
3286
4936
  const m = url.pathname.match(/^\/api\/admin\/people\/([0-9a-fA-F-]+)\/comms$/);
3287
4937
  if (req.method !== "GET" || !m) return null;
3288
- const got = await gate3(req, env, ctx);
4938
+ const got = await gate4(req, env, ctx);
3289
4939
  if (got instanceof Response) return got;
3290
4940
  const db = got;
3291
4941
  const appId = m[1];
@@ -3318,7 +4968,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
3318
4968
 
3319
4969
  // src/worker-routes-network.ts
3320
4970
  var import_crm6 = require("@odla-ai/crm");
3321
- var import_db5 = require("@odla-ai/db");
4971
+ var import_db7 = require("@odla-ai/db");
3322
4972
 
3323
4973
  // src/worker-network-fetch.ts
3324
4974
  function targetFetcher(env, target) {
@@ -3336,8 +4986,8 @@ function fetchNetworkTarget(env, target, destination, init) {
3336
4986
 
3337
4987
  // src/worker-network-push.ts
3338
4988
  var import_crm5 = require("@odla-ai/crm");
3339
- var import_db4 = require("@odla-ai/db");
3340
- async function pushNetworkRecord(db, env, ctx, target, record) {
4989
+ var import_db6 = require("@odla-ai/db");
4990
+ async function pushNetworkRecord(db, env, ctx, target, record3) {
3341
4991
  const secret = await getVaultSecret(db, target.secretName);
3342
4992
  const crmDeps3 = {
3343
4993
  crm: ctx.chapter.crm,
@@ -3348,7 +4998,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3348
4998
  if (!secret) {
3349
4999
  const error = `vault secret "${target.secretName}" is missing`;
3350
5000
  await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
3351
- recordId: record.id,
5001
+ recordId: record3.id,
3352
5002
  targetId: target.id,
3353
5003
  status: "failed",
3354
5004
  payloadVersion: 2,
@@ -3358,7 +5008,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3358
5008
  }
3359
5009
  let payload;
3360
5010
  try {
3361
- payload = sharedRecordFromCrm(ctx.chapter.crm, record, target, ctx.chapter.id);
5011
+ payload = sharedRecordFromCrm(ctx.chapter.crm, record3, target, ctx.chapter.id);
3362
5012
  } catch (err) {
3363
5013
  return {
3364
5014
  id: target.id,
@@ -3370,7 +5020,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3370
5020
  const payloadBody = JSON.stringify(payload);
3371
5021
  const destination = new URL("/api/network/shared", target.url);
3372
5022
  try {
3373
- const signed = await (0, import_db4.signFederatedRequest)({
5023
+ const signed = await (0, import_db6.signFederatedRequest)({
3374
5024
  secret,
3375
5025
  sender: ctx.chapter.id,
3376
5026
  method: "POST",
@@ -3387,7 +5037,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3387
5037
  if (!res.ok) {
3388
5038
  const error = responseBody.error ?? "follower rejected the record";
3389
5039
  await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
3390
- recordId: record.id,
5040
+ recordId: record3.id,
3391
5041
  targetId: target.id,
3392
5042
  status: "failed",
3393
5043
  payloadVersion: 2,
@@ -3402,7 +5052,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3402
5052
  };
3403
5053
  }
3404
5054
  await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
3405
- recordId: record.id,
5055
+ recordId: record3.id,
3406
5056
  targetId: target.id,
3407
5057
  status: "delivered",
3408
5058
  payloadVersion: 2,
@@ -3418,7 +5068,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3418
5068
  } catch (err) {
3419
5069
  const error = err instanceof Error ? err.message : "delivery failed";
3420
5070
  await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
3421
- recordId: record.id,
5071
+ recordId: record3.id,
3422
5072
  targetId: target.id,
3423
5073
  status: "failed",
3424
5074
  payloadVersion: 2,
@@ -3429,7 +5079,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3429
5079
  }
3430
5080
 
3431
5081
  // src/worker-routes-network.ts
3432
- async function gate4(req, env, ctx) {
5082
+ async function gate5(req, env, ctx) {
3433
5083
  const db = ctx.makeDb(env);
3434
5084
  const user = await ctx.verifyUser(req, env);
3435
5085
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3438,7 +5088,7 @@ async function gate4(req, env, ctx) {
3438
5088
  }
3439
5089
  var handleAdminNetworkTargets = async (req, url, env, ctx) => {
3440
5090
  if (req.method !== "GET" || url.pathname !== "/api/admin/network/targets") return null;
3441
- const got = await gate4(req, env, ctx);
5091
+ const got = await gate5(req, env, ctx);
3442
5092
  if ("response" in got) return got.response;
3443
5093
  return json({
3444
5094
  targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
@@ -3455,7 +5105,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
3455
5105
  const db = ctx.makeDb(env);
3456
5106
  const secret = await getVaultSecret(db, "network_share_secret");
3457
5107
  if (!secret) return json({ error: "unauthorized" }, 401);
3458
- const verified = await (0, import_db5.verifyFederatedRequest)(req, { secret });
5108
+ const verified = await (0, import_db7.verifyFederatedRequest)(req, { secret });
3459
5109
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
3460
5110
  const types = await Promise.all(
3461
5111
  Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm6.typeSummary)({ crm: ctx.chapter.crm, db }, type))
@@ -3473,7 +5123,7 @@ async function snapshotOne(db, env, ctx, target) {
3473
5123
  if (!secret) return { id: target.id, name: target.name, url: target.url, available: false, error: "edge secret is missing" };
3474
5124
  const destination = new URL("/api/network/snapshot", target.url);
3475
5125
  try {
3476
- const headers = await (0, import_db5.signFederatedRequest)({
5126
+ const headers = await (0, import_db7.signFederatedRequest)({
3477
5127
  secret,
3478
5128
  sender: ctx.chapter.id,
3479
5129
  method: "GET",
@@ -3514,7 +5164,7 @@ async function snapshotOne(db, env, ctx, target) {
3514
5164
  }
3515
5165
  var handleAdminNetworkRollup = async (req, url, env, ctx) => {
3516
5166
  if (req.method !== "GET" || url.pathname !== "/api/admin/network/rollup") return null;
3517
- const got = await gate4(req, env, ctx);
5167
+ const got = await gate5(req, env, ctx);
3518
5168
  if ("response" in got) return got.response;
3519
5169
  const targets = await Promise.all(
3520
5170
  ctx.chapter.network.targets.map((target) => snapshotOne(got.db, env, ctx, target))
@@ -3541,7 +5191,7 @@ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
3541
5191
  };
3542
5192
  var handleAdminNetworkPush = async (req, url, env, ctx) => {
3543
5193
  if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
3544
- const got = await gate4(req, env, ctx);
5194
+ const got = await gate5(req, env, ctx);
3545
5195
  if ("response" in got) return got.response;
3546
5196
  let body;
3547
5197
  try {
@@ -3556,22 +5206,22 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
3556
5206
  if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
3557
5207
  const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
3558
5208
  if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
3559
- const record = await (0, import_crm6.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
3560
- if (!record) return json({ error: "record not found" }, 404);
5209
+ const record3 = await (0, import_crm6.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
5210
+ if (!record3) return json({ error: "record not found" }, 404);
3561
5211
  const results = await Promise.all(
3562
- targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record))
5212
+ targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record3))
3563
5213
  );
3564
5214
  return json({ ok: results.every((result) => result.ok), results });
3565
5215
  };
3566
5216
 
3567
5217
  // src/worker-routes-network-records.ts
3568
5218
  var import_crm7 = require("@odla-ai/crm");
3569
- var import_db6 = require("@odla-ai/db");
5219
+ var import_db8 = require("@odla-ai/db");
3570
5220
  var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
3571
5221
  var MAX_PAGE = 50;
3572
5222
  var MAX_OFFSET = 1e4;
3573
5223
  var MAX_SEARCH = 200;
3574
- async function gate5(req, env, ctx) {
5224
+ async function gate6(req, env, ctx) {
3575
5225
  const db = ctx.makeDb(env);
3576
5226
  const user = await ctx.verifyUser(req, env);
3577
5227
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3584,18 +5234,18 @@ function integer(value, fallback, min, max) {
3584
5234
  const parsed = Number(value);
3585
5235
  return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
3586
5236
  }
3587
- function projectRecord(record, allowed) {
5237
+ function projectRecord(record3, allowed2) {
3588
5238
  const fields = Object.fromEntries(
3589
- allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(record.fields, name) ? [[name, record.fields[name]]] : [])
5239
+ allowed2.flatMap((name) => Object.prototype.hasOwnProperty.call(record3.fields, name) ? [[name, record3.fields[name]]] : [])
3590
5240
  );
3591
5241
  return {
3592
- id: record.id,
3593
- type: record.type,
3594
- name: record.name,
3595
- ...record.stage ? { stage: record.stage } : {},
5242
+ id: record3.id,
5243
+ type: record3.type,
5244
+ name: record3.name,
5245
+ ...record3.stage ? { stage: record3.stage } : {},
3596
5246
  fields,
3597
- createdAt: record.createdAt,
3598
- updatedAt: record.updatedAt
5247
+ createdAt: record3.createdAt,
5248
+ updatedAt: record3.updatedAt
3599
5249
  };
3600
5250
  }
3601
5251
  var handleNetworkRecords = async (req, url, env, ctx) => {
@@ -3604,15 +5254,15 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
3604
5254
  const secret = await getVaultSecret(db, "network_share_secret");
3605
5255
  const readers = ctx.chapter.network.readers;
3606
5256
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
3607
- const verified = await (0, import_db6.verifyFederatedRequest)(req, {
5257
+ const verified = await (0, import_db8.verifyFederatedRequest)(req, {
3608
5258
  secret,
3609
5259
  senders: readers.map((reader2) => reader2.id)
3610
5260
  });
3611
5261
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
3612
5262
  const reader = readers.find((candidate) => candidate.id === verified.sender);
3613
5263
  const type = url.searchParams.get("type") ?? "";
3614
- const allowed = reader?.fields[type];
3615
- if (!IDENTIFIER.test(type) || !allowed) return json({ error: "record type is not shared with this reader" }, 403);
5264
+ const allowed2 = reader?.fields[type];
5265
+ if (!IDENTIFIER.test(type) || !allowed2) return json({ error: "record type is not shared with this reader" }, 403);
3616
5266
  const limit = integer(url.searchParams.get("limit"), 25, 1, MAX_PAGE);
3617
5267
  const offset = integer(url.searchParams.get("offset"), 0, 0, MAX_OFFSET);
3618
5268
  const search = (url.searchParams.get("search") ?? "").trim();
@@ -3633,7 +5283,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
3633
5283
  version: 1,
3634
5284
  site: { id: ctx.chapter.id, name: ctx.chapter.name },
3635
5285
  type,
3636
- records: page.records.map((record) => projectRecord(record, allowed)),
5286
+ records: page.records.map((record3) => projectRecord(record3, allowed2)),
3637
5287
  total: page.total,
3638
5288
  limit: page.limit,
3639
5289
  offset: page.offset
@@ -3649,7 +5299,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
3649
5299
  const destination = new URL("/api/network/records", target.url);
3650
5300
  destination.search = query.toString();
3651
5301
  try {
3652
- const headers = await (0, import_db6.signFederatedRequest)({
5302
+ const headers = await (0, import_db8.signFederatedRequest)({
3653
5303
  secret,
3654
5304
  sender: ctx.chapter.id,
3655
5305
  method: "GET",
@@ -3674,7 +5324,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
3674
5324
  }
3675
5325
  var handleAdminNetworkRecords = async (req, url, env, ctx) => {
3676
5326
  if (req.method !== "GET" || url.pathname !== "/api/admin/network/records") return null;
3677
- const got = await gate5(req, env, ctx);
5327
+ const got = await gate6(req, env, ctx);
3678
5328
  if ("response" in got) return got.response;
3679
5329
  const targetId = url.searchParams.get("targetId") ?? "";
3680
5330
  const type = url.searchParams.get("type") ?? "";
@@ -3701,8 +5351,8 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
3701
5351
  // src/worker-routes-network-notes.ts
3702
5352
  var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
3703
5353
  var RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3704
- var MAX_NOTE = 4e3;
3705
- async function gate6(req, env, ctx) {
5354
+ var MAX_NOTE2 = 4e3;
5355
+ async function gate7(req, env, ctx) {
3706
5356
  const db = ctx.makeDb(env);
3707
5357
  const user = await ctx.verifyUser(req, env);
3708
5358
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3736,7 +5386,7 @@ function asNote(row) {
3736
5386
  }
3737
5387
  var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3738
5388
  if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
3739
- const got = await gate6(req, env, ctx);
5389
+ const got = await gate7(req, env, ctx);
3740
5390
  if ("response" in got) return got.response;
3741
5391
  const identity = noteIdentity(url, ctx);
3742
5392
  if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
@@ -3753,7 +5403,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3753
5403
  return json({ notes: (result.networkNotes ?? []).map((row) => asNote(row)) });
3754
5404
  }
3755
5405
  const contentLength = Number(req.headers.get("content-length") ?? 0);
3756
- if (contentLength > MAX_NOTE * 2) return json({ error: "note is too large" }, 413);
5406
+ if (contentLength > MAX_NOTE2 * 2) return json({ error: "note is too large" }, 413);
3757
5407
  let input;
3758
5408
  try {
3759
5409
  input = await req.json();
@@ -3761,7 +5411,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3761
5411
  return json({ error: "invalid JSON body" }, 400);
3762
5412
  }
3763
5413
  const body = typeof input.body === "string" ? input.body.trim() : "";
3764
- if (!body || body.length > MAX_NOTE) return json({ error: `note must be 1\u2013${MAX_NOTE} characters` }, 400);
5414
+ if (!body || body.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
3765
5415
  const id = crypto.randomUUID();
3766
5416
  const note = {
3767
5417
  id,
@@ -3784,13 +5434,13 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3784
5434
 
3785
5435
  // src/worker-routes-network-shared-notes.ts
3786
5436
  var import_crm8 = require("@odla-ai/crm");
3787
- var import_db7 = require("@odla-ai/db");
5437
+ var import_db9 = require("@odla-ai/db");
3788
5438
  var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
3789
5439
  var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3790
5440
  var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
3791
- var MAX_NOTE2 = 4e3;
5441
+ var MAX_NOTE3 = 4e3;
3792
5442
  var NETWORK_META = "chapterNetwork";
3793
- async function gate7(req, env, ctx) {
5443
+ async function gate8(req, env, ctx) {
3794
5444
  const db = ctx.makeDb(env);
3795
5445
  const user = await ctx.verifyUser(req, env);
3796
5446
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3836,14 +5486,14 @@ function sourceLabel(sourceId) {
3836
5486
  }
3837
5487
  var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3838
5488
  if (url.pathname !== "/api/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
3839
- if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_NOTE2 * 2) {
5489
+ if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_NOTE3 * 2) {
3840
5490
  return json({ error: "note is too large" }, 413);
3841
5491
  }
3842
5492
  const db = ctx.makeDb(env);
3843
5493
  const secret = await getVaultSecret(db, "network_share_secret");
3844
5494
  const readers = ctx.chapter.network.readers;
3845
5495
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
3846
- const verified = await (0, import_db7.verifyFederatedRequest)(req, {
5496
+ const verified = await (0, import_db9.verifyFederatedRequest)(req, {
3847
5497
  secret,
3848
5498
  senders: readers.map((reader2) => reader2.id)
3849
5499
  });
@@ -3854,8 +5504,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3854
5504
  if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
3855
5505
  return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
3856
5506
  }
3857
- const record = await (0, import_crm8.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
3858
- if (!record || record.type !== recordType) return json({ error: "record not found" }, 404);
5507
+ const record3 = await (0, import_crm8.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
5508
+ if (!record3 || record3.type !== recordType) return json({ error: "record not found" }, 404);
3859
5509
  if (req.method === "GET") {
3860
5510
  return json({
3861
5511
  version: 1,
@@ -3871,8 +5521,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3871
5521
  }
3872
5522
  const body = typeof input.body === "string" ? input.body.trim() : "";
3873
5523
  const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
3874
- if (!body || body.length > MAX_NOTE2 || !MUTATION_ID.test(mutationId)) {
3875
- return json({ error: `body must be 1\u2013${MAX_NOTE2} characters and mutationId must be valid` }, 400);
5524
+ if (!body || body.length > MAX_NOTE3 || !MUTATION_ID.test(mutationId)) {
5525
+ return json({ error: `body must be 1\u2013${MAX_NOTE3} characters and mutationId must be valid` }, 400);
3876
5526
  }
3877
5527
  const createdAt = Date.now();
3878
5528
  const id = `network:${verified.sender}:${mutationId}`;
@@ -3914,7 +5564,7 @@ function targetFor2(ctx, id) {
3914
5564
  }
3915
5565
  var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
3916
5566
  if (url.pathname !== "/api/admin/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
3917
- const got = await gate7(req, env, ctx);
5567
+ const got = await gate8(req, env, ctx);
3918
5568
  if ("response" in got) return got.response;
3919
5569
  const targetId = url.searchParams.get("targetId") ?? "";
3920
5570
  const recordType = url.searchParams.get("type") ?? "";
@@ -3932,7 +5582,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
3932
5582
  return json({ error: "invalid JSON body" }, 400);
3933
5583
  }
3934
5584
  const note = typeof input.body === "string" ? input.body.trim() : "";
3935
- if (!note || note.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
5585
+ if (!note || note.length > MAX_NOTE3) return json({ error: `note must be 1\u2013${MAX_NOTE3} characters` }, 400);
3936
5586
  body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
3937
5587
  }
3938
5588
  const secret = await getVaultSecret(got.db, target.secretName);
@@ -3940,7 +5590,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
3940
5590
  const destination = new URL("/api/network/shared-notes", target.url);
3941
5591
  destination.search = new URLSearchParams({ type: recordType, recordId }).toString();
3942
5592
  try {
3943
- const headers = await (0, import_db7.signFederatedRequest)({
5593
+ const headers = await (0, import_db9.signFederatedRequest)({
3944
5594
  secret,
3945
5595
  sender: ctx.chapter.id,
3946
5596
  method: req.method,
@@ -4009,15 +5659,15 @@ var handleFormation = async (req, url, env, ctx) => {
4009
5659
  if (submissionId && !SUBMISSION.test(submissionId)) {
4010
5660
  return json({ error: "submissionId must be an 8\u2013128 character base64url value" }, 400);
4011
5661
  }
4012
- const allowed = /* @__PURE__ */ new Set([...formation.required, ...formation.optional]);
4013
- const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !allowed.has(field));
5662
+ const allowed2 = /* @__PURE__ */ new Set([...formation.required, ...formation.optional]);
5663
+ const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !allowed2.has(field));
4014
5664
  if (unknown.length) return json({ error: `field "${unknown[0]}" is not accepted` }, 400);
4015
5665
  for (const field of formation.required) {
4016
5666
  const value = body[field];
4017
5667
  if (typeof value !== "string" || value.trim() === "") return json({ error: `${field} is required` }, 400);
4018
5668
  }
4019
5669
  const input = {};
4020
- for (const field of allowed) {
5670
+ for (const field of allowed2) {
4021
5671
  const value = body[field];
4022
5672
  if (value === void 0) continue;
4023
5673
  const cap = formation.maxLen[field] ?? formation.defaultMaxLen;
@@ -4061,11 +5711,11 @@ var handleFormation = async (req, url, env, ctx) => {
4061
5711
  };
4062
5712
 
4063
5713
  // src/signup-control-delivery.ts
4064
- var import_db8 = require("@odla-ai/db");
5714
+ var import_db10 = require("@odla-ai/db");
4065
5715
  async function deliverSignupControl(options) {
4066
5716
  const destination = new URL(SIGNUP_CONTROL_PATH, options.targetUrl);
4067
5717
  const body = JSON.stringify(options.envelope);
4068
- const signed = await (0, import_db8.signFederatedRequest)({
5718
+ const signed = await (0, import_db10.signFederatedRequest)({
4069
5719
  secret: options.secret,
4070
5720
  sender: options.sender,
4071
5721
  method: "POST",
@@ -4085,6 +5735,56 @@ async function deliverSignupControl(options) {
4085
5735
  return result;
4086
5736
  }
4087
5737
 
5738
+ // src/member-content-delivery.ts
5739
+ var import_db11 = require("@odla-ai/db");
5740
+ async function deliverMemberContent(options) {
5741
+ const destination = new URL(MEMBER_CONTENT_CONTROL_PATH, options.targetUrl);
5742
+ const body = JSON.stringify(options.envelope);
5743
+ const signed = await (0, import_db11.signFederatedRequest)({
5744
+ secret: options.secret,
5745
+ sender: options.sender,
5746
+ method: "POST",
5747
+ url: destination,
5748
+ body
5749
+ });
5750
+ const response = await (options.fetch ?? globalThis.fetch)(destination, {
5751
+ method: "POST",
5752
+ headers: { ...signed, "content-type": "application/json" },
5753
+ body
5754
+ });
5755
+ const result = await response.json();
5756
+ if (!response.ok || !("ok" in result) || result.ok !== true) {
5757
+ const failure = result;
5758
+ throw new Error(`member content rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
5759
+ }
5760
+ return result;
5761
+ }
5762
+
5763
+ // src/admission-grants-delivery.ts
5764
+ var import_db12 = require("@odla-ai/db");
5765
+ async function deliverAdmissionGrants(options) {
5766
+ const destination = new URL(ADMISSION_GRANTS_PATH, options.targetUrl);
5767
+ const body = JSON.stringify(options.envelope);
5768
+ const signed = await (0, import_db12.signFederatedRequest)({
5769
+ secret: options.secret,
5770
+ sender: options.sender,
5771
+ method: "POST",
5772
+ url: destination,
5773
+ body
5774
+ });
5775
+ const response = await (options.fetch ?? globalThis.fetch)(destination, {
5776
+ method: "POST",
5777
+ headers: { ...signed, "content-type": "application/json" },
5778
+ body
5779
+ });
5780
+ const result = await response.json();
5781
+ if (!response.ok || !("ok" in result) || result.ok !== true) {
5782
+ const failure = result;
5783
+ throw new Error(`admission grants rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
5784
+ }
5785
+ return result;
5786
+ }
5787
+
4088
5788
  // src/signup-control-theme.ts
4089
5789
  var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
4090
5790
  var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
@@ -4224,11 +5924,15 @@ var BUILTIN_ROUTES = [
4224
5924
  handleCrm,
4225
5925
  handleNetworkShared,
4226
5926
  handleSignupControl,
5927
+ handleMemberContentControl,
5928
+ handleAdmissionGrants,
4227
5929
  handleNetworkSnapshot,
4228
5930
  handleNetworkRecords,
4229
5931
  handleNetworkSharedNotes,
4230
5932
  handleFormation,
4231
5933
  handleMember,
5934
+ handleMemberContent,
5935
+ handleGifts,
4232
5936
  handleSchedule,
4233
5937
  handlePayments,
4234
5938
  handleAdminMeetings,
@@ -4238,10 +5942,12 @@ var BUILTIN_ROUTES = [
4238
5942
  handleAdminPeopleAccess,
4239
5943
  handleAdminPeopleRole,
4240
5944
  handleAdminCrmSync,
5945
+ handleAdminAdmissionGrants,
4241
5946
  handleAdminClerkPrivateProfileSync,
4242
5947
  // Aggregation
4243
5948
  handleAdminDashboard,
4244
5949
  handleAdminBilling,
5950
+ handleAdminMemberContent,
4245
5951
  // Lifecycle actions
4246
5952
  handleAdminMeetingReschedule,
4247
5953
  handleAdminMeetingCancel,