@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.
@@ -167,6 +167,205 @@ async function applicationIdForSubmission(submissionId) {
167
167
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
168
168
  }
169
169
 
170
+ // src/admission-grants-contract.ts
171
+ var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
172
+ var ADMISSION_GRANTS_PATH = "/api/admission-grants";
173
+
174
+ // src/signup-control-canonical.ts
175
+ var encoder = new TextEncoder();
176
+ function canonicalValue(value) {
177
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
178
+ return JSON.stringify(value);
179
+ }
180
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
181
+ if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
182
+ const record3 = value;
183
+ const entries = Object.keys(record3).sort().flatMap((key) => {
184
+ const item = record3[key];
185
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
186
+ });
187
+ return `{${entries.join(",")}}`;
188
+ }
189
+ function canonicalSignupControl(value) {
190
+ return canonicalValue(value);
191
+ }
192
+ async function signupControlDigest(document) {
193
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
194
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
195
+ }
196
+
197
+ // src/admission-grants.ts
198
+ var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
199
+ var MAX_GRANTS = 2e3;
200
+ var MAX_NOTE = 2e3;
201
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
202
+ var isAdmissionEmail = (value) => EMAIL_RE.test(value);
203
+ function normalizeAdmissionEmail(email) {
204
+ return email.trim().toLowerCase();
205
+ }
206
+ function validateAdmissionGrantDocument(input) {
207
+ const errors = [];
208
+ const root = input && typeof input === "object" ? input : {};
209
+ if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
210
+ const revision = Number(root.revision);
211
+ const generatedAt = Number(root.generatedAt);
212
+ if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
213
+ if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
214
+ const source = root.source && typeof root.source === "object" ? root.source : {};
215
+ const target = root.target && typeof root.target === "object" ? root.target : {};
216
+ const sourceId = typeof source.appId === "string" ? source.appId : "";
217
+ const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
218
+ const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
219
+ const environment = typeof target.environment === "string" ? target.environment : "";
220
+ if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
221
+ if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
222
+ if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
223
+ if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
224
+ if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
225
+ const rawGrants = Array.isArray(root.grants) ? root.grants : [];
226
+ if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
227
+ const grants = [];
228
+ const seenIds = /* @__PURE__ */ new Set();
229
+ const seenEmails = /* @__PURE__ */ new Set();
230
+ for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
231
+ const grant = raw && typeof raw === "object" ? raw : {};
232
+ const id = typeof grant.id === "string" ? grant.id : "";
233
+ const email = typeof grant.email === "string" ? grant.email.trim() : "";
234
+ const normalized = normalizeAdmissionEmail(email);
235
+ const decision = grant.decision;
236
+ const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
237
+ const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
238
+ if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
239
+ if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
240
+ if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
241
+ if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
242
+ if (decision !== "waive_interview" && decision !== "require_interview") {
243
+ errors.push(`document.grants[${index}].decision is invalid`);
244
+ }
245
+ if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
246
+ if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
247
+ errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
248
+ }
249
+ seenIds.add(id);
250
+ seenEmails.add(normalized);
251
+ grants.push({
252
+ id,
253
+ email,
254
+ decision,
255
+ ...note ? { note } : {},
256
+ ...expiresAt !== void 0 ? { expiresAt } : {}
257
+ });
258
+ }
259
+ if (errors.length) return { ok: false, errors };
260
+ return {
261
+ ok: true,
262
+ value: {
263
+ schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
264
+ revision,
265
+ generatedAt,
266
+ source: { appId: sourceId, actor: sourceActor },
267
+ target: { chapterId, environment },
268
+ grants
269
+ }
270
+ };
271
+ }
272
+ var rows = (value) => Array.isArray(value) ? value : [];
273
+ function asGrant(row) {
274
+ if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
275
+ if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
276
+ if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
277
+ return row;
278
+ }
279
+ async function findAdmissionGrant(db, targetId, email, now) {
280
+ const emailNormalized = normalizeAdmissionEmail(email);
281
+ if (!isAdmissionEmail(emailNormalized)) return null;
282
+ const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
283
+ const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
284
+ grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
285
+ )).sort((a, b) => {
286
+ const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
287
+ return scope || b.createdAt - a.createdAt;
288
+ });
289
+ return active[0] ?? null;
290
+ }
291
+ async function hasAdmissionRevision(db, sourceId, targetId, environment) {
292
+ const where = { sourceId, targetId, environment };
293
+ if (db.aggregate) {
294
+ return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
295
+ }
296
+ const found = await db.query({ admissionGrantRevisions: {} });
297
+ return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
298
+ }
299
+ async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
300
+ if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
301
+ const result = await db.query({ admissionGrantRevisions: { $: {
302
+ where: { sourceId, targetId, environment },
303
+ order: { revision: "desc" },
304
+ limit: 1
305
+ } } });
306
+ const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
307
+ return row ? row : null;
308
+ }
309
+ async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
310
+ const { document, digest } = envelope;
311
+ const current = rows((await db.query({ admissionGrants: { $: {
312
+ where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
313
+ limit: MAX_GRANTS
314
+ } } })).admissionGrants);
315
+ const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
316
+ const ops = [];
317
+ for (const row of current) {
318
+ const id = String(row.id ?? "");
319
+ if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
320
+ active: false,
321
+ revokedAt: receivedAt,
322
+ revokedBy: document.source.actor,
323
+ updatedAt: receivedAt,
324
+ updatedBy: document.source.actor
325
+ } });
326
+ }
327
+ for (const grant of document.grants) {
328
+ const id = `admission:parent:${document.source.appId}:${grant.id}`;
329
+ const existing = current.find((row) => row.id === id);
330
+ ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
331
+ id,
332
+ sourceGrantId: grant.id,
333
+ sourceScope: "parent",
334
+ sourceId: document.source.appId,
335
+ targetId: document.target.chapterId,
336
+ email: grant.email,
337
+ emailNormalized: normalizeAdmissionEmail(grant.email),
338
+ decision: grant.decision,
339
+ ...grant.note ? { note: grant.note } : {},
340
+ ...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
341
+ active: true,
342
+ sourceRevision: document.revision,
343
+ createdAt: Number(existing?.createdAt ?? receivedAt),
344
+ createdBy: String(existing?.createdBy ?? document.source.actor),
345
+ updatedAt: receivedAt,
346
+ updatedBy: document.source.actor
347
+ } });
348
+ const retract = [
349
+ ...!grant.note && existing?.note !== void 0 ? ["note"] : [],
350
+ ...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
351
+ ];
352
+ if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
353
+ }
354
+ if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
355
+ const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
356
+ ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
357
+ id: revisionId,
358
+ sourceId: document.source.appId,
359
+ targetId: document.target.chapterId,
360
+ environment: document.target.environment,
361
+ revision: document.revision,
362
+ digest,
363
+ active: true,
364
+ receivedAt
365
+ } });
366
+ await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
367
+ }
368
+
170
369
  // src/member.ts
171
370
  var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
172
371
  var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
@@ -218,9 +417,9 @@ function resolveApplication(a) {
218
417
  validateEmail: a?.validateEmail ?? true
219
418
  };
220
419
  }
221
- var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
420
+ var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
222
421
  function isValidEmail(value) {
223
- return typeof value === "string" && EMAIL_RE.test(value);
422
+ return typeof value === "string" && EMAIL_RE2.test(value);
224
423
  }
225
424
  function clampArray(value, max) {
226
425
  if (!Array.isArray(value)) return value;
@@ -232,14 +431,14 @@ function hasDisclaimerAck(fields) {
232
431
  var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
233
432
  function applicantProfile(chapter, fields) {
234
433
  const app = chapter.application;
235
- const allowed = (f) => app.profileFields.includes(f);
434
+ const allowed2 = (f) => app.profileFields.includes(f);
236
435
  const profile = {};
237
436
  for (const f of [...app.required, ...app.optional]) {
238
- if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
437
+ if (IDENTITY_FIELDS.has(f) || !allowed2(f)) continue;
239
438
  const v = fields[f];
240
439
  if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
241
440
  }
242
- if (fields.focus !== void 0 && allowed("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
441
+ if (fields.focus !== void 0 && allowed2("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
243
442
  return Object.keys(profile).length > 0 ? profile : void 0;
244
443
  }
245
444
  async function submitApplication(db, chapter, fields, opts) {
@@ -270,6 +469,15 @@ async function submitApplication(db, chapter, fields, opts) {
270
469
  }
271
470
  if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
272
471
  if (opts.groupId) row.groupId = opts.groupId;
472
+ if (typeof fields.email === "string" && isValidEmail(fields.email.trim())) {
473
+ row.emailNormalized = normalizeAdmissionEmail(fields.email);
474
+ }
475
+ if (opts.admissionGrant?.decision === "waive_interview") {
476
+ row.admissionGrantId = opts.admissionGrant.id;
477
+ row.admissionSource = opts.admissionGrant.sourceScope;
478
+ row.interviewWaivedAt = opts.now;
479
+ }
480
+ if (opts.giftOfferId) row.giftOfferId = opts.giftOfferId;
273
481
  if (typeof fields.tierId === "string" && fields.tierId) {
274
482
  if (opts.tierIds && !opts.tierIds.includes(fields.tierId)) {
275
483
  return { ok: false, error: "tierId is not an offered tier" };
@@ -277,8 +485,17 @@ async function submitApplication(db, chapter, fields, opts) {
277
485
  row.tierId = fields.tierId;
278
486
  }
279
487
  if (acked) row.disclaimerAckAt = opts.now;
488
+ const ops = [
489
+ { t: "update", ns: "applications", id, attrs: row }
490
+ ];
491
+ if (opts.admissionGrant?.decision === "waive_interview") {
492
+ ops.push({ t: "update", ns: "admissionGrants", id: opts.admissionGrant.id, attrs: {
493
+ consumedAt: opts.now,
494
+ consumedApplicationId: id
495
+ } });
496
+ }
280
497
  const { duplicate } = await db.transact(
281
- [{ t: "update", ns: "applications", id, attrs: row }],
498
+ ops,
282
499
  opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
283
500
  );
284
501
  return { ok: true, id, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
@@ -312,8 +529,8 @@ function tierPayable(tier, group, hasSecretKey) {
312
529
  function byOrder(a, b) {
313
530
  return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
314
531
  }
315
- function resolveTiers(group, rows2 = []) {
316
- const stored = rows2.filter((tier) => tier.active);
532
+ function resolveTiers(group, rows4 = []) {
533
+ const stored = rows4.filter((tier) => tier.active);
317
534
  if (stored.length) return [...stored].sort(byOrder);
318
535
  return [{
319
536
  id: "standard",
@@ -359,15 +576,15 @@ function rowToTier(row, groupId) {
359
576
  }
360
577
  async function loadTiers(db, group) {
361
578
  const groupId = String(group.id ?? "");
362
- let rows2;
579
+ let rows4;
363
580
  try {
364
- rows2 = (await db.query({
581
+ rows4 = (await db.query({
365
582
  tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
366
583
  })).tiers;
367
584
  } catch {
368
- rows2 = void 0;
585
+ rows4 = void 0;
369
586
  }
370
- const list = Array.isArray(rows2) ? rows2.map((row) => rowToTier(row, groupId)) : [];
587
+ const list = Array.isArray(rows4) ? rows4.map((row) => rowToTier(row, groupId)) : [];
371
588
  return resolveTiers(group, list);
372
589
  }
373
590
  async function loadOfferableTiers(db, group, hasSecretKey) {
@@ -481,346 +698,257 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
481
698
  };
482
699
  }
483
700
 
484
- // src/pipeline.ts
485
- function canTransition(from, to, p) {
486
- const fi = p.stages.indexOf(from);
487
- const ti = p.stages.indexOf(to);
488
- return fi >= 0 && ti >= 0 && ti >= fi;
701
+ // src/gift-offers.ts
702
+ var GIFT_OFFER_CATALOG_VERSION = 1;
703
+ var MAX_GIFT_OFFERS = 20;
704
+ var ID2 = /^[a-z][a-z0-9-]{1,79}$/;
705
+ var PRICE = /^price_[A-Za-z0-9]{12,}$/;
706
+ var CURRENCY = /^[a-z]{3}$/;
707
+ var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
708
+ var text = (value, path, errors, max) => {
709
+ if (typeof value !== "string" || !value.trim()) {
710
+ errors.push(`${path} is required`);
711
+ return "";
712
+ }
713
+ if (value.trim().length > max) errors.push(`${path} must be at most ${max} characters`);
714
+ return value.trim();
715
+ };
716
+ function validateGiftOfferCatalog(input, tierIds) {
717
+ const errors = [];
718
+ const root = record(input);
719
+ if (!root) return { ok: false, errors: ["document.giftOffers must be an object"] };
720
+ for (const key of Object.keys(root)) if (key !== "schemaVersion" && key !== "offers") errors.push(`document.giftOffers.${key} is not supported`);
721
+ if (root.schemaVersion !== GIFT_OFFER_CATALOG_VERSION) errors.push("document.giftOffers.schemaVersion must be 1");
722
+ if (!Array.isArray(root.offers)) errors.push("document.giftOffers.offers must be an array");
723
+ const rawOffers = Array.isArray(root.offers) ? root.offers : [];
724
+ if (rawOffers.length > MAX_GIFT_OFFERS) errors.push(`document.giftOffers.offers supports at most ${MAX_GIFT_OFFERS} entries`);
725
+ const ids = /* @__PURE__ */ new Set();
726
+ const offers = [];
727
+ for (const [index, raw] of rawOffers.slice(0, MAX_GIFT_OFFERS).entries()) {
728
+ const path = `document.giftOffers.offers[${index}]`;
729
+ const item = record(raw);
730
+ if (!item) {
731
+ errors.push(`${path} must be an object`);
732
+ continue;
733
+ }
734
+ const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
735
+ for (const key of Object.keys(item)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
736
+ const id = text(item.id, `${path}.id`, errors, 80);
737
+ if (id && !ID2.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
738
+ if (ids.has(id)) errors.push(`${path}.id duplicates "${id}"`);
739
+ ids.add(id);
740
+ const targetRaw = record(item.target);
741
+ let target = { type: "entitlement", entitlementKey: "invalid" };
742
+ if (targetRaw?.type === "tier") {
743
+ const tierId = text(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
744
+ if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
745
+ target = { type: "tier", tierId };
746
+ } else if (targetRaw?.type === "entitlement") {
747
+ const entitlementKey = text(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
748
+ if (entitlementKey && !ID2.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
749
+ target = { type: "entitlement", entitlementKey };
750
+ } else errors.push(`${path}.target.type must be tier or entitlement`);
751
+ if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
752
+ const stripePriceId = text(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
753
+ if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
754
+ 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`);
755
+ const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
756
+ if (!CURRENCY.test(currency)) errors.push(`${path}.currency must be a lowercase ISO currency code`);
757
+ const lifetime = Number(item.redemptionLifetimeHours);
758
+ if (!Number.isSafeInteger(lifetime) || lifetime < 1 || lifetime > 8760) errors.push(`${path}.redemptionLifetimeHours must be an integer from 1 to 8760`);
759
+ const activeFrom = item.activeFrom === void 0 ? void 0 : Number(item.activeFrom);
760
+ const activeUntil = item.activeUntil === void 0 ? void 0 : Number(item.activeUntil);
761
+ if (activeFrom !== void 0 && (!Number.isSafeInteger(activeFrom) || activeFrom < 1)) errors.push(`${path}.activeFrom must be epoch milliseconds`);
762
+ if (activeUntil !== void 0 && (!Number.isSafeInteger(activeUntil) || activeUntil < 1)) errors.push(`${path}.activeUntil must be epoch milliseconds`);
763
+ if (activeFrom !== void 0 && activeUntil !== void 0 && activeUntil <= activeFrom) errors.push(`${path}.activeUntil must follow activeFrom`);
764
+ offers.push({
765
+ id,
766
+ target,
767
+ billingMode: item.billingMode,
768
+ stripePriceId,
769
+ priceCents: Number(item.priceCents),
770
+ currency,
771
+ redemptionLifetimeHours: lifetime,
772
+ ...activeFrom !== void 0 ? { activeFrom } : {},
773
+ ...activeUntil !== void 0 ? { activeUntil } : {},
774
+ title: text(item.title, `${path}.title`, errors, 160),
775
+ description: text(item.description, `${path}.description`, errors, 2e3),
776
+ ctaLabel: text(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
777
+ });
778
+ }
779
+ return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
489
780
  }
490
- function canBook(status, p) {
491
- return p.bookableFrom.includes(status);
781
+ var giftOfferActive = (offer, now) => (!offer.activeFrom || offer.activeFrom <= now) && (!offer.activeUntil || offer.activeUntil > now);
782
+ function publicGiftOffers(catalog, now) {
783
+ return (catalog?.offers ?? []).filter((offer) => giftOfferActive(offer, now)).map(({ stripePriceId: _stripe, target: _target, ...offer }) => offer);
492
784
  }
493
- function canApprove(status, p) {
494
- return p.approvableFrom.includes(status);
785
+ function findGiftOffer(catalog, offerId, now) {
786
+ return catalog?.offers.find((offer) => offer.id === offerId && giftOfferActive(offer, now)) ?? null;
495
787
  }
496
788
 
497
- // src/signup-control-store.ts
498
- function asStored(row) {
499
- if (!row || typeof row.document !== "object" || row.document === null) return null;
500
- return row;
789
+ // src/clerk-roles.ts
790
+ var CLERK_API = "https://api.clerk.com";
791
+ var DEFAULT_ROLE = "provisional";
792
+ var PAGE = 100;
793
+ function toRecord(u) {
794
+ if (typeof u.id !== "string") return null;
795
+ const pm = u.public_metadata ?? {};
796
+ const privateMetadata = u.private_metadata ?? {};
797
+ const rawProfile = privateMetadata.profile;
798
+ const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
799
+ const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
800
+ const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
801
+ const email = u.email_addresses?.[0]?.email_address;
802
+ return {
803
+ id: u.id,
804
+ email: typeof email === "string" ? email : void 0,
805
+ role,
806
+ publicMetadata: pm,
807
+ privateMetadata,
808
+ profile,
809
+ ...applicationId ? { applicationId } : {}
810
+ };
501
811
  }
502
- async function hasSignupControl(db, targetId, environment, active) {
503
- const where = { targetId, environment, ...active ? { active: true } : {} };
504
- if (db.aggregate) {
505
- return Number((await db.aggregate("signupControls", { count: true }, { where })).count ?? 0) > 0;
812
+ async function clerkGet(path, secretKey, fetchImpl) {
813
+ const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
814
+ if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
815
+ return res.json();
816
+ }
817
+ async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
818
+ const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
819
+ const user = Array.isArray(data) ? data[0] : void 0;
820
+ return user ? toRecord(user) : null;
821
+ }
822
+ async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
823
+ const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
824
+ return data ? toRecord(data) : null;
825
+ }
826
+ async function clerkListUsers(secretKey, fetchImpl = fetch) {
827
+ const out = [];
828
+ for (let offset = 0; ; offset += PAGE) {
829
+ const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
830
+ const page = Array.isArray(data) ? data : [];
831
+ for (const u of page) {
832
+ const record3 = toRecord(u);
833
+ if (record3) out.push(record3);
834
+ }
835
+ if (page.length < PAGE) break;
506
836
  }
507
- const rows2 = (await db.query({ signupControls: {} })).signupControls;
508
- return Array.isArray(rows2) && rows2.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
837
+ return out;
509
838
  }
510
- async function loadLatestSignupControl(db, targetId, environment) {
511
- if (!await hasSignupControl(db, targetId, environment)) return null;
512
- const rows2 = (await db.query({ signupControls: { $: {
513
- where: { targetId, environment },
514
- order: { revision: "desc" },
515
- limit: 1
516
- } } })).signupControls;
517
- const latest = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
518
- return asStored(latest);
839
+ async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
840
+ const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
841
+ method: "PATCH",
842
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
843
+ body: JSON.stringify({ public_metadata: { role } })
844
+ });
845
+ return res.ok;
519
846
  }
520
- async function loadActiveSignupControl(db, targetId, environment) {
521
- if (!await hasSignupControl(db, targetId, environment, true)) return null;
522
- const rows2 = (await db.query({ signupControls: { $: {
523
- where: { targetId, environment, active: true },
524
- order: { revision: "desc" },
525
- limit: 1
526
- } } })).signupControls;
527
- const active = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
528
- return asStored(active);
847
+
848
+ // src/crm-sync.ts
849
+ import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
850
+
851
+ // src/network.ts
852
+ import {
853
+ createRecord,
854
+ getRecordByOrigin,
855
+ updateRecord,
856
+ upsertRecordOrigin
857
+ } from "@odla-ai/crm";
858
+
859
+ // src/network-contract.ts
860
+ var DEFAULT_SHARE_FIELDS = {
861
+ person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
862
+ company: ["name", "domain", "industry", "location", "linkedin", "notes"]
863
+ };
864
+
865
+ // src/network.ts
866
+ function sharedPersonInput(person) {
867
+ const email = person.email.toLowerCase();
868
+ const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
869
+ const input = { name: person.name ?? fullName ?? email, email };
870
+ if (input.name === "") input.name = email;
871
+ if (person.firstName) input.firstName = person.firstName;
872
+ if (person.lastName) input.lastName = person.lastName;
873
+ if (person.phone) input.phone = person.phone;
874
+ if (person.linkedin) input.linkedin = person.linkedin;
875
+ return input;
529
876
  }
530
- function controlledApplication(base, document) {
531
- const delegated = document?.application;
532
- return delegated ? { ...base, required: delegated.required, optional: delegated.optional, conditions: { ...delegated.conditions ?? {} } } : base;
877
+ function shortHash(value) {
878
+ let a = 2166136261;
879
+ let b = 2654435769;
880
+ for (let i = 0; i < value.length; i += 1) {
881
+ const n = value.charCodeAt(i);
882
+ a = Math.imul(a ^ n, 16777619);
883
+ b = Math.imul(b ^ n, 2246822507);
884
+ }
885
+ return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
533
886
  }
534
- function validateControlledApplication(chapter, document) {
535
- if (!document.application) return [];
536
- const declared = /* @__PURE__ */ new Set([...chapter.application.required, ...chapter.application.optional]);
537
- 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`);
887
+ function networkSourceTag(type, hubRecordId, sourceId) {
888
+ const typeKey = type.toLowerCase();
889
+ const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
890
+ const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
891
+ const raw = `${prefix}:${hubRecordId}`;
892
+ if (readable && raw.length <= 64) return raw;
893
+ return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
538
894
  }
539
- function groupAttrs(document) {
540
- const first = [...document.tiers].filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder)[0];
541
- const firstPaid = [...document.tiers].find((tier) => tier.available && tier.priceCents > 0);
895
+ function normalizeSharedRecord(record3, fallbackSourceId = "legacy-source") {
896
+ if ("input" in record3 && record3.version === 2) {
897
+ return {
898
+ version: 2,
899
+ sourceId: record3.source.siteId,
900
+ sourceRecordId: record3.source.recordId,
901
+ type: record3.type,
902
+ input: record3.input
903
+ };
904
+ }
905
+ if ("input" in record3) {
906
+ return {
907
+ version: 1,
908
+ sourceId: fallbackSourceId,
909
+ sourceRecordId: record3.hubRecordId,
910
+ type: record3.type,
911
+ input: record3.input
912
+ };
913
+ }
914
+ if ("type" in record3 && record3.type === "company") {
915
+ const input = { name: record3.name };
916
+ for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
917
+ if (record3[key]) input[key] = record3[key];
918
+ }
919
+ return {
920
+ version: 1,
921
+ sourceId: fallbackSourceId,
922
+ sourceRecordId: record3.hubRecordId,
923
+ type: "company",
924
+ input
925
+ };
926
+ }
542
927
  return {
543
- ...first ? { standardPriceCents: first.priceCents } : {},
544
- foundingDiscountCents: 0,
545
- ...firstPaid?.stripePriceId ? { stripePriceId: firstPaid.stripePriceId } : {},
546
- ...document.policy ?? {},
547
- ...document.scheduling ? { schedulingJson: document.scheduling } : {}
928
+ version: 1,
929
+ sourceId: fallbackSourceId,
930
+ sourceRecordId: record3.hubRecordId,
931
+ type: "person",
932
+ input: sharedPersonInput(record3)
548
933
  };
549
934
  }
550
- var byPublicId = (ns, value) => ({
551
- ns,
552
- attr: "id",
553
- value
554
- });
555
- async function materializeSignupControl(db, envelope, previous, receivedAt) {
556
- const { document, digest } = envelope;
557
- const currentRows = (await db.query({ tiers: { $: {
558
- where: { groupId: document.target.chapterId },
559
- limit: 100
560
- } } })).tiers ?? [];
561
- const incoming = new Set(document.tiers.map((tier) => tier.id));
562
- const ops = [];
563
- if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
564
- for (const row of currentRows) {
565
- const id2 = String(row.id ?? "");
566
- if (id2 && !incoming.has(id2)) {
567
- ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
568
- }
569
- }
570
- for (const tier of document.tiers) {
571
- const tierRef = byPublicId("tiers", tier.id);
572
- ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
573
- id: tier.id,
574
- groupId: document.target.chapterId,
575
- name: tier.name,
576
- priceCents: tier.priceCents,
577
- ...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
578
- ...tier.blurb ? { blurb: tier.blurb } : {},
579
- sortOrder: tier.sortOrder,
580
- active: tier.available,
581
- createdAt: receivedAt
582
- } });
583
- const retract = [
584
- ...!tier.stripePriceId ? ["stripePriceId"] : [],
585
- ...!tier.blurb ? ["blurb"] : []
586
- ];
587
- if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
588
- }
589
- const groupRef = byPublicId("groups", document.target.chapterId);
590
- const attrs = groupAttrs(document);
591
- ops.push({
592
- t: "update",
593
- ns: "groups",
594
- id: groupRef,
595
- attrs
596
- });
597
- if (!("stripePriceId" in attrs)) {
598
- ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
599
- }
600
- const id = `signup-control:${document.target.environment}:${document.revision}`;
601
- ops.push({ t: "update", ns: "signupControls", id, attrs: {
602
- id,
603
- sourceId: document.source.appId,
604
- targetId: document.target.chapterId,
605
- environment: document.target.environment,
606
- revision: document.revision,
607
- digest,
608
- document,
609
- active: true,
610
- receivedAt
611
- } });
612
- await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
613
- }
614
-
615
- // src/session.ts
616
- function applicationSummary(app) {
617
- return {
618
- id: app.id,
619
- firstName: app.firstName ?? null,
620
- lastName: app.lastName ?? null,
621
- email: app.email ?? null,
622
- status: app.status,
623
- createdAt: app.createdAt ?? null,
624
- meetingLink: app.meetingLink ?? null,
625
- paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
626
- renewalAt: app.renewalAt ?? null,
627
- canceled: app.canceled === true
628
- };
629
- }
630
- function memberApplication(app, meeting, defaultTimezone) {
631
- const summary = applicationSummary(app);
632
- let meetingAt = app.meetingAt ?? null;
633
- let meetUrl = null;
634
- let timezone = defaultTimezone;
635
- if (meeting) {
636
- timezone = meeting.timezone ?? timezone;
637
- if (meeting.status === "scheduled") {
638
- meetingAt = meeting.startAt ?? null;
639
- meetUrl = meeting.meetUrl ?? null;
640
- } else {
641
- meetingAt = null;
642
- }
643
- }
644
- return { ...summary, meetingAt, meetUrl, timezone };
645
- }
646
-
647
- // src/clerk.ts
648
- var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
649
- function clerkInviteRequest(input) {
650
- return {
651
- path: "/v1/invitations",
652
- body: {
653
- email_address: input.email,
654
- notify: true,
655
- ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
656
- ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
657
- }
658
- };
659
- }
660
- async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
661
- const { path, body } = clerkInviteRequest(input);
662
- const res = await fetchImpl(`https://api.clerk.com${path}`, {
663
- method: "POST",
664
- headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
665
- body: JSON.stringify(body)
666
- });
667
- return res.ok ? { ok: true, status: res.status } : heal(res.status);
668
- }
669
- function clerkUserRequest(input) {
670
- return {
671
- path: "/v1/users",
672
- body: {
673
- email_address: [input.email],
674
- skip_password_requirement: true,
675
- ...input.firstName ? { first_name: input.firstName } : {},
676
- ...input.lastName ? { last_name: input.lastName } : {},
677
- ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
678
- ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
679
- }
680
- };
681
- }
682
- async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
683
- const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
684
- method: "PATCH",
685
- headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
686
- body: JSON.stringify({
687
- ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
688
- ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
689
- })
690
- });
691
- return res.ok;
692
- }
693
- async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
694
- const auth = { authorization: `Bearer ${secretKey}` };
695
- const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
696
- if (!found.ok) return false;
697
- const users = await found.json().catch(() => null);
698
- const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
699
- if (!id) return false;
700
- return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
701
- }
702
- async function createClerkUser(secretKey, input, fetchImpl = fetch) {
703
- const { path, body } = clerkUserRequest(input);
704
- const res = await fetchImpl(`https://api.clerk.com${path}`, {
705
- method: "POST",
706
- headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
707
- body: JSON.stringify(body)
708
- });
709
- if (res.ok) return { ok: true, status: res.status };
710
- const healed = heal(res.status);
711
- const repair = input.repairMetadata ?? {
712
- ...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
713
- ...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
714
- };
715
- if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
716
- const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
717
- ...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
718
- ...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
719
- }, fetchImpl).catch(() => false);
720
- return { ...healed, refreshed };
721
- }
722
-
723
- // src/network.ts
724
- import {
725
- createRecord,
726
- getRecordByOrigin,
727
- updateRecord,
728
- upsertRecordOrigin
729
- } from "@odla-ai/crm";
730
-
731
- // src/network-contract.ts
732
- var DEFAULT_SHARE_FIELDS = {
733
- person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
734
- company: ["name", "domain", "industry", "location", "linkedin", "notes"]
735
- };
736
-
737
- // src/network.ts
738
- function sharedPersonInput(person) {
739
- const email = person.email.toLowerCase();
740
- const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
741
- const input = { name: person.name ?? fullName ?? email, email };
742
- if (input.name === "") input.name = email;
743
- if (person.firstName) input.firstName = person.firstName;
744
- if (person.lastName) input.lastName = person.lastName;
745
- if (person.phone) input.phone = person.phone;
746
- if (person.linkedin) input.linkedin = person.linkedin;
747
- return input;
748
- }
749
- function shortHash(value) {
750
- let a = 2166136261;
751
- let b = 2654435769;
752
- for (let i = 0; i < value.length; i += 1) {
753
- const n = value.charCodeAt(i);
754
- a = Math.imul(a ^ n, 16777619);
755
- b = Math.imul(b ^ n, 2246822507);
756
- }
757
- return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
758
- }
759
- function networkSourceTag(type, hubRecordId, sourceId) {
760
- const typeKey = type.toLowerCase();
761
- const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
762
- const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
763
- const raw = `${prefix}:${hubRecordId}`;
764
- if (readable && raw.length <= 64) return raw;
765
- return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
766
- }
767
- function normalizeSharedRecord(record, fallbackSourceId = "legacy-source") {
768
- if ("input" in record && record.version === 2) {
769
- return {
770
- version: 2,
771
- sourceId: record.source.siteId,
772
- sourceRecordId: record.source.recordId,
773
- type: record.type,
774
- input: record.input
775
- };
776
- }
777
- if ("input" in record) {
778
- return {
779
- version: 1,
780
- sourceId: fallbackSourceId,
781
- sourceRecordId: record.hubRecordId,
782
- type: record.type,
783
- input: record.input
784
- };
785
- }
786
- if ("type" in record && record.type === "company") {
787
- const input = { name: record.name };
788
- for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
789
- if (record[key]) input[key] = record[key];
790
- }
791
- return {
792
- version: 1,
793
- sourceId: fallbackSourceId,
794
- sourceRecordId: record.hubRecordId,
795
- type: "company",
796
- input
797
- };
798
- }
799
- return {
800
- version: 1,
801
- sourceId: fallbackSourceId,
802
- sourceRecordId: record.hubRecordId,
803
- type: "person",
804
- input: sharedPersonInput(record)
805
- };
806
- }
807
- function sharedRecordFromCrm(crm, record, target, sourceId) {
808
- if (target.fields && !target.fields[record.type]) {
809
- throw new Error(`${target.name} does not accept "${record.type}" records`);
935
+ function sharedRecordFromCrm(crm, record3, target, sourceId) {
936
+ if (target.fields && !target.fields[record3.type]) {
937
+ throw new Error(`${target.name} does not accept "${record3.type}" records`);
810
938
  }
811
- const def = crm.type(record.type);
812
- const fields = target.fields?.[record.type] ?? DEFAULT_SHARE_FIELDS[record.type];
939
+ const def = crm.type(record3.type);
940
+ const fields = target.fields?.[record3.type] ?? DEFAULT_SHARE_FIELDS[record3.type];
813
941
  if (!fields) {
814
- throw new Error(`${target.name} requires an explicit field allowlist for "${record.type}" records`);
942
+ throw new Error(`${target.name} requires an explicit field allowlist for "${record3.type}" records`);
815
943
  }
816
944
  const nameField = def.nameField ?? "name";
817
945
  const input = {};
818
946
  for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
819
- const value = record.fields?.[field];
947
+ const value = record3.fields?.[field];
820
948
  if (value !== void 0) input[field] = value;
821
949
  }
822
- if (input[nameField] === void 0) input[nameField] = record.name;
823
- return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record.id }, type: record.type, input } : { version: 1, type: record.type, hubRecordId: record.id, input };
950
+ if (input[nameField] === void 0) input[nameField] = record3.name;
951
+ return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record3.id }, type: record3.type, input } : { version: 1, type: record3.type, hubRecordId: record3.id, input };
824
952
  }
825
953
  async function upsertPerson(deps, opts) {
826
954
  const email = opts.email.toLowerCase();
@@ -834,11 +962,11 @@ async function upsertPerson(deps, opts) {
834
962
  const created = await createRecord(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
835
963
  return { recordId: created.id };
836
964
  }
837
- async function findSharedRecord(deps, record, tag) {
965
+ async function findSharedRecord(deps, record3, tag) {
838
966
  const structured = await getRecordByOrigin(
839
967
  { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
840
- record.sourceId,
841
- record.sourceRecordId
968
+ record3.sourceId,
969
+ record3.sourceRecordId
842
970
  );
843
971
  if (structured) return structured;
844
972
  const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
@@ -847,53 +975,53 @@ async function findSharedRecord(deps, record, tag) {
847
975
  const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
848
976
  if (found.crm_record?.[0]) return found.crm_record[0];
849
977
  }
850
- const def = deps.crm.type(record.type);
978
+ const def = deps.crm.type(record3.type);
851
979
  const emailField = def.emailField;
852
- if (emailField && typeof record.input[emailField] === "string") {
853
- const primaryEmail = record.input[emailField].toLowerCase();
980
+ if (emailField && typeof record3.input[emailField] === "string") {
981
+ const primaryEmail = record3.input[emailField].toLowerCase();
854
982
  const found = await deps.db.query({
855
- crm_record: { $: { where: { type: record.type, primaryEmail }, limit: 1 } }
983
+ crm_record: { $: { where: { type: record3.type, primaryEmail }, limit: 1 } }
856
984
  });
857
985
  if (found.crm_record?.[0]) return found.crm_record[0];
858
986
  }
859
- const domain = record.input.domain;
987
+ const domain = record3.input.domain;
860
988
  const domainSlot = def.fields.domain?.slot;
861
989
  if (typeof domain === "string" && domainSlot) {
862
990
  const found = await deps.db.query({
863
- crm_record: { $: { where: { type: record.type, [domainSlot]: domain }, limit: 1 } }
991
+ crm_record: { $: { where: { type: record3.type, [domainSlot]: domain }, limit: 1 } }
864
992
  });
865
993
  if (found.crm_record?.[0]) return found.crm_record[0];
866
994
  }
867
995
  const nameField = def.nameField ?? "name";
868
- const name = record.input[nameField];
869
- if (record.type === "company" && typeof name === "string" && name.trim()) {
996
+ const name = record3.input[nameField];
997
+ if (record3.type === "company" && typeof name === "string" && name.trim()) {
870
998
  const found = await deps.db.query({
871
- crm_record: { $: { where: { type: record.type, name: name.trim() }, limit: 1 } }
999
+ crm_record: { $: { where: { type: record3.type, name: name.trim() }, limit: 1 } }
872
1000
  });
873
1001
  if (found.crm_record?.[0]) return found.crm_record[0];
874
1002
  }
875
1003
  return void 0;
876
1004
  }
877
1005
  async function projectSharedRecord(deps, shared, options = {}) {
878
- const record = normalizeSharedRecord(shared, options.sourceId);
879
- if (!record.type.trim() || !record.sourceId.trim() || !record.sourceRecordId.trim()) {
1006
+ const record3 = normalizeSharedRecord(shared, options.sourceId);
1007
+ if (!record3.type.trim() || !record3.sourceId.trim() || !record3.sourceRecordId.trim()) {
880
1008
  throw new Error("type, source site id, and source record id must be non-empty");
881
1009
  }
882
- if (options.sourceId && options.sourceId !== record.sourceId) {
1010
+ if (options.sourceId && options.sourceId !== record3.sourceId) {
883
1011
  throw new Error("signed sender does not match payload source");
884
1012
  }
885
- const tag = networkSourceTag(record.type, record.sourceRecordId, record.version === 2 ? record.sourceId : void 0);
1013
+ const tag = networkSourceTag(record3.type, record3.sourceRecordId, record3.version === 2 ? record3.sourceId : void 0);
886
1014
  const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
887
- const existing = await findSharedRecord(deps, record, tag);
1015
+ const existing = await findSharedRecord(deps, record3, tag);
888
1016
  let recordId;
889
1017
  if (existing && typeof existing.id === "string") {
890
- await updateRecord(crmDeps3, { id: existing.id, input: record.input });
1018
+ await updateRecord(crmDeps3, { id: existing.id, input: record3.input });
891
1019
  recordId = existing.id;
892
1020
  } else {
893
- recordId = `network_${shortHash(`${record.sourceId}\0${record.type}\0${record.sourceRecordId}`)}`;
1021
+ recordId = `network_${shortHash(`${record3.sourceId}\0${record3.type}\0${record3.sourceRecordId}`)}`;
894
1022
  await createRecord({ ...crmDeps3, newId: () => recordId }, {
895
- type: record.type,
896
- input: record.input,
1023
+ type: record3.type,
1024
+ input: record3.input,
897
1025
  mutationId: `share-create:${tag}`
898
1026
  });
899
1027
  }
@@ -903,10 +1031,10 @@ async function projectSharedRecord(deps, shared, options = {}) {
903
1031
  );
904
1032
  await upsertRecordOrigin(crmDeps3, {
905
1033
  recordId,
906
- sourceId: record.sourceId,
907
- sourceRecordId: record.sourceRecordId,
1034
+ sourceId: record3.sourceId,
1035
+ sourceRecordId: record3.sourceRecordId,
908
1036
  ...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
909
- payloadVersion: record.version
1037
+ payloadVersion: record3.version
910
1038
  });
911
1039
  return { recordId };
912
1040
  }
@@ -929,29 +1057,115 @@ async function projectApplicant(deps, applicant) {
929
1057
  }
930
1058
  }
931
1059
 
932
- // src/email.ts
933
- function render(template, vars) {
934
- return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
935
- }
936
- function groupVars(group, vars) {
937
- return {
938
- ...vars,
939
- refundPolicyText: group.refundPolicyText ?? "",
940
- commitmentText: group.commitmentText ?? "",
941
- normsText: group.normsText ?? ""
942
- };
943
- }
944
- function renderTemplateBody(group, template, vars) {
945
- const tpl = group.emailTemplates?.[template];
946
- if (!tpl) return null;
947
- return render(tpl.text, groupVars(group, vars));
948
- }
949
- function isAlreadySent(priorRows) {
950
- return priorRows.some((row) => !row.error);
951
- }
952
- function planDelivery(input) {
953
- const tpl = input.group.emailTemplates?.[input.template];
954
- if (!tpl) return { deliver: false, reason: "template-missing" };
1060
+ // src/crm-sync.ts
1061
+ var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
1062
+ var crmDeps = (db, ctx) => ({
1063
+ crm: ctx.chapter.crm,
1064
+ db,
1065
+ now: () => Date.now(),
1066
+ newId: () => crypto.randomUUID(),
1067
+ chapter: ctx.chapter
1068
+ });
1069
+ function personInputFromApp(chapter, app) {
1070
+ const input = sharedPersonInput({
1071
+ email: str(app.email),
1072
+ firstName: str(app.firstName) || void 0,
1073
+ lastName: str(app.lastName) || void 0,
1074
+ phone: str(app.phone) || void 0,
1075
+ linkedin: str(app.linkedin) || void 0,
1076
+ hubRecordId: str(app.id)
1077
+ });
1078
+ for (const f of chapter.application.crmFields) {
1079
+ if (app[f] !== void 0) input[f] = app[f];
1080
+ }
1081
+ if (app.id !== void 0) input.applicationId = str(app.id);
1082
+ return input;
1083
+ }
1084
+ function billingColumns(app) {
1085
+ const status = str(app.status);
1086
+ const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
1087
+ const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
1088
+ const cols = { billingStatus };
1089
+ if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
1090
+ if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
1091
+ if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
1092
+ return cols;
1093
+ }
1094
+ async function syncApplicationToCrm(deps, opts) {
1095
+ const emailKey = str(opts.app.email).toLowerCase();
1096
+ if (!emailKey) return null;
1097
+ const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1098
+ const input = personInputFromApp(deps.chapter, opts.app);
1099
+ const { crm_record } = await deps.db.query({
1100
+ crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
1101
+ });
1102
+ const existing = crm_record?.[0] ?? null;
1103
+ const stage = opts.stage || void 0;
1104
+ let recordId;
1105
+ if (existing && typeof existing.id === "string") {
1106
+ recordId = existing.id;
1107
+ await updateRecord2(recordDeps, { id: recordId, input });
1108
+ } else {
1109
+ const created = await createRecord2(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
1110
+ recordId = created.id;
1111
+ }
1112
+ if (existing && stage && existing.stage !== stage) {
1113
+ await setStage(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1114
+ }
1115
+ await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1116
+ await linkIdentity(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1117
+ return recordId;
1118
+ }
1119
+ async function backfillCrm(deps) {
1120
+ const [appsRes, usersRes] = await Promise.all([
1121
+ deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
1122
+ deps.db.query({ $users: { $: { limit: 1e3 } } })
1123
+ ]);
1124
+ const seen = /* @__PURE__ */ new Set();
1125
+ let synced = 0;
1126
+ const errors = [];
1127
+ const run = async (app, stage) => {
1128
+ const key = str(app.email).toLowerCase();
1129
+ if (!key || seen.has(key)) return;
1130
+ seen.add(key);
1131
+ try {
1132
+ await syncApplicationToCrm(deps, { app, stage });
1133
+ synced += 1;
1134
+ } catch (err) {
1135
+ errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
1136
+ }
1137
+ };
1138
+ for (const a of appsRes.applications ?? []) await run(a, str(a.status));
1139
+ for (const u of usersRes.$users ?? []) {
1140
+ if (u.deleted === true) continue;
1141
+ await run({ email: u.email, firstName: str(u.name) });
1142
+ }
1143
+ return { synced, errors };
1144
+ }
1145
+
1146
+ // src/email.ts
1147
+ function render(template, vars) {
1148
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
1149
+ }
1150
+ function groupVars(group, vars) {
1151
+ return {
1152
+ ...vars,
1153
+ refundPolicyText: group.refundPolicyText ?? "",
1154
+ commitmentText: group.commitmentText ?? "",
1155
+ normsText: group.normsText ?? ""
1156
+ };
1157
+ }
1158
+ function renderTemplateBody(group, template, vars) {
1159
+ const tpl = group.emailTemplates?.[template];
1160
+ if (!tpl) return null;
1161
+ return render(tpl.text, groupVars(group, vars));
1162
+ }
1163
+ function isAlreadySent(priorRows) {
1164
+ return priorRows.some((row) => !row.error);
1165
+ }
1166
+ function planDelivery(input) {
1167
+ const tpl = input.group.emailTemplates?.[input.template];
1168
+ if (!tpl) return { deliver: false, reason: "template-missing" };
955
1169
  if (tpl.enabled === false && !input.force) return { deliver: false, reason: "disabled" };
956
1170
  const vars = groupVars(input.group, input.vars);
957
1171
  const isProd = input.envName === "prod";
@@ -959,10 +1173,10 @@ function planDelivery(input) {
959
1173
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
960
1174
  const to = redirect ? input.group.debugEmail : input.to;
961
1175
  const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
962
- const text2 = redirect ? `(dev redirect; original recipient: ${input.to})
1176
+ const text3 = redirect ? `(dev redirect; original recipient: ${input.to})
963
1177
 
964
1178
  ` + render(tpl.text, vars) : render(tpl.text, vars);
965
- return { deliver: true, transport, to, subject, text: text2, redirected: redirect };
1179
+ return { deliver: true, transport, to, subject, text: text3, redirected: redirect };
966
1180
  }
967
1181
 
968
1182
  // src/notify.ts
@@ -1032,144 +1246,482 @@ function emailGroupFrom(row) {
1032
1246
  };
1033
1247
  }
1034
1248
 
1035
- // src/worker-provisioning.ts
1036
- var applicantPrivateMetadata = (chapter, applicationId, fields, removeMissingProfile = false) => {
1037
- const profile = applicantProfile(chapter, fields);
1038
- return {
1039
- applicationId,
1040
- ...profile ? { profile } : removeMissingProfile ? { profile: null } : {}
1041
- };
1042
- };
1043
- var publicProfileRemoval = { applicationId: null, profile: null };
1044
- async function markPrivateMetadataSynced(db, applicationId, clerkUserId) {
1045
- await db.transact([{
1046
- t: "update",
1047
- ns: "applications",
1048
- id: applicationId,
1049
- attrs: {
1050
- clerkPrivateMetadataSyncedAt: Date.now(),
1051
- ...clerkUserId ? { clerkUserId } : {}
1052
- }
1053
- }]);
1249
+ // src/pipeline.ts
1250
+ function canTransition(from, to, p) {
1251
+ const fi = p.stages.indexOf(from);
1252
+ const ti = p.stages.indexOf(to);
1253
+ return fi >= 0 && ti >= 0 && ti >= fi;
1054
1254
  }
1055
- async function syncApplicantPrivateMetadata(db, chapter, applicationId, fields, clerkUserId) {
1056
- if (chapter.account === "none") return false;
1057
- try {
1058
- const secret = await getVaultSecret(db, "clerk_secret_key");
1059
- if (!secret) return false;
1060
- const ok = await updateClerkUserMetadata(secret, clerkUserId, {
1061
- publicMetadata: publicProfileRemoval,
1062
- privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
1063
- });
1064
- if (!ok) return false;
1065
- await markPrivateMetadataSynced(db, applicationId, clerkUserId);
1066
- return true;
1067
- } catch {
1068
- return false;
1069
- }
1255
+ function canBook(status, p) {
1256
+ return p.bookableFrom.includes(status);
1070
1257
  }
1071
- async function provisionApplicant(db, chapter, applicationId, fields) {
1072
- const email = typeof fields.email === "string" ? fields.email : "";
1073
- if (!email) return;
1074
- const stringValue = (value) => typeof value === "string" ? value : void 0;
1075
- const extra = {};
1076
- for (const field of chapter.application.crmFields) {
1077
- if (fields[field] !== void 0) extra[field] = fields[field];
1078
- }
1079
- try {
1080
- await projectApplicant(
1081
- { crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
1082
- {
1083
- applicationId,
1084
- email,
1085
- firstName: stringValue(fields.firstName),
1086
- lastName: stringValue(fields.lastName),
1087
- phone: stringValue(fields.phone),
1088
- linkedin: stringValue(fields.linkedin),
1089
- extra
1090
- }
1091
- );
1092
- } catch {
1258
+ function canApprove(status, p) {
1259
+ return p.approvableFrom.includes(status);
1260
+ }
1261
+
1262
+ // src/approve-application.ts
1263
+ async function approveApplication(input) {
1264
+ const { db, env, ctx, app } = input;
1265
+ const id = String(app.id ?? "");
1266
+ const status = String(app.status ?? "");
1267
+ if (!id) return { ok: false, status: 404, error: "not found" };
1268
+ if (input.reason === "manual" && status !== "approved" && !canApprove(status, ctx.chapter.pipeline)) {
1269
+ return { ok: false, status: 409, error: `cannot approve from status "${status}"` };
1270
+ }
1271
+ if (input.reason === "interview_waiver") {
1272
+ if (typeof app.interviewWaivedAt !== "number") return { ok: false, status: 409, error: "interview waiver required" };
1273
+ if (status !== "approved" && !canTransition(status, "approved", ctx.chapter.pipeline)) {
1274
+ return { ok: false, status: 409, error: `cannot approve from status "${status}"` };
1275
+ }
1093
1276
  }
1094
- if (chapter.account === "none") return;
1095
- try {
1096
- const secret = await getVaultSecret(db, "clerk_secret_key");
1097
- if (!secret) return;
1098
- const privateMetadata = applicantPrivateMetadata(chapter, applicationId, fields);
1099
- if (chapter.account === "create") {
1100
- const result = await createClerkUser(secret, {
1101
- email,
1102
- firstName: stringValue(fields.firstName),
1103
- lastName: stringValue(fields.lastName),
1104
- privateMetadata,
1105
- repairMetadata: {
1106
- publicMetadata: publicProfileRemoval,
1107
- privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
1108
- }
1109
- });
1110
- if (result.ok && (!result.existed || result.refreshed)) {
1111
- await markPrivateMetadataSynced(db, applicationId);
1112
- }
1113
- } else {
1114
- const result = await createClerkInvitation(secret, { email });
1115
- if (result.existed) {
1116
- const refreshed = await updateClerkUserMetadataByEmail(secret, email, {
1117
- publicMetadata: publicProfileRemoval,
1118
- privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
1119
- });
1120
- if (refreshed) await markPrivateMetadataSynced(db, applicationId);
1121
- }
1277
+ const write = await db.transact(
1278
+ [{ t: "update", ns: "applications", id, attrs: { status: "approved" } }],
1279
+ { mutationId: `approve:${id}` }
1280
+ );
1281
+ const approved = { ...app, status: "approved" };
1282
+ await syncApplicationToCrm(crmDeps(db, ctx), { app: approved, stage: "approved" }).catch(() => void 0);
1283
+ const { promoteTo, send } = ctx.chapter.operations.onApprove;
1284
+ let rolePromoted = false;
1285
+ const secret = await getVaultSecret(db, "clerk_secret_key");
1286
+ if (promoteTo !== false && secret) {
1287
+ let userId = typeof app.clerkUserId === "string" ? app.clerkUserId : null;
1288
+ if (!userId && typeof app.email === "string") {
1289
+ const found = await clerkGetUserByEmail(secret, app.email);
1290
+ userId = found?.id ?? null;
1291
+ if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
1122
1292
  }
1123
- } catch {
1293
+ if (userId) rolePromoted = await clerkSetRole(secret, userId, promoteTo);
1124
1294
  }
1125
- }
1126
- async function notifyAdminOfApplication(db, env, chapterId, applicationId, fields) {
1127
- try {
1128
- const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
1129
- const group = Array.isArray(groups) ? groups[0] : void 0;
1130
- if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
1131
- const stringValue = (value) => typeof value === "string" ? value : "";
1132
- await sendTemplated(
1133
- {
1134
- db,
1135
- envName: env.ODLA_ENV,
1136
- sender: env.SEND_EMAIL,
1137
- from: env.EMAIL_FROM,
1138
- now: () => Date.now(),
1139
- newId: () => crypto.randomUUID()
1140
- },
1295
+ let emailLogged = false;
1296
+ const groups = (await db.query({ groups: { $: { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 } } })).groups;
1297
+ const group = Array.isArray(groups) ? groups[0] : void 0;
1298
+ if (send !== false && group && typeof app.email === "string") {
1299
+ const result = await sendTemplated(
1300
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
1141
1301
  {
1142
1302
  group: emailGroupFrom(group),
1143
- template: "adminNotification",
1144
- to: group.notificationEmail,
1145
- vars: {
1146
- firstName: stringValue(fields.firstName),
1147
- lastName: stringValue(fields.lastName),
1148
- email: stringValue(fields.email),
1149
- phone: stringValue(fields.phone),
1150
- state: stringValue(fields.state)
1151
- },
1152
- dedupeKey: `apply:${applicationId}:admin`,
1153
- applicationId
1303
+ template: send,
1304
+ to: app.email,
1305
+ vars: { firstName: String(app.firstName ?? ""), membersUrl: `${input.origin}/members/` },
1306
+ applicationId: id,
1307
+ dedupeKey: `approve:${id}`
1154
1308
  }
1155
1309
  );
1156
- } catch {
1310
+ emailLogged = result.sent;
1157
1311
  }
1312
+ return { ok: true, status: 200, rolePromoted, emailLogged, duplicate: write.duplicate };
1158
1313
  }
1159
1314
 
1160
- // src/worker-routes.ts
1161
- async function memberSessionApplication(db, chapterId, email) {
1162
- const apps = (await db.query({ applications: { $: { where: { email }, order: { createdAt: "desc" }, limit: 1 } } })).applications;
1163
- const app = Array.isArray(apps) ? apps[0] : void 0;
1164
- if (!app) return null;
1165
- const meetings = (await db.query({ meetings: { $: { where: { applicationId: app.id, status: "scheduled" }, order: { createdAt: "desc" }, limit: 1 } } })).meetings;
1166
- const meeting = Array.isArray(meetings) ? meetings[0] : void 0;
1167
- const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
1168
- const group = Array.isArray(groups) ? groups[0] : void 0;
1169
- const timezone = resolveScheduling(group?.schedulingJson).timezone;
1170
- return {
1171
- application: memberApplication(app, meeting, timezone),
1172
- source: app
1315
+ // src/join-continuation.ts
1316
+ function resolveJoinContinuation(input) {
1317
+ if (input.status === "approved") return "accepted";
1318
+ if (input.interviewWaived) {
1319
+ if (!input.payable) return "approve";
1320
+ if (input.status === input.initialStatus) {
1321
+ return input.subscriptionStarted ? "paymentPending" : "payment";
1322
+ }
1323
+ return "approve";
1324
+ }
1325
+ if (!input.payable) return "booking";
1326
+ if (input.status === input.initialStatus) {
1327
+ return input.subscriptionStarted ? "paymentPending" : "payment";
1328
+ }
1329
+ if (canBook(input.status, input.pipeline)) return "booking";
1330
+ return null;
1331
+ }
1332
+
1333
+ // src/join-continuation-server.ts
1334
+ async function continueJoin(db, ctx, env, origin, app, group) {
1335
+ if (typeof app.giftOfferId === "string") {
1336
+ if (typeof app.giftClaimId === "string") return "gift";
1337
+ return typeof app.giftCheckoutStartedAt === "number" ? "paymentPending" : "payment";
1338
+ }
1339
+ const stripeKey = await getVaultSecret(db, "stripe_secret_key");
1340
+ const offered = await loadOfferableTiers(db, group, Boolean(stripeKey));
1341
+ const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
1342
+ const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
1343
+ const state = resolveJoinContinuation({
1344
+ status: String(app.status ?? ""),
1345
+ initialStatus: ctx.chapter.pipeline.initial,
1346
+ pipeline: ctx.chapter.pipeline,
1347
+ payable,
1348
+ subscriptionStarted: typeof app.stripeSubscriptionId === "string" && app.stripeSubscriptionId !== "",
1349
+ interviewWaived: typeof app.interviewWaivedAt === "number"
1350
+ });
1351
+ if (state !== "approve") return state;
1352
+ const approved = await approveApplication({ db, env, ctx, app, origin, reason: "interview_waiver" });
1353
+ return approved.ok ? "accepted" : null;
1354
+ }
1355
+
1356
+ // src/signup-control-store.ts
1357
+ function asStored(row) {
1358
+ if (!row || typeof row.document !== "object" || row.document === null) return null;
1359
+ return row;
1360
+ }
1361
+ async function hasSignupControl(db, targetId, environment, active) {
1362
+ const where = { targetId, environment, ...active ? { active: true } : {} };
1363
+ if (db.aggregate) {
1364
+ return Number((await db.aggregate("signupControls", { count: true }, { where })).count ?? 0) > 0;
1365
+ }
1366
+ const rows4 = (await db.query({ signupControls: {} })).signupControls;
1367
+ return Array.isArray(rows4) && rows4.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
1368
+ }
1369
+ async function loadLatestSignupControl(db, targetId, environment) {
1370
+ if (!await hasSignupControl(db, targetId, environment)) return null;
1371
+ const rows4 = (await db.query({ signupControls: { $: {
1372
+ where: { targetId, environment },
1373
+ order: { revision: "desc" },
1374
+ limit: 1
1375
+ } } })).signupControls;
1376
+ const latest = Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
1377
+ return asStored(latest);
1378
+ }
1379
+ async function loadActiveSignupControl(db, targetId, environment) {
1380
+ if (!await hasSignupControl(db, targetId, environment, true)) return null;
1381
+ const rows4 = (await db.query({ signupControls: { $: {
1382
+ where: { targetId, environment, active: true },
1383
+ order: { revision: "desc" },
1384
+ limit: 1
1385
+ } } })).signupControls;
1386
+ const active = Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
1387
+ return asStored(active);
1388
+ }
1389
+ function controlledApplication(base, document) {
1390
+ const delegated = document?.application;
1391
+ return delegated ? { ...base, required: delegated.required, optional: delegated.optional, conditions: { ...delegated.conditions ?? {} } } : base;
1392
+ }
1393
+ function validateControlledApplication(chapter, document) {
1394
+ if (!document.application) return [];
1395
+ const declared = /* @__PURE__ */ new Set([...chapter.application.required, ...chapter.application.optional]);
1396
+ 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`);
1397
+ }
1398
+ function groupAttrs(document) {
1399
+ const first4 = [...document.tiers].filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder)[0];
1400
+ const firstPaid = [...document.tiers].find((tier) => tier.available && tier.priceCents > 0);
1401
+ return {
1402
+ ...first4 ? { standardPriceCents: first4.priceCents } : {},
1403
+ foundingDiscountCents: 0,
1404
+ ...firstPaid?.stripePriceId ? { stripePriceId: firstPaid.stripePriceId } : {},
1405
+ ...document.policy ?? {},
1406
+ ...document.scheduling ? { schedulingJson: document.scheduling } : {}
1407
+ };
1408
+ }
1409
+ var byPublicId = (ns, value) => ({
1410
+ ns,
1411
+ attr: "id",
1412
+ value
1413
+ });
1414
+ async function materializeSignupControl(db, envelope, previous, receivedAt) {
1415
+ const { document, digest } = envelope;
1416
+ const currentRows = (await db.query({ tiers: { $: {
1417
+ where: { groupId: document.target.chapterId },
1418
+ limit: 100
1419
+ } } })).tiers ?? [];
1420
+ const incoming = new Set(document.tiers.map((tier) => tier.id));
1421
+ const ops = [];
1422
+ if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
1423
+ for (const row of currentRows) {
1424
+ const id2 = String(row.id ?? "");
1425
+ if (id2 && !incoming.has(id2)) {
1426
+ ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
1427
+ }
1428
+ }
1429
+ for (const tier of document.tiers) {
1430
+ const tierRef = byPublicId("tiers", tier.id);
1431
+ ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
1432
+ id: tier.id,
1433
+ groupId: document.target.chapterId,
1434
+ name: tier.name,
1435
+ priceCents: tier.priceCents,
1436
+ ...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
1437
+ ...tier.blurb ? { blurb: tier.blurb } : {},
1438
+ sortOrder: tier.sortOrder,
1439
+ active: tier.available,
1440
+ createdAt: receivedAt
1441
+ } });
1442
+ const retract = [
1443
+ ...!tier.stripePriceId ? ["stripePriceId"] : [],
1444
+ ...!tier.blurb ? ["blurb"] : []
1445
+ ];
1446
+ if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
1447
+ }
1448
+ const groupRef = byPublicId("groups", document.target.chapterId);
1449
+ const attrs = groupAttrs(document);
1450
+ ops.push({
1451
+ t: "update",
1452
+ ns: "groups",
1453
+ id: groupRef,
1454
+ attrs
1455
+ });
1456
+ if (!("stripePriceId" in attrs)) {
1457
+ ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
1458
+ }
1459
+ const id = `signup-control:${document.target.environment}:${document.revision}`;
1460
+ ops.push({ t: "update", ns: "signupControls", id, attrs: {
1461
+ id,
1462
+ sourceId: document.source.appId,
1463
+ targetId: document.target.chapterId,
1464
+ environment: document.target.environment,
1465
+ revision: document.revision,
1466
+ digest,
1467
+ document,
1468
+ active: true,
1469
+ receivedAt
1470
+ } });
1471
+ await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
1472
+ }
1473
+
1474
+ // src/session.ts
1475
+ function applicationSummary(app) {
1476
+ return {
1477
+ id: app.id,
1478
+ firstName: app.firstName ?? null,
1479
+ lastName: app.lastName ?? null,
1480
+ email: app.email ?? null,
1481
+ status: app.status,
1482
+ createdAt: app.createdAt ?? null,
1483
+ meetingLink: app.meetingLink ?? null,
1484
+ paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
1485
+ renewalAt: app.renewalAt ?? null,
1486
+ canceled: app.canceled === true,
1487
+ tierId: app.tierId ?? null
1488
+ };
1489
+ }
1490
+ function memberApplication(app, meeting, defaultTimezone) {
1491
+ const summary = applicationSummary(app);
1492
+ let meetingAt = app.meetingAt ?? null;
1493
+ let meetUrl = null;
1494
+ let timezone = defaultTimezone;
1495
+ if (meeting) {
1496
+ timezone = meeting.timezone ?? timezone;
1497
+ if (meeting.status === "scheduled") {
1498
+ meetingAt = meeting.startAt ?? null;
1499
+ meetUrl = meeting.meetUrl ?? null;
1500
+ } else {
1501
+ meetingAt = null;
1502
+ }
1503
+ }
1504
+ return { ...summary, meetingAt, meetUrl, timezone };
1505
+ }
1506
+
1507
+ // src/clerk.ts
1508
+ var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
1509
+ function clerkInviteRequest(input) {
1510
+ return {
1511
+ path: "/v1/invitations",
1512
+ body: {
1513
+ email_address: input.email,
1514
+ notify: true,
1515
+ ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
1516
+ ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
1517
+ }
1518
+ };
1519
+ }
1520
+ async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
1521
+ const { path, body } = clerkInviteRequest(input);
1522
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
1523
+ method: "POST",
1524
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1525
+ body: JSON.stringify(body)
1526
+ });
1527
+ return res.ok ? { ok: true, status: res.status } : heal(res.status);
1528
+ }
1529
+ function clerkUserRequest(input) {
1530
+ return {
1531
+ path: "/v1/users",
1532
+ body: {
1533
+ email_address: [input.email],
1534
+ skip_password_requirement: true,
1535
+ ...input.firstName ? { first_name: input.firstName } : {},
1536
+ ...input.lastName ? { last_name: input.lastName } : {},
1537
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
1538
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
1539
+ }
1540
+ };
1541
+ }
1542
+ async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
1543
+ const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
1544
+ method: "PATCH",
1545
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1546
+ body: JSON.stringify({
1547
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
1548
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
1549
+ })
1550
+ });
1551
+ return res.ok;
1552
+ }
1553
+ async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
1554
+ const auth = { authorization: `Bearer ${secretKey}` };
1555
+ const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
1556
+ if (!found.ok) return false;
1557
+ const users = await found.json().catch(() => null);
1558
+ const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
1559
+ if (!id) return false;
1560
+ return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
1561
+ }
1562
+ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
1563
+ const { path, body } = clerkUserRequest(input);
1564
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
1565
+ method: "POST",
1566
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1567
+ body: JSON.stringify(body)
1568
+ });
1569
+ if (res.ok) return { ok: true, status: res.status };
1570
+ const healed = heal(res.status);
1571
+ const repair = input.repairMetadata ?? {
1572
+ ...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
1573
+ ...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
1574
+ };
1575
+ if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
1576
+ const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
1577
+ ...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
1578
+ ...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
1579
+ }, fetchImpl).catch(() => false);
1580
+ return { ...healed, refreshed };
1581
+ }
1582
+
1583
+ // src/worker-provisioning.ts
1584
+ var applicantPrivateMetadata = (chapter, applicationId, fields, removeMissingProfile = false) => {
1585
+ const profile = applicantProfile(chapter, fields);
1586
+ return {
1587
+ applicationId,
1588
+ ...profile ? { profile } : removeMissingProfile ? { profile: null } : {}
1589
+ };
1590
+ };
1591
+ var publicProfileRemoval = { applicationId: null, profile: null };
1592
+ async function markPrivateMetadataSynced(db, applicationId, clerkUserId) {
1593
+ await db.transact([{
1594
+ t: "update",
1595
+ ns: "applications",
1596
+ id: applicationId,
1597
+ attrs: {
1598
+ clerkPrivateMetadataSyncedAt: Date.now(),
1599
+ ...clerkUserId ? { clerkUserId } : {}
1600
+ }
1601
+ }]);
1602
+ }
1603
+ async function syncApplicantPrivateMetadata(db, chapter, applicationId, fields, clerkUserId) {
1604
+ if (chapter.account === "none") return false;
1605
+ try {
1606
+ const secret = await getVaultSecret(db, "clerk_secret_key");
1607
+ if (!secret) return false;
1608
+ const ok = await updateClerkUserMetadata(secret, clerkUserId, {
1609
+ publicMetadata: publicProfileRemoval,
1610
+ privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
1611
+ });
1612
+ if (!ok) return false;
1613
+ await markPrivateMetadataSynced(db, applicationId, clerkUserId);
1614
+ return true;
1615
+ } catch {
1616
+ return false;
1617
+ }
1618
+ }
1619
+ async function provisionApplicant(db, chapter, applicationId, fields) {
1620
+ const email = typeof fields.email === "string" ? fields.email : "";
1621
+ if (!email) return;
1622
+ const stringValue = (value) => typeof value === "string" ? value : void 0;
1623
+ const extra = {};
1624
+ for (const field of chapter.application.crmFields) {
1625
+ if (fields[field] !== void 0) extra[field] = fields[field];
1626
+ }
1627
+ try {
1628
+ await projectApplicant(
1629
+ { crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
1630
+ {
1631
+ applicationId,
1632
+ email,
1633
+ firstName: stringValue(fields.firstName),
1634
+ lastName: stringValue(fields.lastName),
1635
+ phone: stringValue(fields.phone),
1636
+ linkedin: stringValue(fields.linkedin),
1637
+ extra
1638
+ }
1639
+ );
1640
+ } catch {
1641
+ }
1642
+ if (chapter.account === "none") return;
1643
+ try {
1644
+ const secret = await getVaultSecret(db, "clerk_secret_key");
1645
+ if (!secret) return;
1646
+ const privateMetadata = applicantPrivateMetadata(chapter, applicationId, fields);
1647
+ if (chapter.account === "create") {
1648
+ const result = await createClerkUser(secret, {
1649
+ email,
1650
+ firstName: stringValue(fields.firstName),
1651
+ lastName: stringValue(fields.lastName),
1652
+ privateMetadata,
1653
+ repairMetadata: {
1654
+ publicMetadata: publicProfileRemoval,
1655
+ privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
1656
+ }
1657
+ });
1658
+ if (result.ok && (!result.existed || result.refreshed)) {
1659
+ await markPrivateMetadataSynced(db, applicationId);
1660
+ }
1661
+ } else {
1662
+ const result = await createClerkInvitation(secret, { email });
1663
+ if (result.existed) {
1664
+ const refreshed = await updateClerkUserMetadataByEmail(secret, email, {
1665
+ publicMetadata: publicProfileRemoval,
1666
+ privateMetadata: applicantPrivateMetadata(chapter, applicationId, fields, true)
1667
+ });
1668
+ if (refreshed) await markPrivateMetadataSynced(db, applicationId);
1669
+ }
1670
+ }
1671
+ } catch {
1672
+ }
1673
+ }
1674
+ async function notifyAdminOfApplication(db, env, chapterId, applicationId, fields) {
1675
+ try {
1676
+ const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
1677
+ const group = Array.isArray(groups) ? groups[0] : void 0;
1678
+ if (!group || typeof group.notificationEmail !== "string" || !group.notificationEmail) return;
1679
+ const stringValue = (value) => typeof value === "string" ? value : "";
1680
+ await sendTemplated(
1681
+ {
1682
+ db,
1683
+ envName: env.ODLA_ENV,
1684
+ sender: env.SEND_EMAIL,
1685
+ from: env.EMAIL_FROM,
1686
+ now: () => Date.now(),
1687
+ newId: () => crypto.randomUUID()
1688
+ },
1689
+ {
1690
+ group: emailGroupFrom(group),
1691
+ template: "adminNotification",
1692
+ to: group.notificationEmail,
1693
+ vars: {
1694
+ firstName: stringValue(fields.firstName),
1695
+ lastName: stringValue(fields.lastName),
1696
+ email: stringValue(fields.email),
1697
+ phone: stringValue(fields.phone),
1698
+ state: stringValue(fields.state)
1699
+ },
1700
+ dedupeKey: `apply:${applicationId}:admin`,
1701
+ applicationId
1702
+ }
1703
+ );
1704
+ } catch {
1705
+ }
1706
+ }
1707
+
1708
+ // src/worker-routes.ts
1709
+ async function memberSessionApplication(db, chapterId, email) {
1710
+ let apps = (await db.query({ applications: { $: {
1711
+ where: { emailNormalized: normalizeAdmissionEmail(email) },
1712
+ limit: 20
1713
+ } } })).applications;
1714
+ if (!apps?.length) apps = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
1715
+ const app = Array.isArray(apps) ? [...apps].sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0] : void 0;
1716
+ if (!app) return null;
1717
+ const meetings = (await db.query({ meetings: { $: { where: { applicationId: app.id, status: "scheduled" }, order: { createdAt: "desc" }, limit: 1 } } })).meetings;
1718
+ const meeting = Array.isArray(meetings) ? meetings[0] : void 0;
1719
+ const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
1720
+ const group = Array.isArray(groups) ? groups[0] : void 0;
1721
+ const timezone = resolveScheduling(group?.schedulingJson).timezone;
1722
+ return {
1723
+ application: memberApplication(app, meeting, timezone),
1724
+ source: app
1173
1725
  };
1174
1726
  }
1175
1727
  var handleHealth = async (_req, url) => url.pathname === "/api/health" ? json({ ok: true }) : null;
@@ -1262,7 +1814,8 @@ var handleMember = async (req, url, env, ctx) => {
1262
1814
  membershipPage: activeControl.document.membershipPage,
1263
1815
  application: activeControl.document.application,
1264
1816
  theme: activeControl.document.theme
1265
- } } : {}
1817
+ } } : {},
1818
+ giftOffers: publicGiftOffers(activeControl?.document.giftOffers, Date.now())
1266
1819
  });
1267
1820
  }
1268
1821
  if (req.method === "GET" && url.pathname === "/api/join/resume") {
@@ -1301,18 +1854,9 @@ var handleMember = async (req, url, env, ctx) => {
1301
1854
  })).groups;
1302
1855
  const group = Array.isArray(groups) ? groups[0] : void 0;
1303
1856
  if (!group) return json({ error: "not found" }, 404);
1304
- const stripeKey = await getVaultSecret(db, "stripe_secret_key");
1305
- const status = String(app.status ?? "");
1306
- const offered = await loadOfferableTiers(db, group, Boolean(stripeKey));
1307
- const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
1308
- const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
1309
- if (!payable) return json({ state: "booking", applicationId });
1310
- if (status === chapter.pipeline.initial && app.stripeSubscriptionId) {
1311
- return json({ state: "paymentPending", applicationId });
1312
- }
1313
- if (status === chapter.pipeline.initial) return json({ state: "payment", applicationId });
1314
- if (canBook(status, chapter.pipeline)) return json({ state: "booking", applicationId });
1315
- return json({ error: `application cannot resume from status "${status}"` }, 409);
1857
+ const state = await continueJoin(db, ctx, env, url.origin, app, group);
1858
+ if (state) return json({ state, applicationId, ...state === "gift" ? { claimId: app.giftClaimId } : {} });
1859
+ return json({ error: `application cannot resume from status "${String(app.status ?? "")}"` }, 409);
1316
1860
  }
1317
1861
  if (req.method === "POST" && url.pathname === "/api/applications") {
1318
1862
  const raw = await req.text();
@@ -1328,12 +1872,19 @@ var handleMember = async (req, url, env, ctx) => {
1328
1872
  const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
1329
1873
  const effectiveChapter = activeControl ? { ...chapter, application: controlledApplication(chapter.application, activeControl.document) } : chapter;
1330
1874
  const offeredTierIds = typeof parsed.tierId === "string" && parsed.tierId ? (await loadTiers(db, { id: chapter.id })).map((tier) => tier.id) : void 0;
1875
+ const now = Date.now();
1876
+ const requestedGiftId = typeof parsed.giftOfferId === "string" ? parsed.giftOfferId : "";
1877
+ const giftOffer = requestedGiftId ? findGiftOffer(activeControl?.document.giftOffers, requestedGiftId, now) : null;
1878
+ if (requestedGiftId && !giftOffer) return json({ error: "gift offer is unavailable" }, 400);
1879
+ const effectiveGrant = !giftOffer && typeof parsed.email === "string" ? await findAdmissionGrant(db, chapter.id, parsed.email, now) : null;
1331
1880
  const result = await submitApplication(db, effectiveChapter, parsed, {
1332
1881
  submissionId,
1333
1882
  groupId: chapter.id,
1334
- now: Date.now(),
1883
+ now,
1335
1884
  newId: () => crypto.randomUUID(),
1336
- ...offeredTierIds ? { tierIds: offeredTierIds } : {}
1885
+ ...offeredTierIds ? { tierIds: offeredTierIds } : {},
1886
+ ...effectiveGrant?.decision === "waive_interview" ? { admissionGrant: effectiveGrant } : {},
1887
+ ...giftOffer ? { giftOfferId: giftOffer.id } : {}
1337
1888
  });
1338
1889
  if (!result.ok) return json({ error: result.error }, 400);
1339
1890
  if (!result.duplicate) {
@@ -1342,10 +1893,16 @@ var handleMember = async (req, url, env, ctx) => {
1342
1893
  }
1343
1894
  await provisionApplicant(db, effectiveChapter, result.id, parsed);
1344
1895
  }
1896
+ const apps = (await db.query({ applications: { $: { where: { id: result.id }, limit: 1 } } })).applications;
1897
+ const canonical2 = Array.isArray(apps) ? apps[0] : void 0;
1898
+ const groups = (await db.query({ groups: { $: { where: { id: chapter.id }, limit: 1 } } })).groups;
1899
+ const group = Array.isArray(groups) ? groups[0] : void 0;
1900
+ const state = canonical2 && group ? await continueJoin(db, ctx, env, url.origin, canonical2, group) : null;
1345
1901
  return json({
1346
1902
  id: result.id,
1347
1903
  duplicate: result.duplicate,
1348
1904
  status: result.status,
1905
+ ...state ? { state } : {},
1349
1906
  // Echoed so a site can see (and assert in an integration test) whether its
1350
1907
  // join page actually posted the ack. Absent consent is otherwise invisible.
1351
1908
  disclaimerAckAt: result.disclaimerAckAt
@@ -1401,8 +1958,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
1401
1958
  payload,
1402
1959
  sourceId ? { sourceId } : {}
1403
1960
  );
1404
- const record = normalizeSharedRecord(payload, sourceId);
1405
- return json({ recordId, type: record.type });
1961
+ const record3 = normalizeSharedRecord(payload, sourceId);
1962
+ return json({ recordId, type: record3.type });
1406
1963
  } catch (error) {
1407
1964
  return json({
1408
1965
  error: error instanceof Error ? error.message : "invalid shared record"
@@ -1410,31 +1967,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
1410
1967
  }
1411
1968
  };
1412
1969
 
1413
- // src/worker-routes-signup-control.ts
1414
- import { verifyFederatedRequest as verifyFederatedRequest2 } from "@odla-ai/db";
1415
-
1416
- // src/signup-control-canonical.ts
1417
- var encoder = new TextEncoder();
1418
- function canonicalValue(value) {
1419
- if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
1420
- return JSON.stringify(value);
1421
- }
1422
- if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
1423
- if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
1424
- const record = value;
1425
- const entries = Object.keys(record).sort().flatMap((key) => {
1426
- const item = record[key];
1427
- return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
1428
- });
1429
- return `{${entries.join(",")}}`;
1430
- }
1431
- function canonicalSignupControl(value) {
1432
- return canonicalValue(value);
1433
- }
1434
- async function signupControlDigest(document) {
1435
- const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
1436
- return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
1437
- }
1970
+ // src/worker-routes-signup-control.ts
1971
+ import { verifyFederatedRequest as verifyFederatedRequest2 } from "@odla-ai/db";
1438
1972
 
1439
1973
  // src/signup-control-contract.ts
1440
1974
  var SIGNUP_CONTROL_PATH = "/api/signup-control";
@@ -1465,9 +1999,9 @@ var MAX_BENEFITS = 24;
1465
1999
  var MAX_MARKDOWN = 65536;
1466
2000
  var MAX_LABEL = 160;
1467
2001
  var objectRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1468
- function rejectUnknownKeys(value, allowed, path, errors) {
2002
+ function rejectUnknownKeys(value, allowed2, path, errors) {
1469
2003
  for (const key of Object.keys(value)) {
1470
- if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
2004
+ if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
1471
2005
  }
1472
2006
  }
1473
2007
  function boundedString(value, path, errors, max, required = false) {
@@ -1479,10 +2013,10 @@ function boundedString(value, path, errors, max, required = false) {
1479
2013
  errors.push(`${path} must be a string`);
1480
2014
  return void 0;
1481
2015
  }
1482
- const text2 = value.trim();
1483
- if (required && !text2) errors.push(`${path} is required`);
1484
- if (text2.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
1485
- return text2;
2016
+ const text3 = value.trim();
2017
+ if (required && !text3) errors.push(`${path} is required`);
2018
+ if (text3.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
2019
+ return text3;
1486
2020
  }
1487
2021
  function validateMembershipPageDocument(input, options = {}) {
1488
2022
  const errors = [];
@@ -1531,8 +2065,8 @@ function validateMembershipPageDocument(input, options = {}) {
1531
2065
  else {
1532
2066
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
1533
2067
  benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
1534
- const text2 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
1535
- return text2 ? [text2] : [];
2068
+ const text3 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
2069
+ return text3 ? [text3] : [];
1536
2070
  });
1537
2071
  }
1538
2072
  }
@@ -1606,7 +2140,7 @@ function validateMembershipPageDocument(input, options = {}) {
1606
2140
  var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
1607
2141
  var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
1608
2142
  var TIER = /^[a-z][a-z0-9-]{0,79}$/;
1609
- var PRICE = /^price_[A-Za-z0-9]{12,}$/;
2143
+ var PRICE2 = /^price_[A-Za-z0-9]{12,}$/;
1610
2144
  var ROOT_KEYS2 = /* @__PURE__ */ new Set([
1611
2145
  "schemaVersion",
1612
2146
  "revision",
@@ -1618,14 +2152,15 @@ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
1618
2152
  "application",
1619
2153
  "policy",
1620
2154
  "scheduling",
1621
- "theme"
2155
+ "theme",
2156
+ "giftOffers"
1622
2157
  ]);
1623
2158
  var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
1624
2159
  var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1625
- function unknownKeys(value, allowed, path, errors) {
1626
- for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
2160
+ function unknownKeys(value, allowed2, path, errors) {
2161
+ for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
1627
2162
  }
1628
- function text(value, path, errors, max = 65536) {
2163
+ function text2(value, path, errors, max = 65536) {
1629
2164
  if (typeof value !== "string" || !value.trim()) {
1630
2165
  errors.push(`${path} is required`);
1631
2166
  return "";
@@ -1648,23 +2183,23 @@ function tiersOf(value, errors) {
1648
2183
  return [];
1649
2184
  }
1650
2185
  unknownKeys(item, TIER_KEYS2, path, errors);
1651
- const id = text(item.id, `${path}.id`, errors, 80);
2186
+ const id = text2(item.id, `${path}.id`, errors, 80);
1652
2187
  if (id && !TIER.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
1653
2188
  if (seen.has(id)) errors.push(`${path}.id duplicates "${id}"`);
1654
2189
  seen.add(id);
1655
- const name = text(item.name, `${path}.name`, errors, 160);
2190
+ const name = text2(item.name, `${path}.name`, errors, 160);
1656
2191
  if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
1657
2192
  if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
1658
2193
  if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
1659
2194
  errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
1660
2195
  }
1661
- if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE.test(item.stripePriceId))) {
2196
+ if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE2.test(item.stripePriceId))) {
1662
2197
  errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
1663
2198
  }
1664
2199
  if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
1665
2200
  errors.push(`${path}.stripePriceId is required for an available paid tier`);
1666
2201
  }
1667
- const blurb = item.blurb === void 0 ? void 0 : text(item.blurb, `${path}.blurb`, errors, 4e3);
2202
+ const blurb = item.blurb === void 0 ? void 0 : text2(item.blurb, `${path}.blurb`, errors, 4e3);
1668
2203
  return [{
1669
2204
  id,
1670
2205
  name,
@@ -1685,143 +2220,781 @@ function applicationOf(value, errors) {
1685
2220
  }
1686
2221
  unknownKeys(input, /* @__PURE__ */ new Set(["required", "optional", "conditions", "copy"]), "document.application", errors);
1687
2222
  try {
1688
- const resolved = resolveApplication(input);
1689
- const copy = recordOf(input.copy);
1690
- if (input.copy !== void 0 && (!copy || Object.values(copy).some((item) => typeof item !== "string"))) {
1691
- errors.push("document.application.copy must contain only string values");
2223
+ const resolved = resolveApplication(input);
2224
+ const copy = recordOf(input.copy);
2225
+ if (input.copy !== void 0 && (!copy || Object.values(copy).some((item) => typeof item !== "string"))) {
2226
+ errors.push("document.application.copy must contain only string values");
2227
+ }
2228
+ return {
2229
+ required: resolved.required,
2230
+ optional: resolved.optional,
2231
+ ...Object.keys(resolved.conditions).length ? { conditions: resolved.conditions } : {},
2232
+ ...copy ? { copy } : {}
2233
+ };
2234
+ } catch (error) {
2235
+ errors.push(error instanceof Error ? error.message : "document.application is invalid");
2236
+ return void 0;
2237
+ }
2238
+ }
2239
+ function policyOf(value, errors) {
2240
+ if (value === void 0) return void 0;
2241
+ const input = recordOf(value);
2242
+ if (!input) {
2243
+ errors.push("document.policy must be an object");
2244
+ return void 0;
2245
+ }
2246
+ const allowed2 = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
2247
+ unknownKeys(input, allowed2, "document.policy", errors);
2248
+ const result = {};
2249
+ for (const key of allowed2) {
2250
+ if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
2251
+ result[key] = text2(input[key], `document.policy.${key}`, errors);
2252
+ }
2253
+ return result;
2254
+ }
2255
+ function themeOf(value, errors) {
2256
+ if (value === void 0) return void 0;
2257
+ const input = recordOf(value);
2258
+ const tokens = recordOf(input?.tokens);
2259
+ if (!input || !tokens) {
2260
+ errors.push("document.theme.tokens must be an object");
2261
+ return void 0;
2262
+ }
2263
+ unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
2264
+ if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
2265
+ let serializedLength = 0;
2266
+ for (const [key, token] of Object.entries(tokens)) {
2267
+ if (!/^--ui-[a-z0-9][a-z0-9-]{0,94}$/.test(key)) {
2268
+ errors.push(`document.theme.tokens.${key} must be an allowlisted --ui-* custom property`);
2269
+ }
2270
+ const rendered = String(token).trim();
2271
+ serializedLength += key.length + rendered.length + 4;
2272
+ 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)) {
2273
+ errors.push(`document.theme.tokens.${key} must be a bounded breakout-safe CSS value`);
2274
+ }
2275
+ }
2276
+ if (serializedLength > 65536) errors.push("document.theme.tokens exceeds the serialized size bound");
2277
+ return { tokens };
2278
+ }
2279
+ function validateSignupControlDocument(input) {
2280
+ const errors = [];
2281
+ const root = recordOf(input);
2282
+ if (!root) return { ok: false, errors: ["document must be an object"] };
2283
+ unknownKeys(root, ROOT_KEYS2, "document", errors);
2284
+ if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
2285
+ if (!Number.isSafeInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
2286
+ if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
2287
+ const source = recordOf(root.source);
2288
+ const target = recordOf(root.target);
2289
+ const appId = text2(source?.appId, "document.source.appId", errors, 63);
2290
+ const chapterId = text2(target?.chapterId, "document.target.chapterId", errors, 63);
2291
+ const environment = text2(target?.environment, "document.target.environment", errors, 63);
2292
+ if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
2293
+ if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
2294
+ if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
2295
+ if (target?.stripeMode !== "test" && target?.stripeMode !== "live") errors.push("document.target.stripeMode must be test or live");
2296
+ const tiers = tiersOf(root.tiers, errors);
2297
+ const available = tiers.filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder);
2298
+ const page = validateMembershipPageDocument(root.membershipPage, { tiers: available });
2299
+ if (!page.ok) errors.push(...page.errors);
2300
+ else if (page.value.tiers.some((tier, index) => tier.tierId !== available[index]?.id)) {
2301
+ errors.push("document.membershipPage.tiers must follow the available tier display order");
2302
+ }
2303
+ const application = applicationOf(root.application, errors);
2304
+ const policy = policyOf(root.policy, errors);
2305
+ let scheduling;
2306
+ if (root.scheduling !== void 0) {
2307
+ const checked = validateScheduling(root.scheduling);
2308
+ if (!checked.ok) errors.push(...Object.entries(checked.errors).map(([key, value]) => `document.scheduling.${key}: ${value}`));
2309
+ else scheduling = root.scheduling;
2310
+ }
2311
+ const theme = themeOf(root.theme, errors);
2312
+ const giftOffers = root.giftOffers === void 0 ? void 0 : validateGiftOfferCatalog(root.giftOffers, new Set(tiers.map((tier) => tier.id)));
2313
+ if (giftOffers && !giftOffers.ok) errors.push(...giftOffers.errors);
2314
+ if (errors.length || !page.ok) return { ok: false, errors };
2315
+ return { ok: true, value: {
2316
+ schemaVersion: 1,
2317
+ revision: Number(root.revision),
2318
+ generatedAt: Number(root.generatedAt),
2319
+ source: { appId },
2320
+ target: { chapterId, environment, stripeMode: target?.stripeMode },
2321
+ tiers,
2322
+ membershipPage: page.value,
2323
+ ...application ? { application } : {},
2324
+ ...policy ? { policy } : {},
2325
+ ...scheduling ? { scheduling } : {},
2326
+ ...theme ? { theme } : {},
2327
+ ...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
2328
+ } };
2329
+ }
2330
+
2331
+ // src/worker-routes-signup-control.ts
2332
+ var reject = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
2333
+ var handleSignupControl = async (req, url, env, ctx) => {
2334
+ if (req.method !== "POST" || url.pathname !== SIGNUP_CONTROL_PATH) return null;
2335
+ const authority = ctx.chapter.signupControl;
2336
+ if (!authority) return json({ error: "not found" }, 404);
2337
+ const db = ctx.makeDb(env);
2338
+ const secret = await getVaultSecret(db, authority.secretName);
2339
+ if (!secret) return json({ error: "unauthorized" }, 401);
2340
+ const verified = await verifyFederatedRequest2(req, { secret, senders: [authority.sourceId] });
2341
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
2342
+ let input;
2343
+ try {
2344
+ input = JSON.parse(verified.body);
2345
+ } catch {
2346
+ return reject("invalid_document", 400, ["request body must be JSON"]);
2347
+ }
2348
+ const digest = typeof input.digest === "string" ? input.digest : "";
2349
+ const checked = validateSignupControlDocument(input.document);
2350
+ if (!checked.ok) return reject("invalid_document", 400, checked.errors);
2351
+ const document = checked.value;
2352
+ if (document.source.appId !== authority.sourceId) return reject("wrong_source", 403);
2353
+ if (document.target.chapterId !== ctx.chapter.id) return reject("wrong_target", 409);
2354
+ if (document.target.environment !== env.ODLA_ENV) return reject("wrong_environment", 409);
2355
+ if (document.target.stripeMode !== authority.stripeMode) return reject("wrong_stripe_mode", 409);
2356
+ const applicationErrors = validateControlledApplication(ctx.chapter, document);
2357
+ if (applicationErrors.length) return reject("invalid_document", 400, applicationErrors);
2358
+ if (!digest || await signupControlDigest(document) !== digest) return reject("invalid_digest", 400);
2359
+ const latest = await loadLatestSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
2360
+ if (latest && document.revision < latest.revision) return reject("stale_revision", 409);
2361
+ if (latest && document.revision === latest.revision) {
2362
+ if (latest.digest !== digest) return reject("conflicting_revision", 409);
2363
+ return json({
2364
+ ok: true,
2365
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
2366
+ revision: latest.revision,
2367
+ digest: latest.digest,
2368
+ status: "replayed",
2369
+ acknowledgedAt: latest.receivedAt
2370
+ });
2371
+ }
2372
+ const receivedAt = Date.now();
2373
+ try {
2374
+ await materializeSignupControl(db, { digest, document }, latest, receivedAt);
2375
+ } catch {
2376
+ return reject("materialization_failed", 503);
2377
+ }
2378
+ return json({
2379
+ ok: true,
2380
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
2381
+ revision: document.revision,
2382
+ digest,
2383
+ status: "accepted",
2384
+ acknowledgedAt: receivedAt
2385
+ });
2386
+ };
2387
+
2388
+ // src/worker-routes-member-content.ts
2389
+ import { verifyFederatedRequest as verifyFederatedRequest3 } from "@odla-ai/db";
2390
+
2391
+ // src/member-content-canonical.ts
2392
+ var encoder2 = new TextEncoder();
2393
+ function canonicalValue2(value) {
2394
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
2395
+ return JSON.stringify(value);
2396
+ }
2397
+ if (Array.isArray(value)) return `[${value.map(canonicalValue2).join(",")}]`;
2398
+ if (typeof value !== "object") throw new TypeError("member content contains an unsupported value");
2399
+ const record3 = value;
2400
+ const entries = Object.keys(record3).sort().flatMap((key) => {
2401
+ const item = record3[key];
2402
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue2(item)}`];
2403
+ });
2404
+ return `{${entries.join(",")}}`;
2405
+ }
2406
+ function canonicalMemberContent(value) {
2407
+ return canonicalValue2(value);
2408
+ }
2409
+ async function memberContentDigest(document) {
2410
+ const digest = await crypto.subtle.digest("SHA-256", encoder2.encode(canonicalMemberContent(document)));
2411
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
2412
+ }
2413
+
2414
+ // src/member-content-validation-helpers.ts
2415
+ var ID3 = /^[a-z][a-z0-9-]{0,79}$/;
2416
+ var SLUG2 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2417
+ var ROOT_KEYS3 = /* @__PURE__ */ new Set(["schemaVersion", "revision", "generatedAt", "source", "target", "pages", "upsellOverrides"]);
2418
+ var SOURCE_KEYS = /* @__PURE__ */ new Set(["type", "appId"]);
2419
+ var TARGET_KEYS = /* @__PURE__ */ new Set(["chapterId", "environment"]);
2420
+ var PAGE_KEYS = /* @__PURE__ */ new Set(["id", "slug", "title", "summary", "navigation", "audience", "teaserMarkdown", "bodyMarkdown", "upsell"]);
2421
+ var NAV_KEYS = /* @__PURE__ */ new Set(["section", "order", "parentId"]);
2422
+ var AUDIENCE_KEYS = /* @__PURE__ */ new Set(["kind", "tierIds"]);
2423
+ var UPSELL_KEYS = /* @__PURE__ */ new Set(["owner", "offer"]);
2424
+ var OFFER_KEYS = /* @__PURE__ */ new Set(["tierId", "label", "bodyMarkdown", "ctaLabel"]);
2425
+ var OVERRIDE_KEYS = /* @__PURE__ */ new Set(["pageId", "offer"]);
2426
+ var MAX_PAGES = 100;
2427
+ var MAX_MARKDOWN2 = 65536;
2428
+ var MAX_TEXT = 240;
2429
+ var record2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
2430
+ function unknownKeys2(value, allowed2, path, errors) {
2431
+ for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
2432
+ }
2433
+ function textValue(value, path, errors, max = MAX_TEXT, required = false) {
2434
+ if (value === void 0) {
2435
+ if (required) errors.push(`${path} is required`);
2436
+ return void 0;
2437
+ }
2438
+ if (typeof value !== "string") {
2439
+ errors.push(`${path} must be a string`);
2440
+ return void 0;
2441
+ }
2442
+ const valueText = value.trim();
2443
+ if (required && !valueText) errors.push(`${path} is required`);
2444
+ if (valueText.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
2445
+ return valueText;
2446
+ }
2447
+ function offerValue(value, path, errors, tiers, audience) {
2448
+ const raw = record2(value);
2449
+ if (!raw) {
2450
+ errors.push(`${path} must be an object`);
2451
+ return void 0;
2452
+ }
2453
+ unknownKeys2(raw, OFFER_KEYS, path, errors);
2454
+ const tierId = textValue(raw.tierId, `${path}.tierId`, errors, 80, true) ?? "";
2455
+ if (tierId && !ID3.test(tierId)) errors.push(`${path}.tierId must be a lowercase kebab-case id`);
2456
+ if (tiers.size && !tiers.has(tierId)) errors.push(`${path}.tierId must reference a declared tier`);
2457
+ if (audience?.kind === "tiers" && tierId && !audience.tierIds.includes(tierId)) {
2458
+ errors.push(`${path}.tierId must grant access to this page`);
2459
+ }
2460
+ const label = textValue(raw.label, `${path}.label`, errors);
2461
+ const bodyMarkdown = textValue(raw.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2);
2462
+ const ctaLabel = textValue(raw.ctaLabel, `${path}.ctaLabel`, errors);
2463
+ return {
2464
+ tierId,
2465
+ ...label ? { label } : {},
2466
+ ...bodyMarkdown ? { bodyMarkdown } : {},
2467
+ ...ctaLabel ? { ctaLabel } : {}
2468
+ };
2469
+ }
2470
+
2471
+ // src/member-content-validation.ts
2472
+ function validateMemberContentDocument(input, options = {}) {
2473
+ const errors = [];
2474
+ const root = record2(input);
2475
+ if (!root) return { ok: false, errors: ["document must be an object"] };
2476
+ unknownKeys2(root, ROOT_KEYS3, "document", errors);
2477
+ if (root.schemaVersion !== MEMBER_CONTENT_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
2478
+ if (!Number.isInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
2479
+ if (!Number.isInteger(root.generatedAt) || Number(root.generatedAt) < 0) errors.push("document.generatedAt must be a non-negative integer timestamp");
2480
+ const sourceRaw = record2(root.source);
2481
+ let source = { type: "chapter", appId: "invalid" };
2482
+ if (!sourceRaw) errors.push("document.source must be an object");
2483
+ else {
2484
+ unknownKeys2(sourceRaw, SOURCE_KEYS, "document.source", errors);
2485
+ const type = sourceRaw.type;
2486
+ const appId = textValue(sourceRaw.appId, "document.source.appId", errors, 63, true) ?? "";
2487
+ if (type !== "parent" && type !== "chapter") errors.push("document.source.type must be parent or chapter");
2488
+ if (appId && !ID3.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
2489
+ if (options.sourceType && type !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
2490
+ if (options.sourceAppId && appId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
2491
+ if (type === "parent" || type === "chapter") source = { type, appId };
2492
+ }
2493
+ let target;
2494
+ if (root.target !== void 0) {
2495
+ const targetRaw = record2(root.target);
2496
+ if (!targetRaw) errors.push("document.target must be an object");
2497
+ else {
2498
+ unknownKeys2(targetRaw, TARGET_KEYS, "document.target", errors);
2499
+ const chapterId = textValue(targetRaw.chapterId, "document.target.chapterId", errors, 63, true) ?? "";
2500
+ const environment = textValue(targetRaw.environment, "document.target.environment", errors, 80, true) ?? "";
2501
+ target = { chapterId, environment };
2502
+ }
2503
+ }
2504
+ if (source.type === "parent" && !target) errors.push("document.target is required for parent content");
2505
+ if (source.type === "chapter" && target) errors.push("document.target is not supported for chapter content");
2506
+ if (!Array.isArray(root.pages)) errors.push("document.pages must be an array");
2507
+ const rawPages = Array.isArray(root.pages) ? root.pages : [];
2508
+ if (rawPages.length > MAX_PAGES) errors.push(`document.pages supports at most ${MAX_PAGES} entries`);
2509
+ const tierIds = new Set((options.tiers ?? []).map((tier) => tier.id));
2510
+ const knownParentIds = new Set((options.parentPages ?? []).map((page) => page.id));
2511
+ const ids = /* @__PURE__ */ new Set();
2512
+ const slugs = /* @__PURE__ */ new Set();
2513
+ const pages = [];
2514
+ for (const [index, value] of rawPages.slice(0, MAX_PAGES).entries()) {
2515
+ const path = `document.pages[${index}]`;
2516
+ const raw = record2(value);
2517
+ if (!raw) {
2518
+ errors.push(`${path} must be an object`);
2519
+ continue;
2520
+ }
2521
+ unknownKeys2(raw, PAGE_KEYS, path, errors);
2522
+ const id = textValue(raw.id, `${path}.id`, errors, 80, true) ?? "";
2523
+ const slug = textValue(raw.slug, `${path}.slug`, errors, 120, true) ?? "";
2524
+ const title = textValue(raw.title, `${path}.title`, errors, MAX_TEXT, true) ?? "";
2525
+ if (id && !ID3.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
2526
+ if (slug && !SLUG2.test(slug)) errors.push(`${path}.slug must be a lowercase URL slug`);
2527
+ if (ids.has(id) || knownParentIds.has(id)) errors.push(`${path}.id duplicates page "${id}"`);
2528
+ if (slugs.has(slug) || (options.parentPages ?? []).some((page) => page.slug === slug)) errors.push(`${path}.slug duplicates "${slug}"`);
2529
+ ids.add(id);
2530
+ slugs.add(slug);
2531
+ const navRaw = record2(raw.navigation);
2532
+ let navigation = { section: "General", order: index };
2533
+ if (!navRaw) errors.push(`${path}.navigation must be an object`);
2534
+ else {
2535
+ unknownKeys2(navRaw, NAV_KEYS, `${path}.navigation`, errors);
2536
+ const section = textValue(navRaw.section, `${path}.navigation.section`, errors, 120, true) ?? "";
2537
+ const order = navRaw.order;
2538
+ if (!Number.isInteger(order) || Number(order) < 0 || Number(order) > 1e6) {
2539
+ errors.push(`${path}.navigation.order must be an integer from 0 to 1,000,000`);
2540
+ }
2541
+ const parentId = textValue(navRaw.parentId, `${path}.navigation.parentId`, errors, 80);
2542
+ navigation = { section, order: Number.isInteger(order) ? Number(order) : index, ...parentId ? { parentId } : {} };
2543
+ }
2544
+ const audienceRaw = record2(raw.audience);
2545
+ let audience = { kind: "all-members" };
2546
+ if (!audienceRaw) errors.push(`${path}.audience must be an object`);
2547
+ else {
2548
+ unknownKeys2(audienceRaw, AUDIENCE_KEYS, `${path}.audience`, errors);
2549
+ if (audienceRaw.kind === "all-members") {
2550
+ if (audienceRaw.tierIds !== void 0) errors.push(`${path}.audience.tierIds is not supported for all-members`);
2551
+ } else if (audienceRaw.kind === "tiers") {
2552
+ if (!Array.isArray(audienceRaw.tierIds) || audienceRaw.tierIds.length === 0) {
2553
+ errors.push(`${path}.audience.tierIds must be a non-empty array`);
2554
+ }
2555
+ const values = Array.isArray(audienceRaw.tierIds) ? audienceRaw.tierIds : [];
2556
+ const normalized = [];
2557
+ for (const [tierIndex, value2] of values.entries()) {
2558
+ const tier = textValue(value2, `${path}.audience.tierIds[${tierIndex}]`, errors, 80, true) ?? "";
2559
+ if (normalized.includes(tier)) errors.push(`${path}.audience.tierIds duplicates "${tier}"`);
2560
+ if (tierIds.size && !tierIds.has(tier)) errors.push(`${path}.audience.tierIds references undeclared tier "${tier}"`);
2561
+ normalized.push(tier);
2562
+ }
2563
+ audience = { kind: "tiers", tierIds: normalized };
2564
+ } else errors.push(`${path}.audience.kind must be all-members or tiers`);
2565
+ }
2566
+ const summary = textValue(raw.summary, `${path}.summary`, errors, 2e3);
2567
+ const teaserMarkdown = textValue(raw.teaserMarkdown, `${path}.teaserMarkdown`, errors, MAX_MARKDOWN2);
2568
+ const bodyMarkdown = textValue(raw.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2, true) ?? "";
2569
+ let upsell;
2570
+ if (raw.upsell !== void 0) {
2571
+ const upsellRaw = record2(raw.upsell);
2572
+ if (!upsellRaw) errors.push(`${path}.upsell must be an object`);
2573
+ else {
2574
+ unknownKeys2(upsellRaw, UPSELL_KEYS, `${path}.upsell`, errors);
2575
+ const owner = upsellRaw.owner;
2576
+ if (owner !== "parent" && owner !== "chapter") errors.push(`${path}.upsell.owner must be parent or chapter`);
2577
+ if (source.type === "chapter" && owner === "parent") errors.push(`${path}.upsell.owner cannot be parent on a chapter page`);
2578
+ if (source.type === "parent" && owner === "parent" && upsellRaw.offer === void 0) errors.push(`${path}.upsell.offer is required when the parent owns the offer`);
2579
+ if (source.type === "parent" && owner === "chapter" && upsellRaw.offer !== void 0) errors.push(`${path}.upsell.offer must be set by the chapter after delegation`);
2580
+ const offer = upsellRaw.offer === void 0 ? void 0 : offerValue(upsellRaw.offer, `${path}.upsell.offer`, errors, tierIds, audience);
2581
+ if (owner === "parent") upsell = { owner, offer: offer ?? { tierId: "" } };
2582
+ else if (owner === "chapter") upsell = { owner, ...offer ? { offer } : {} };
2583
+ }
2584
+ }
2585
+ pages.push({ id, slug, title, ...summary ? { summary } : {}, navigation, audience, ...teaserMarkdown ? { teaserMarkdown } : {}, bodyMarkdown, ...upsell ? { upsell } : {} });
2586
+ }
2587
+ const allIds = /* @__PURE__ */ new Set([...knownParentIds, ...ids]);
2588
+ for (const [index, page] of pages.entries()) {
2589
+ const parentId = page.navigation.parentId;
2590
+ if (parentId && !allIds.has(parentId)) errors.push(`document.pages[${index}].navigation.parentId references unknown page "${parentId}"`);
2591
+ if (parentId === page.id) errors.push(`document.pages[${index}].navigation.parentId cannot reference itself`);
2592
+ }
2593
+ for (const page of pages) {
2594
+ const seen = /* @__PURE__ */ new Set([page.id]);
2595
+ let parentId = page.navigation.parentId;
2596
+ while (parentId) {
2597
+ if (seen.has(parentId)) {
2598
+ errors.push(`document.pages contains a navigation cycle at "${page.id}"`);
2599
+ break;
2600
+ }
2601
+ seen.add(parentId);
2602
+ parentId = pages.find((candidate) => candidate.id === parentId)?.navigation.parentId;
2603
+ }
2604
+ }
2605
+ let upsellOverrides;
2606
+ if (root.upsellOverrides !== void 0) {
2607
+ if (source.type !== "chapter") errors.push("document.upsellOverrides is supported only for chapter content");
2608
+ if (!Array.isArray(root.upsellOverrides)) errors.push("document.upsellOverrides must be an array");
2609
+ const values = Array.isArray(root.upsellOverrides) ? root.upsellOverrides : [];
2610
+ const seen = /* @__PURE__ */ new Set();
2611
+ upsellOverrides = [];
2612
+ for (const [index, value] of values.entries()) {
2613
+ const path = `document.upsellOverrides[${index}]`;
2614
+ const raw = record2(value);
2615
+ if (!raw) {
2616
+ errors.push(`${path} must be an object`);
2617
+ continue;
2618
+ }
2619
+ unknownKeys2(raw, OVERRIDE_KEYS, path, errors);
2620
+ const pageId = textValue(raw.pageId, `${path}.pageId`, errors, 80, true) ?? "";
2621
+ if (seen.has(pageId)) errors.push(`${path}.pageId duplicates "${pageId}"`);
2622
+ seen.add(pageId);
2623
+ const parentPage = (options.parentPages ?? []).find((page) => page.id === pageId);
2624
+ if (!parentPage) errors.push(`${path}.pageId must reference a parent page`);
2625
+ else if (parentPage.upsell?.owner !== "chapter") errors.push(`${path}.pageId does not delegate upsell authority to the chapter`);
2626
+ const offer = offerValue(raw.offer, `${path}.offer`, errors, tierIds, parentPage?.audience);
2627
+ if (offer) upsellOverrides.push({ pageId, offer });
2628
+ }
2629
+ }
2630
+ if (errors.length) return { ok: false, errors };
2631
+ return { ok: true, value: {
2632
+ schemaVersion: MEMBER_CONTENT_SCHEMA_VERSION,
2633
+ revision: Number(root.revision),
2634
+ generatedAt: Number(root.generatedAt),
2635
+ source,
2636
+ ...target ? { target } : {},
2637
+ pages,
2638
+ ...upsellOverrides ? { upsellOverrides } : {}
2639
+ } };
2640
+ }
2641
+
2642
+ // src/member-content.ts
2643
+ var MEMBER_CONTENT_SCHEMA_VERSION = 1;
2644
+ var MEMBER_CONTENT_PATH = "/api/member-content";
2645
+ var MEMBER_CONTENT_CONTROL_PATH = "/api/member-content-control";
2646
+ 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);
2647
+ function composeMemberContent(parent, chapter) {
2648
+ const pages = [];
2649
+ const parentPages = /* @__PURE__ */ new Map();
2650
+ for (const page of parent?.pages ?? []) {
2651
+ const effective = { ...page, source: parent.source };
2652
+ parentPages.set(page.id, effective);
2653
+ pages.push(effective);
2654
+ }
2655
+ for (const override of chapter?.upsellOverrides ?? []) {
2656
+ const page = parentPages.get(override.pageId);
2657
+ if (page?.upsell?.owner === "chapter") page.upsell = { owner: "chapter", offer: override.offer };
2658
+ }
2659
+ for (const page of chapter?.pages ?? []) {
2660
+ if (parentPages.has(page.id) || pages.some((candidate) => candidate.slug === page.slug)) {
2661
+ throw new TypeError(`Chapter member page "${page.id}" conflicts with parent content`);
2662
+ }
2663
+ pages.push({ ...page, source: chapter.source });
2664
+ }
2665
+ return {
2666
+ pages: pages.sort(pageOrder),
2667
+ parentRevision: parent?.revision ?? null,
2668
+ chapterRevision: chapter?.revision ?? null
2669
+ };
2670
+ }
2671
+ var allowed = (page, viewer) => viewer.admin || page.audience.kind === "all-members" || viewer.tierId !== null && page.audience.tierIds.includes(viewer.tierId);
2672
+ function projectMemberContent(content, viewer, tiers) {
2673
+ const authority = new Map(tiers.filter((tier) => tier.active !== false).map((tier) => [tier.id, tier]));
2674
+ return {
2675
+ viewer: { ...viewer },
2676
+ revisions: { parent: content.parentRevision, chapter: content.chapterRevision },
2677
+ pages: content.pages.map((page) => {
2678
+ const canRead = allowed(page, viewer);
2679
+ const offer = !canRead ? page.upsell?.offer : void 0;
2680
+ const tier = offer ? authority.get(offer.tierId) : void 0;
2681
+ const upsell = offer && tier && typeof tier.name === "string" && typeof tier.priceCents === "number" ? { ...offer, tierName: tier.name, priceCents: tier.priceCents } : void 0;
2682
+ return {
2683
+ id: page.id,
2684
+ slug: page.slug,
2685
+ title: page.title,
2686
+ ...page.summary ? { summary: page.summary } : {},
2687
+ navigation: page.navigation,
2688
+ audience: page.audience,
2689
+ source: page.source,
2690
+ access: canRead ? "allowed" : "locked",
2691
+ ...page.teaserMarkdown ? { teaserMarkdown: page.teaserMarkdown } : {},
2692
+ ...canRead ? { bodyMarkdown: page.bodyMarkdown } : {},
2693
+ ...upsell ? { upsell } : {}
2694
+ };
2695
+ })
2696
+ };
2697
+ }
2698
+
2699
+ // src/member-content-store.ts
2700
+ var control = (row) => row && row.document && typeof row.document === "object" ? row : null;
2701
+ var local = (row) => row && row.document && typeof row.document === "object" ? row : null;
2702
+ async function exists(db, ns, where) {
2703
+ if (db.aggregate) return Number((await db.aggregate(ns, { count: true }, { where })).count ?? 0) > 0;
2704
+ const rows4 = (await db.query({ [ns]: {} }))[ns] ?? [];
2705
+ return rows4.some((row) => Object.entries(where).every(([key, value]) => row[key] === value));
2706
+ }
2707
+ async function loadActiveMemberContentControl(db, targetId, environment) {
2708
+ const where = { targetId, environment, active: true };
2709
+ if (!await exists(db, "memberContentControls", where)) return null;
2710
+ const rows4 = (await db.query({ memberContentControls: { $: {
2711
+ where,
2712
+ order: { revision: "desc" },
2713
+ limit: 1
2714
+ } } })).memberContentControls;
2715
+ return control(Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision) - Number(a.revision))[0] : void 0);
2716
+ }
2717
+ async function loadLatestMemberContentControl(db, targetId, environment) {
2718
+ const where = { targetId, environment };
2719
+ if (!await exists(db, "memberContentControls", where)) return null;
2720
+ const rows4 = (await db.query({ memberContentControls: { $: {
2721
+ where,
2722
+ order: { revision: "desc" },
2723
+ limit: 1
2724
+ } } })).memberContentControls;
2725
+ return control(Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision) - Number(a.revision))[0] : void 0);
2726
+ }
2727
+ async function loadLocalMemberContent(db, chapterId, environment) {
2728
+ const id = `member-content-local:${environment}`;
2729
+ const rows4 = (await db.query({ memberContentLocal: { $: { where: { id }, limit: 1 } } })).memberContentLocal;
2730
+ return local(Array.isArray(rows4) ? rows4[0] : void 0);
2731
+ }
2732
+ async function materializeMemberContentControl(db, envelope, previous, receivedAt) {
2733
+ const { document, digest } = envelope;
2734
+ const target = document.target;
2735
+ const id = `member-content-control:${target.environment}:${document.revision}`;
2736
+ const ops = [];
2737
+ if (previous?.active) {
2738
+ ops.push({ t: "update", ns: "memberContentControls", id: previous.id, attrs: { active: false } });
2739
+ }
2740
+ ops.push({ t: "update", ns: "memberContentControls", id, attrs: {
2741
+ id,
2742
+ sourceId: document.source.appId,
2743
+ targetId: target.chapterId,
2744
+ environment: target.environment,
2745
+ revision: document.revision,
2746
+ digest,
2747
+ document,
2748
+ active: true,
2749
+ receivedAt
2750
+ } });
2751
+ await db.transact(ops, { mutationId: `member-content-control:${target.environment}:${document.revision}:${digest}` });
2752
+ }
2753
+ async function saveLocalMemberContent(db, document, environment, expectedRevision, updatedAt, updatedBy) {
2754
+ const id = `member-content-local:${environment}`;
2755
+ const current = await loadLocalMemberContent(db, document.source.appId, environment);
2756
+ if ((current?.revision ?? 0) !== expectedRevision) throw new Error("member_content_revision_conflict");
2757
+ const row = {
2758
+ id,
2759
+ chapterId: document.source.appId,
2760
+ environment,
2761
+ revision: document.revision,
2762
+ document,
2763
+ updatedAt,
2764
+ ...updatedBy ? { updatedBy } : {}
2765
+ };
2766
+ await db.transact([{ t: "update", ns: "memberContentLocal", id, attrs: row }], {
2767
+ mutationId: `member-content-local:${environment}:${document.revision}`,
2768
+ guards: current ? [{ ns: "memberContentLocal", id, exists: true, equals: { revision: expectedRevision } }] : [{ ns: "memberContentLocal", id, exists: false }]
2769
+ });
2770
+ return row;
2771
+ }
2772
+
2773
+ // src/worker-routes-member-content.ts
2774
+ var ADMIN_PATH = "/api/admin/member-content";
2775
+ var BODY_CAP = 1e6;
2776
+ var reject2 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
2777
+ async function groupAndTiers(db, chapterId) {
2778
+ const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
2779
+ const group = Array.isArray(groups) ? groups[0] : void 0;
2780
+ if (!group) return { group: null, tiers: [] };
2781
+ return { group, tiers: await loadTiers(db, group) };
2782
+ }
2783
+ async function memberTierId(db, chapterId, email) {
2784
+ if (!email) return null;
2785
+ let rows4 = (await db.query({ applications: { $: {
2786
+ where: { emailNormalized: normalizeAdmissionEmail(email) },
2787
+ limit: 20
2788
+ } } })).applications;
2789
+ if (!rows4?.length) rows4 = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
2790
+ 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;
2791
+ return typeof latest?.tierId === "string" && latest.tierId ? latest.tierId : null;
2792
+ }
2793
+ async function activeDocuments(db, chapterId, environment) {
2794
+ const [parent, chapter] = await Promise.all([
2795
+ loadActiveMemberContentControl(db, chapterId, environment),
2796
+ loadLocalMemberContent(db, chapterId, environment)
2797
+ ]);
2798
+ return { parent, chapter };
2799
+ }
2800
+ var handleMemberContentControl = async (req, url, env, ctx) => {
2801
+ if (req.method !== "POST" || url.pathname !== MEMBER_CONTENT_CONTROL_PATH) return null;
2802
+ const settings = ctx.chapter.memberContent?.parent;
2803
+ if (!settings) return json({ error: "not found" }, 404);
2804
+ const db = ctx.makeDb(env);
2805
+ const secret = await getVaultSecret(db, settings.secretName);
2806
+ if (!secret) return json({ error: "unauthorized" }, 401);
2807
+ const verified = await verifyFederatedRequest3(req, { secret, senders: [settings.sourceId] });
2808
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
2809
+ let input;
2810
+ try {
2811
+ input = JSON.parse(verified.body);
2812
+ } catch {
2813
+ return reject2("invalid_document", 400, ["request body must be JSON"]);
2814
+ }
2815
+ const digest = typeof input.digest === "string" ? input.digest : "";
2816
+ const initial = validateMemberContentDocument(input.document, {
2817
+ sourceType: "parent"
2818
+ });
2819
+ if (!initial.ok) return reject2("invalid_document", 400, initial.errors);
2820
+ const document = initial.value;
2821
+ if (document.source.appId !== settings.sourceId) return reject2("wrong_source", 403);
2822
+ if (document.target?.chapterId !== ctx.chapter.id) return reject2("wrong_target", 409);
2823
+ if (document.target.environment !== env.ODLA_ENV) return reject2("wrong_environment", 409);
2824
+ const { tiers } = await groupAndTiers(db, ctx.chapter.id);
2825
+ const checked = validateMemberContentDocument(document, {
2826
+ sourceType: "parent",
2827
+ sourceAppId: settings.sourceId,
2828
+ tiers
2829
+ });
2830
+ if (!checked.ok) return reject2("invalid_document", 400, checked.errors);
2831
+ const local2 = await loadLocalMemberContent(db, ctx.chapter.id, env.ODLA_ENV);
2832
+ if (local2) {
2833
+ const localCheck = validateMemberContentDocument(local2.document, {
2834
+ sourceType: "chapter",
2835
+ sourceAppId: ctx.chapter.id,
2836
+ tiers,
2837
+ parentPages: document.pages
2838
+ });
2839
+ if (!localCheck.ok) {
2840
+ return reject2("invalid_document", 409, localCheck.errors.map((error) => `existing chapter content: ${error}`));
1692
2841
  }
1693
- return {
1694
- required: resolved.required,
1695
- optional: resolved.optional,
1696
- ...Object.keys(resolved.conditions).length ? { conditions: resolved.conditions } : {},
1697
- ...copy ? { copy } : {}
1698
- };
1699
- } catch (error) {
1700
- errors.push(error instanceof Error ? error.message : "document.application is invalid");
1701
- return void 0;
1702
2842
  }
1703
- }
1704
- function policyOf(value, errors) {
1705
- if (value === void 0) return void 0;
1706
- const input = recordOf(value);
1707
- if (!input) {
1708
- errors.push("document.policy must be an object");
1709
- return void 0;
2843
+ if (!digest || await memberContentDigest(document) !== digest) return reject2("invalid_digest", 400);
2844
+ const latest = await loadLatestMemberContentControl(db, ctx.chapter.id, env.ODLA_ENV);
2845
+ if (latest && document.revision < latest.revision) return reject2("stale_revision", 409);
2846
+ if (latest && document.revision === latest.revision) {
2847
+ if (latest.digest !== digest) return reject2("conflicting_revision", 409);
2848
+ return json({
2849
+ ok: true,
2850
+ target: document.target,
2851
+ revision: latest.revision,
2852
+ digest: latest.digest,
2853
+ status: "replayed",
2854
+ acknowledgedAt: latest.receivedAt
2855
+ });
1710
2856
  }
1711
- const allowed = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
1712
- unknownKeys(input, allowed, "document.policy", errors);
1713
- const result = {};
1714
- for (const key of allowed) {
1715
- if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
1716
- result[key] = text(input[key], `document.policy.${key}`, errors);
2857
+ const receivedAt = Date.now();
2858
+ try {
2859
+ await materializeMemberContentControl(db, { digest, document }, latest, receivedAt);
2860
+ } catch {
2861
+ return reject2("materialization_failed", 503);
1717
2862
  }
1718
- return result;
1719
- }
1720
- function themeOf(value, errors) {
1721
- if (value === void 0) return void 0;
1722
- const input = recordOf(value);
1723
- const tokens = recordOf(input?.tokens);
1724
- if (!input || !tokens) {
1725
- errors.push("document.theme.tokens must be an object");
1726
- return void 0;
2863
+ return json({
2864
+ ok: true,
2865
+ target: document.target,
2866
+ revision: document.revision,
2867
+ digest,
2868
+ status: "accepted",
2869
+ acknowledgedAt: receivedAt
2870
+ });
2871
+ };
2872
+ var handleMemberContent = async (req, url, env, ctx) => {
2873
+ if (req.method !== "GET" || url.pathname !== MEMBER_CONTENT_PATH) return null;
2874
+ if (!ctx.chapter.memberContent) return json({ error: "not found" }, 404);
2875
+ const user = await ctx.verifyUser(req, env);
2876
+ if (!user) return json({ error: "unauthorized" }, 401);
2877
+ const db = ctx.makeDb(env);
2878
+ const role = await ctx.roleFor(db, user);
2879
+ const admin = await ctx.isAdmin(db, user);
2880
+ const roleIndex = ctx.chapter.auth.ladder.indexOf(role);
2881
+ const memberIndex = Math.max(0, ctx.chapter.auth.ladder.length - 2);
2882
+ if (!admin && roleIndex < memberIndex) return json({ error: "member access required" }, 403);
2883
+ const [{ parent, chapter }, { group, tiers }, tierId] = await Promise.all([
2884
+ activeDocuments(db, ctx.chapter.id, env.ODLA_ENV),
2885
+ groupAndTiers(db, ctx.chapter.id),
2886
+ memberTierId(db, ctx.chapter.id, user.email)
2887
+ ]);
2888
+ const effective = composeMemberContent(parent?.document, chapter?.document);
2889
+ let offerable = tiers;
2890
+ if (group) {
2891
+ const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
2892
+ offerable = await loadOfferableTiers(db, group, Boolean(stripeSecret));
1727
2893
  }
1728
- unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
1729
- if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
1730
- let serializedLength = 0;
1731
- for (const [key, token] of Object.entries(tokens)) {
1732
- if (!/^--ui-[a-z0-9][a-z0-9-]{0,94}$/.test(key)) {
1733
- errors.push(`document.theme.tokens.${key} must be an allowlisted --ui-* custom property`);
1734
- }
1735
- const rendered = String(token).trim();
1736
- serializedLength += key.length + rendered.length + 4;
1737
- 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)) {
1738
- errors.push(`document.theme.tokens.${key} must be a bounded breakout-safe CSS value`);
1739
- }
2894
+ return json(projectMemberContent(effective, { tierId, admin }, offerable));
2895
+ };
2896
+ var handleAdminMemberContent = async (req, url, env, ctx) => {
2897
+ if (url.pathname !== ADMIN_PATH) return null;
2898
+ if (!ctx.chapter.memberContent) return json({ error: "not found" }, 404);
2899
+ const user = await ctx.verifyUser(req, env);
2900
+ if (!user) return json({ error: "unauthorized" }, 401);
2901
+ const db = ctx.makeDb(env);
2902
+ if (!await ctx.isAdmin(db, user)) return json({ error: "forbidden" }, 403);
2903
+ const [{ parent, chapter }, { tiers }] = await Promise.all([
2904
+ activeDocuments(db, ctx.chapter.id, env.ODLA_ENV),
2905
+ groupAndTiers(db, ctx.chapter.id)
2906
+ ]);
2907
+ if (req.method === "GET") {
2908
+ const effective = composeMemberContent(parent?.document, chapter?.document);
2909
+ return json({
2910
+ parent: parent ? { revision: parent.revision, digest: parent.digest, document: parent.document } : null,
2911
+ chapter: chapter ? { revision: chapter.revision, document: chapter.document } : null,
2912
+ effective,
2913
+ tiers: tiers.map(({ id, name, priceCents, active }) => ({ id, name, priceCents, active })),
2914
+ localAuthoring: ctx.chapter.memberContent.localAuthoring
2915
+ });
1740
2916
  }
1741
- if (serializedLength > 65536) errors.push("document.theme.tokens exceeds the serialized size bound");
1742
- return { tokens };
1743
- }
1744
- function validateSignupControlDocument(input) {
1745
- const errors = [];
1746
- const root = recordOf(input);
1747
- if (!root) return { ok: false, errors: ["document must be an object"] };
1748
- unknownKeys(root, ROOT_KEYS2, "document", errors);
1749
- if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
1750
- if (!Number.isSafeInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
1751
- if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
1752
- const source = recordOf(root.source);
1753
- const target = recordOf(root.target);
1754
- const appId = text(source?.appId, "document.source.appId", errors, 63);
1755
- const chapterId = text(target?.chapterId, "document.target.chapterId", errors, 63);
1756
- const environment = text(target?.environment, "document.target.environment", errors, 63);
1757
- if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
1758
- if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
1759
- if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
1760
- if (target?.stripeMode !== "test" && target?.stripeMode !== "live") errors.push("document.target.stripeMode must be test or live");
1761
- const tiers = tiersOf(root.tiers, errors);
1762
- const available = tiers.filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder);
1763
- const page = validateMembershipPageDocument(root.membershipPage, { tiers: available });
1764
- if (!page.ok) errors.push(...page.errors);
1765
- else if (page.value.tiers.some((tier, index) => tier.tierId !== available[index]?.id)) {
1766
- errors.push("document.membershipPage.tiers must follow the available tier display order");
2917
+ if (req.method !== "PUT") return json({ error: "method not allowed" }, 405);
2918
+ if (!ctx.chapter.memberContent.localAuthoring) return json({ error: "local authoring is disabled" }, 403);
2919
+ const raw = await req.text();
2920
+ if (raw.length > BODY_CAP) return json({ error: "request body too large" }, 413);
2921
+ let input;
2922
+ try {
2923
+ input = JSON.parse(raw);
2924
+ } catch {
2925
+ return json({ error: "invalid JSON body" }, 400);
1767
2926
  }
1768
- const application = applicationOf(root.application, errors);
1769
- const policy = policyOf(root.policy, errors);
1770
- let scheduling;
1771
- if (root.scheduling !== void 0) {
1772
- const checked = validateScheduling(root.scheduling);
1773
- if (!checked.ok) errors.push(...Object.entries(checked.errors).map(([key, value]) => `document.scheduling.${key}: ${value}`));
1774
- else scheduling = root.scheduling;
2927
+ const expectedRevision = Number(input.expectedRevision);
2928
+ if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
2929
+ return json({ error: "expectedRevision must be a non-negative integer" }, 400);
1775
2930
  }
1776
- const theme = themeOf(root.theme, errors);
1777
- if (errors.length || !page.ok) return { ok: false, errors };
1778
- return { ok: true, value: {
1779
- schemaVersion: 1,
1780
- revision: Number(root.revision),
1781
- generatedAt: Number(root.generatedAt),
1782
- source: { appId },
1783
- target: { chapterId, environment, stripeMode: target?.stripeMode },
2931
+ const now = Date.now();
2932
+ const documentInput = withLocalAuthority(input.document, expectedRevision + 1, now, ctx.chapter.id);
2933
+ const checked = validateMemberContentDocument(documentInput, {
2934
+ sourceType: "chapter",
2935
+ sourceAppId: ctx.chapter.id,
1784
2936
  tiers,
1785
- membershipPage: page.value,
1786
- ...application ? { application } : {},
1787
- ...policy ? { policy } : {},
1788
- ...scheduling ? { scheduling } : {},
1789
- ...theme ? { theme } : {}
1790
- } };
2937
+ parentPages: parent?.document.pages ?? []
2938
+ });
2939
+ if (!checked.ok) return json({ error: "invalid member content", errors: checked.errors }, 400);
2940
+ try {
2941
+ const saved = await saveLocalMemberContent(
2942
+ db,
2943
+ checked.value,
2944
+ env.ODLA_ENV,
2945
+ expectedRevision,
2946
+ now,
2947
+ user.email ?? user.userId
2948
+ );
2949
+ return json({ ok: true, revision: saved.revision, document: saved.document });
2950
+ } catch (error) {
2951
+ if (error instanceof Error && error.message === "member_content_revision_conflict") {
2952
+ return json({ error: "member content changed; reload before saving" }, 409);
2953
+ }
2954
+ return json({ error: "member content could not be saved" }, 503);
2955
+ }
2956
+ };
2957
+ function withLocalAuthority(value, revision, generatedAt, appId) {
2958
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
2959
+ return {
2960
+ ...value,
2961
+ schemaVersion: 1,
2962
+ revision,
2963
+ generatedAt,
2964
+ source: { type: "chapter", appId }
2965
+ };
1791
2966
  }
1792
2967
 
1793
- // src/worker-routes-signup-control.ts
1794
- var reject = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
1795
- var handleSignupControl = async (req, url, env, ctx) => {
1796
- if (req.method !== "POST" || url.pathname !== SIGNUP_CONTROL_PATH) return null;
2968
+ // src/worker-routes-admission-grants.ts
2969
+ import { verifyFederatedRequest as verifyFederatedRequest4 } from "@odla-ai/db";
2970
+ var reject3 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
2971
+ var handleAdmissionGrants = async (req, url, env, ctx) => {
2972
+ if (req.method !== "POST" || url.pathname !== ADMISSION_GRANTS_PATH) return null;
1797
2973
  const authority = ctx.chapter.signupControl;
1798
2974
  if (!authority) return json({ error: "not found" }, 404);
1799
2975
  const db = ctx.makeDb(env);
1800
2976
  const secret = await getVaultSecret(db, authority.secretName);
1801
2977
  if (!secret) return json({ error: "unauthorized" }, 401);
1802
- const verified = await verifyFederatedRequest2(req, { secret, senders: [authority.sourceId] });
2978
+ const verified = await verifyFederatedRequest4(req, { secret, senders: [authority.sourceId] });
1803
2979
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
1804
2980
  let input;
1805
2981
  try {
1806
2982
  input = JSON.parse(verified.body);
1807
2983
  } catch {
1808
- return reject("invalid_document", 400, ["request body must be JSON"]);
2984
+ return reject3("invalid_document", 400, ["request body must be JSON"]);
1809
2985
  }
1810
2986
  const digest = typeof input.digest === "string" ? input.digest : "";
1811
- const checked = validateSignupControlDocument(input.document);
1812
- if (!checked.ok) return reject("invalid_document", 400, checked.errors);
2987
+ const checked = validateAdmissionGrantDocument(input.document);
2988
+ if (!checked.ok) return reject3("invalid_document", 400, checked.errors);
1813
2989
  const document = checked.value;
1814
- if (document.source.appId !== authority.sourceId) return reject("wrong_source", 403);
1815
- if (document.target.chapterId !== ctx.chapter.id) return reject("wrong_target", 409);
1816
- if (document.target.environment !== env.ODLA_ENV) return reject("wrong_environment", 409);
1817
- if (document.target.stripeMode !== authority.stripeMode) return reject("wrong_stripe_mode", 409);
1818
- const applicationErrors = validateControlledApplication(ctx.chapter, document);
1819
- if (applicationErrors.length) return reject("invalid_document", 400, applicationErrors);
1820
- if (!digest || await signupControlDigest(document) !== digest) return reject("invalid_digest", 400);
1821
- const latest = await loadLatestSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
1822
- if (latest && document.revision < latest.revision) return reject("stale_revision", 409);
2990
+ if (document.source.appId !== authority.sourceId) return reject3("wrong_source", 403);
2991
+ if (document.target.chapterId !== ctx.chapter.id) return reject3("wrong_target", 409);
2992
+ if (document.target.environment !== env.ODLA_ENV) return reject3("wrong_environment", 409);
2993
+ if (!digest || await signupControlDigest(document) !== digest) return reject3("invalid_digest", 400);
2994
+ const latest = await loadLatestAdmissionRevision(db, authority.sourceId, ctx.chapter.id, env.ODLA_ENV);
2995
+ if (latest && document.revision < latest.revision) return reject3("stale_revision", 409);
1823
2996
  if (latest && document.revision === latest.revision) {
1824
- if (latest.digest !== digest) return reject("conflicting_revision", 409);
2997
+ if (latest.digest !== digest) return reject3("conflicting_revision", 409);
1825
2998
  return json({
1826
2999
  ok: true,
1827
3000
  target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
@@ -1833,9 +3006,9 @@ var handleSignupControl = async (req, url, env, ctx) => {
1833
3006
  }
1834
3007
  const receivedAt = Date.now();
1835
3008
  try {
1836
- await materializeSignupControl(db, { digest, document }, latest, receivedAt);
3009
+ await materializeAdmissionGrants(db, { digest, document }, latest, receivedAt);
1837
3010
  } catch {
1838
- return reject("materialization_failed", 503);
3011
+ return reject3("materialization_failed", 503);
1839
3012
  }
1840
3013
  return json({
1841
3014
  ok: true,
@@ -1847,92 +3020,101 @@ var handleSignupControl = async (req, url, env, ctx) => {
1847
3020
  });
1848
3021
  };
1849
3022
 
1850
- // src/crm-sync.ts
1851
- import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
1852
- var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
1853
- var crmDeps = (db, ctx) => ({
1854
- crm: ctx.chapter.crm,
1855
- db,
1856
- now: () => Date.now(),
1857
- newId: () => crypto.randomUUID(),
1858
- chapter: ctx.chapter
1859
- });
1860
- function personInputFromApp(chapter, app) {
1861
- const input = sharedPersonInput({
1862
- email: str(app.email),
1863
- firstName: str(app.firstName) || void 0,
1864
- lastName: str(app.lastName) || void 0,
1865
- phone: str(app.phone) || void 0,
1866
- linkedin: str(app.linkedin) || void 0,
1867
- hubRecordId: str(app.id)
1868
- });
1869
- for (const f of chapter.application.crmFields) {
1870
- if (app[f] !== void 0) input[f] = app[f];
1871
- }
1872
- if (app.id !== void 0) input.applicationId = str(app.id);
1873
- return input;
1874
- }
1875
- function billingColumns(app) {
1876
- const status = str(app.status);
1877
- const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
1878
- const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
1879
- const cols = { billingStatus };
1880
- if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
1881
- if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
1882
- if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
1883
- return cols;
1884
- }
1885
- async function syncApplicationToCrm(deps, opts) {
1886
- const emailKey = str(opts.app.email).toLowerCase();
1887
- if (!emailKey) return null;
1888
- const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1889
- const input = personInputFromApp(deps.chapter, opts.app);
1890
- const { crm_record } = await deps.db.query({
1891
- crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
1892
- });
1893
- const existing = crm_record?.[0] ?? null;
1894
- const stage = opts.stage || void 0;
1895
- let recordId;
1896
- if (existing && typeof existing.id === "string") {
1897
- recordId = existing.id;
1898
- await updateRecord2(recordDeps, { id: recordId, input });
1899
- } else {
1900
- const created = await createRecord2(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
1901
- recordId = created.id;
1902
- }
1903
- if (existing && stage && existing.stage !== stage) {
1904
- await setStage(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1905
- }
1906
- await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1907
- await linkIdentity(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1908
- return recordId;
1909
- }
1910
- async function backfillCrm(deps) {
1911
- const [appsRes, usersRes] = await Promise.all([
1912
- deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
1913
- deps.db.query({ $users: { $: { limit: 1e3 } } })
1914
- ]);
1915
- const seen = /* @__PURE__ */ new Set();
1916
- let synced = 0;
1917
- const errors = [];
1918
- const run = async (app, stage) => {
1919
- const key = str(app.email).toLowerCase();
1920
- if (!key || seen.has(key)) return;
1921
- seen.add(key);
3023
+ // src/worker-routes-admin-admission.ts
3024
+ async function gate(req, env, ctx) {
3025
+ const rawDb = ctx.makeDb(env);
3026
+ const user = await ctx.verifyUser(req, env);
3027
+ if (!user) return json({ error: "unauthorized" }, 401);
3028
+ if (!await ctx.isAdmin(rawDb, user)) return json({ error: "forbidden" }, 403);
3029
+ return { db: rawDb, user };
3030
+ }
3031
+ var handleAdminAdmissionGrants = async (req, url, env, ctx) => {
3032
+ if (url.pathname === "/api/admin/admission-grants" && req.method === "GET") {
3033
+ const auth = await gate(req, env, ctx);
3034
+ if (auth instanceof Response) return auth;
3035
+ const result = await auth.db.query({ admissionGrants: { $: {
3036
+ where: { targetId: ctx.chapter.id },
3037
+ order: { createdAt: "desc" },
3038
+ limit: 500
3039
+ } } });
3040
+ const grants = Array.isArray(result.admissionGrants) ? result.admissionGrants.map((row) => ({ ...row, emailNormalized: void 0 })) : [];
3041
+ return json({ grants });
3042
+ }
3043
+ if (url.pathname === "/api/admin/admission-grants" && req.method === "POST") {
3044
+ const auth = await gate(req, env, ctx);
3045
+ if (auth instanceof Response) return auth;
3046
+ let body;
1922
3047
  try {
1923
- await syncApplicationToCrm(deps, { app, stage });
1924
- synced += 1;
1925
- } catch (err) {
1926
- errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
3048
+ body = JSON.parse(await req.text());
3049
+ } catch {
3050
+ return json({ error: "invalid JSON body" }, 400);
3051
+ }
3052
+ const email = typeof body.email === "string" ? body.email.trim() : "";
3053
+ const emailNormalized = normalizeAdmissionEmail(email);
3054
+ if (!isValidEmail(emailNormalized)) return json({ error: "email must be valid" }, 400);
3055
+ const decision = body.decision ?? "waive_interview";
3056
+ if (decision !== "waive_interview" && decision !== "require_interview") {
3057
+ return json({ error: "decision must be waive_interview or require_interview" }, 400);
3058
+ }
3059
+ const note = typeof body.note === "string" ? body.note.trim() : "";
3060
+ if (note.length > 2e3) return json({ error: "note is too long" }, 400);
3061
+ const expiresAt = body.expiresAt === void 0 || body.expiresAt === null ? void 0 : Number(body.expiresAt);
3062
+ if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) {
3063
+ return json({ error: "expiresAt must be a future epoch timestamp" }, 400);
3064
+ }
3065
+ const sourceGrantId = crypto.randomUUID();
3066
+ const id = `admission:local:${ctx.chapter.id}:${sourceGrantId}`;
3067
+ const createdAt = Date.now();
3068
+ const actor = auth.user.email ?? auth.user.userId;
3069
+ await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
3070
+ id,
3071
+ sourceGrantId,
3072
+ sourceScope: "local",
3073
+ sourceId: ctx.chapter.id,
3074
+ targetId: ctx.chapter.id,
3075
+ email,
3076
+ emailNormalized,
3077
+ decision,
3078
+ ...note ? { note } : {},
3079
+ ...expiresAt !== void 0 ? { expiresAt } : {},
3080
+ active: true,
3081
+ createdAt,
3082
+ createdBy: actor,
3083
+ updatedAt: createdAt,
3084
+ updatedBy: actor
3085
+ } }], { mutationId: `admission-local-create:${sourceGrantId}` });
3086
+ return json({ grant: { id, sourceGrantId, sourceScope: "local", email, decision, active: true, createdAt, ...note ? { note } : {}, ...expiresAt ? { expiresAt } : {} } }, 201);
3087
+ }
3088
+ const prefix = "/api/admin/admission-grants/";
3089
+ let revokeId = "";
3090
+ if (url.pathname.startsWith(prefix)) {
3091
+ try {
3092
+ revokeId = decodeURIComponent(url.pathname.slice(prefix.length));
3093
+ } catch {
3094
+ revokeId = "";
1927
3095
  }
1928
- };
1929
- for (const a of appsRes.applications ?? []) await run(a, str(a.status));
1930
- for (const u of usersRes.$users ?? []) {
1931
- if (u.deleted === true) continue;
1932
- await run({ email: u.email, firstName: str(u.name) });
1933
3096
  }
1934
- return { synced, errors };
1935
- }
3097
+ if (/^admission:local:[a-z0-9-]+:[0-9a-f-]+$/.test(revokeId) && req.method === "DELETE") {
3098
+ const auth = await gate(req, env, ctx);
3099
+ if (auth instanceof Response) return auth;
3100
+ const id = revokeId;
3101
+ const found = await auth.db.query({ admissionGrants: { $: { where: { id }, limit: 1 } } });
3102
+ const row = Array.isArray(found.admissionGrants) ? found.admissionGrants[0] : void 0;
3103
+ if (!row || row.targetId !== ctx.chapter.id || row.sourceScope !== "local") return json({ error: "not found" }, 404);
3104
+ if (row.active !== true) return json({ ok: true, replayed: true });
3105
+ const revokedAt = Date.now();
3106
+ const actor = auth.user.email ?? auth.user.userId;
3107
+ await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
3108
+ active: false,
3109
+ revokedAt,
3110
+ revokedBy: actor,
3111
+ updatedAt: revokedAt,
3112
+ updatedBy: actor
3113
+ } }], { mutationId: `admission-local-revoke:${id}` });
3114
+ return json({ ok: true, replayed: false });
3115
+ }
3116
+ return null;
3117
+ };
1936
3118
 
1937
3119
  // src/worker-routes-schedule.ts
1938
3120
  import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
@@ -1948,8 +3130,8 @@ function makeCalendar(env) {
1948
3130
  }
1949
3131
  async function firstRow(db, ns, q) {
1950
3132
  const res = await db.query({ [ns]: { $: q } });
1951
- const rows2 = res[ns];
1952
- return Array.isArray(rows2) ? rows2[0] : void 0;
3133
+ const rows4 = res[ns];
3134
+ return Array.isArray(rows4) ? rows4[0] : void 0;
1953
3135
  }
1954
3136
  async function computeSlots(cal, cfg) {
1955
3137
  const { from, to } = slotWindow(Date.now(), cfg.windowDays);
@@ -2086,8 +3268,26 @@ function findApplicationRef(obj) {
2086
3268
  const customerId = typeof obj.customer === "string" ? obj.customer : void 0;
2087
3269
  return { ...applicationId ? { applicationId } : {}, ...customerId ? { customerId } : {} };
2088
3270
  }
3271
+ function findGiftRef(obj) {
3272
+ const metaOf = (value) => value && typeof value === "object" ? value.metadata ?? {} : {};
3273
+ const pick = (metadata) => typeof metadata.giftIntentId === "string" ? metadata.giftIntentId : void 0;
3274
+ const parent = obj.parent;
3275
+ const subscription = obj.subscription_details ?? parent?.subscription_details;
3276
+ const giftIntentId = pick(metaOf(obj)) ?? pick(metaOf(subscription));
3277
+ const subscriptionId = typeof obj.subscription === "string" ? obj.subscription : typeof subscription?.subscription === "string" ? String(subscription.subscription) : void 0;
3278
+ return { ...giftIntentId ? { giftIntentId } : {}, providerObjectId: subscriptionId ?? String(obj.id ?? "") };
3279
+ }
2089
3280
  function normalizeWebhookEvent(event) {
2090
3281
  const obj = event.data?.object ?? {};
3282
+ const gift = findGiftRef(obj);
3283
+ if (gift.giftIntentId) {
3284
+ if (event.type === "payment_intent.succeeded" || event.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
3285
+ return { kind: "gift_paid", giftIntentId: gift.giftIntentId, providerObjectId: gift.providerObjectId };
3286
+ }
3287
+ if (event.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "refunded" };
3288
+ if (event.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "canceled" };
3289
+ return { kind: "ignored", type: event.type };
3290
+ }
2091
3291
  const ref = findApplicationRef(obj);
2092
3292
  switch (event.type) {
2093
3293
  case "invoice.paid": {
@@ -2188,9 +3388,9 @@ function createStripeProvider(config) {
2188
3388
  `sub:${input.applicationId}`
2189
3389
  );
2190
3390
  if (!sub.ok) fail("subscription create", sub);
2191
- const clientSecret = clientSecretOf(sub.body);
2192
- if (!clientSecret) fail("subscription confirmation-secret missing", sub);
2193
- return { customerId, subscriptionId: String(sub.body.id), clientSecret };
3391
+ const clientSecret2 = clientSecretOf(sub.body);
3392
+ if (!clientSecret2) fail("subscription confirmation-secret missing", sub);
3393
+ return { customerId, subscriptionId: String(sub.body.id), clientSecret: clientSecret2 };
2194
3394
  },
2195
3395
  async ingestWebhook(rawBody, sigHeader) {
2196
3396
  if (!webhookSecret || !await verifyStripeSignature2(rawBody, sigHeader, webhookSecret)) {
@@ -2203,8 +3403,8 @@ function createStripeProvider(config) {
2203
3403
  const sk = requireSecret(secretKey);
2204
3404
  const charges = await stripeCall(sk, "GET", "/v1/charges", { customer: input.customerId, limit: 100 });
2205
3405
  if (!charges.ok) fail("charges list", charges);
2206
- const rows2 = charges.body.data ?? [];
2207
- const paid = rows2.filter((c) => c.status === "succeeded" && c.refunded !== true);
3406
+ const rows4 = charges.body.data ?? [];
3407
+ const paid = rows4.filter((c) => c.status === "succeeded" && c.refunded !== true);
2208
3408
  const charge = paid[paid.length - 1];
2209
3409
  if (!charge) {
2210
3410
  const err = new Error("no paid charge to refund");
@@ -2220,12 +3420,337 @@ function createStripeProvider(config) {
2220
3420
  };
2221
3421
  }
2222
3422
 
3423
+ // src/gift-codes.ts
3424
+ var GIFT_CODE_VERSION = 1;
3425
+ var GIFT_CODE_BYTES = 32;
3426
+ var base64url = (bytes) => {
3427
+ let binary = "";
3428
+ for (const byte of bytes) binary += String.fromCharCode(byte);
3429
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
3430
+ };
3431
+ function createGiftCode() {
3432
+ const bytes = new Uint8Array(GIFT_CODE_BYTES);
3433
+ crypto.getRandomValues(bytes);
3434
+ return `gift${GIFT_CODE_VERSION}_${base64url(bytes)}`;
3435
+ }
3436
+ async function hashGiftCode(chapterId, code, version = GIFT_CODE_VERSION) {
3437
+ const bytes = new TextEncoder().encode(`odla-gift:${version}:${chapterId}:${code}`);
3438
+ return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
3439
+ }
3440
+ async function giftRequesterBucket(chapterId, requester) {
3441
+ const bytes = new TextEncoder().encode(`odla-gift-rate:${chapterId}:${requester}`);
3442
+ return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))).slice(0, 32);
3443
+ }
3444
+
3445
+ // src/gift-store.ts
3446
+ var first = (value) => Array.isArray(value) ? value[0] : void 0;
3447
+ async function prepareGiftCode(chapterId) {
3448
+ const code = createGiftCode();
3449
+ return { code, codeHash: await hashGiftCode(chapterId, code), codeVersion: GIFT_CODE_VERSION };
3450
+ }
3451
+ async function issueGiftClaim(db, intent, provider, now) {
3452
+ const existing = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
3453
+ if (existing) return { claimId: String(existing.id), orderId: String(existing.orderId), duplicate: true };
3454
+ const offer = intent.offerSnapshot;
3455
+ const orderId = `gift-order:${intent.id}`;
3456
+ const claimId = `gift-claim:${intent.id}`;
3457
+ const shared = {
3458
+ chapterId: intent.chapterId,
3459
+ offerId: intent.offerId,
3460
+ ...intent.purchaserApplicationId ? { purchaserApplicationId: intent.purchaserApplicationId } : {},
3461
+ ...intent.purchaserUserId ? { purchaserUserId: intent.purchaserUserId } : {},
3462
+ purchaserEmailNormalized: intent.purchaserEmailNormalized
3463
+ };
3464
+ const ops = [
3465
+ { t: "update", ns: "giftOrders", id: orderId, attrs: {
3466
+ id: orderId,
3467
+ intentId: intent.id,
3468
+ ...shared,
3469
+ environment: intent.environment,
3470
+ offerSnapshot: offer,
3471
+ purchaserType: intent.purchaserType,
3472
+ providerEventId: provider.eventId,
3473
+ providerObjectId: provider.objectId,
3474
+ amountCents: offer.priceCents,
3475
+ currency: offer.currency,
3476
+ billingMode: offer.billingMode,
3477
+ createdAt: now
3478
+ } },
3479
+ { t: "update", ns: "giftClaims", id: claimId, attrs: {
3480
+ id: claimId,
3481
+ orderId,
3482
+ intentId: intent.id,
3483
+ ...shared,
3484
+ target: offer.target,
3485
+ codeHash: intent.codeHash,
3486
+ codeVersion: intent.codeVersion,
3487
+ rotationCount: 0,
3488
+ codeIssuedAt: intent.codeIssuedAt,
3489
+ codeExpiresAt: now + offer.redemptionLifetimeHours * 36e5,
3490
+ attemptCount: 0,
3491
+ attemptWindowStartedAt: now,
3492
+ status: "pending",
3493
+ createdAt: now
3494
+ } },
3495
+ { t: "update", ns: "giftPurchaseIntents", id: intent.id, attrs: { status: "paid", paidAt: now, claimId, updatedAt: now } }
3496
+ ];
3497
+ if (intent.purchaserApplicationId) ops.push({
3498
+ t: "update",
3499
+ ns: "applications",
3500
+ id: intent.purchaserApplicationId,
3501
+ attrs: { giftPaidAt: now, giftClaimId: claimId }
3502
+ });
3503
+ try {
3504
+ const result = await db.transact(ops, {
3505
+ mutationId: `gift-paid:${provider.eventId}`,
3506
+ guards: [{ ns: "giftPurchaseIntents", id: intent.id, exists: true, equals: { status: "checkout_ready" } }]
3507
+ });
3508
+ return { claimId, orderId, duplicate: result.duplicate };
3509
+ } catch {
3510
+ const raced = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
3511
+ if (raced) return { claimId: String(raced.id), orderId: String(raced.orderId), duplicate: true };
3512
+ throw new Error("gift issuance conflict");
3513
+ }
3514
+ }
3515
+ function buyerGiftClaims(rows4, now) {
3516
+ return rows4.map((row) => {
3517
+ const stored = String(row.status ?? "pending");
3518
+ const status = stored === "pending" && Number(row.codeExpiresAt) <= now ? "expired" : stored;
3519
+ return {
3520
+ id: String(row.id),
3521
+ offerId: String(row.offerId),
3522
+ status,
3523
+ createdAt: Number(row.createdAt),
3524
+ codeExpiresAt: Number(row.codeExpiresAt),
3525
+ ...typeof row.redeemedAt === "number" ? { redeemedAt: row.redeemedAt } : {},
3526
+ ...typeof row.revokedAt === "number" ? { revokedAt: row.revokedAt } : {}
3527
+ };
3528
+ });
3529
+ }
3530
+ async function rotateGiftClaim(db, claim, now) {
3531
+ if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
3532
+ const prepared = await prepareGiftCode(String(claim.chapterId));
3533
+ const currentRotation = Number(claim.rotationCount ?? 0);
3534
+ try {
3535
+ const write = await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
3536
+ codeHash: prepared.codeHash,
3537
+ codeVersion: prepared.codeVersion,
3538
+ rotationCount: currentRotation + 1,
3539
+ codeIssuedAt: now,
3540
+ rotatedAt: now,
3541
+ attemptCount: 0,
3542
+ attemptWindowStartedAt: now
3543
+ } }], {
3544
+ mutationId: `gift-rotate:${String(claim.id)}:${currentRotation + 1}`,
3545
+ guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: "pending", rotationCount: currentRotation } }]
3546
+ });
3547
+ return write.duplicate ? null : { code: prepared.code };
3548
+ } catch {
3549
+ return null;
3550
+ }
3551
+ }
3552
+ async function redeemGiftClaim(db, claim, beneficiary, now) {
3553
+ if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
3554
+ const chapterId = String(claim.chapterId);
3555
+ const emailNormalized = normalizeAdmissionEmail(beneficiary.email);
3556
+ const target = claim.target;
3557
+ const ops = [];
3558
+ let result;
3559
+ if (target.type === "tier") {
3560
+ const apps = (await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications;
3561
+ const existing = apps?.filter((row) => row.groupId === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
3562
+ const id = existing ? String(existing.id) : `gift-application:${String(claim.id)}`;
3563
+ const status = existing && existing.status !== "submitted" ? String(existing.status) : "paid_pending_vetting";
3564
+ ops.push({ t: "update", ns: "applications", id, attrs: {
3565
+ id,
3566
+ groupId: chapterId,
3567
+ email: beneficiary.email,
3568
+ emailNormalized,
3569
+ tierId: target.tierId,
3570
+ status,
3571
+ giftClaimId: String(claim.id),
3572
+ giftOrderId: String(claim.orderId),
3573
+ clerkUserId: beneficiary.userId,
3574
+ ...existing ? {} : { createdAt: now }
3575
+ } });
3576
+ result = { kind: "application", id };
3577
+ } else {
3578
+ const id = `gift-entitlement:${String(claim.id)}`;
3579
+ ops.push({ t: "update", ns: "giftEntitlements", id, attrs: {
3580
+ id,
3581
+ claimId: String(claim.id),
3582
+ orderId: String(claim.orderId),
3583
+ chapterId,
3584
+ entitlementKey: target.entitlementKey,
3585
+ beneficiaryEmailNormalized: emailNormalized,
3586
+ beneficiaryUserId: beneficiary.userId,
3587
+ active: true,
3588
+ createdAt: now
3589
+ } });
3590
+ result = { kind: "entitlement", id };
3591
+ }
3592
+ ops.unshift({ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
3593
+ status: "redeemed",
3594
+ redeemedAt: now,
3595
+ beneficiaryEmailNormalized: emailNormalized,
3596
+ beneficiaryUserId: beneficiary.userId,
3597
+ ...result.kind === "application" ? { applicationId: result.id } : { entitlementId: result.id },
3598
+ attemptCount: Number(claim.attemptCount ?? 0) + 1
3599
+ } });
3600
+ try {
3601
+ await db.transact(ops, {
3602
+ mutationId: `gift-redeem:${String(claim.id)}:${beneficiary.userId}`,
3603
+ guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: {
3604
+ status: "pending",
3605
+ codeVersion: Number(claim.codeVersion),
3606
+ codeHash: String(claim.codeHash)
3607
+ } }]
3608
+ });
3609
+ return result;
3610
+ } catch {
3611
+ return null;
3612
+ }
3613
+ }
3614
+ async function revokeGiftClaim(db, claim, reason, now) {
3615
+ if (claim.status === "revoked") return false;
3616
+ const ops = [{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
3617
+ status: "revoked",
3618
+ revokedAt: now,
3619
+ revokedReason: reason
3620
+ } }];
3621
+ if (claim.entitlementId) ops.push({ t: "update", ns: "giftEntitlements", id: String(claim.entitlementId), attrs: { active: false, revokedAt: now } });
3622
+ if (claim.applicationId) ops.push({ t: "update", ns: "applications", id: String(claim.applicationId), attrs: {
3623
+ giftRevokedAt: now,
3624
+ ...reason === "refunded" ? { status: "refunded" } : { canceled: true }
3625
+ } });
3626
+ try {
3627
+ await db.transact(ops, {
3628
+ mutationId: `gift-revoke:${String(claim.id)}:${reason}`,
3629
+ guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: String(claim.status) } }]
3630
+ });
3631
+ return true;
3632
+ } catch {
3633
+ return false;
3634
+ }
3635
+ }
3636
+
3637
+ // src/gift-checkout.ts
3638
+ var clientSecret = (value) => {
3639
+ const direct = value.client_secret;
3640
+ if (typeof direct === "string" && direct) return direct;
3641
+ const invoice = value.latest_invoice;
3642
+ const confirmation = invoice?.confirmation_secret;
3643
+ const intent = invoice?.payment_intent;
3644
+ const nested = confirmation?.client_secret ?? intent?.client_secret;
3645
+ return typeof nested === "string" && nested ? nested : null;
3646
+ };
3647
+ async function startGiftProviderCheckout(secretKey, intent, email, existingCustomerId) {
3648
+ const offer = intent.offerSnapshot;
3649
+ const metadata = {
3650
+ giftIntentId: intent.id,
3651
+ chapterId: intent.chapterId,
3652
+ environment: intent.environment,
3653
+ offerId: intent.offerId
3654
+ };
3655
+ let customerId = existingCustomerId;
3656
+ if (!customerId) {
3657
+ const customer = await stripeCall(secretKey, "POST", "/v1/customers", { email, metadata }, `gift-customer:${intent.id}`);
3658
+ if (!customer.ok) throw new Error(`gift customer create failed (${customer.status})`);
3659
+ customerId = String(customer.body.id);
3660
+ }
3661
+ if (offer.billingMode === "subscription") {
3662
+ const created2 = await stripeCall(secretKey, "POST", "/v1/subscriptions", {
3663
+ customer: customerId,
3664
+ "items[0][price]": offer.stripePriceId,
3665
+ payment_behavior: "default_incomplete",
3666
+ "payment_settings[save_default_payment_method]": "on_subscription",
3667
+ "payment_settings[payment_method_types][0]": "card",
3668
+ "expand[0]": "latest_invoice.confirmation_secret",
3669
+ metadata
3670
+ }, `gift-subscription:${intent.id}`);
3671
+ const secret2 = created2.ok ? clientSecret(created2.body) : null;
3672
+ if (!created2.ok || !secret2) throw new Error(`gift subscription create failed (${created2.status})`);
3673
+ return { clientSecret: secret2, providerObjectId: String(created2.body.id), customerId };
3674
+ }
3675
+ const price = await stripeCall(secretKey, "GET", `/v1/prices/${encodeURIComponent(offer.stripePriceId)}`);
3676
+ if (!price.ok || price.body.active === false || price.body.unit_amount !== offer.priceCents || price.body.currency !== offer.currency) {
3677
+ throw new Error("gift price does not match the signed offer");
3678
+ }
3679
+ const created = await stripeCall(secretKey, "POST", "/v1/payment_intents", {
3680
+ customer: customerId,
3681
+ amount: offer.priceCents,
3682
+ currency: offer.currency,
3683
+ "automatic_payment_methods[enabled]": true,
3684
+ metadata
3685
+ }, `gift-payment:${intent.id}`);
3686
+ const secret = created.ok ? clientSecret(created.body) : null;
3687
+ if (!created.ok || !secret) throw new Error(`gift payment create failed (${created.status})`);
3688
+ return { clientSecret: secret, providerObjectId: String(created.body.id), customerId };
3689
+ }
3690
+
3691
+ // src/gift-intents.ts
3692
+ var first2 = (value) => Array.isArray(value) ? value[0] : void 0;
3693
+ async function startGiftIntent(db, input) {
3694
+ let stored = first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents);
3695
+ let giftCode;
3696
+ if (!stored) {
3697
+ const prepared = await prepareGiftCode(input.chapterId);
3698
+ const attrs = {
3699
+ id: input.id,
3700
+ chapterId: input.chapterId,
3701
+ environment: input.environment,
3702
+ purchaserType: input.purchaserType,
3703
+ purchaserEmailNormalized: input.purchaserEmailNormalized,
3704
+ ...input.purchaserApplicationId ? { purchaserApplicationId: input.purchaserApplicationId } : {},
3705
+ ...input.purchaserUserId ? { purchaserUserId: input.purchaserUserId } : {},
3706
+ offerId: input.offer.id,
3707
+ offerSnapshot: input.offer,
3708
+ status: "created",
3709
+ codeHash: prepared.codeHash,
3710
+ codeVersion: prepared.codeVersion,
3711
+ codeIssuedAt: input.now,
3712
+ createdAt: input.now,
3713
+ updatedAt: input.now
3714
+ };
3715
+ const write = await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs }], {
3716
+ mutationId: `gift-intent:${input.id}`
3717
+ });
3718
+ stored = write.duplicate ? first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents) : attrs;
3719
+ if (!write.duplicate) giftCode = prepared.code;
3720
+ }
3721
+ if (!stored || stored.chapterId !== input.chapterId || stored.environment !== input.environment || stored.offerId !== input.offer.id || stored.purchaserEmailNormalized !== input.purchaserEmailNormalized) {
3722
+ throw new Error("gift purchase intent does not match this checkout");
3723
+ }
3724
+ if (stored.status === "paid") throw new Error("gift purchase is already paid");
3725
+ const checkout = await startGiftProviderCheckout(
3726
+ input.stripeSecret,
3727
+ stored,
3728
+ input.purchaserEmail,
3729
+ typeof stored.providerCustomerId === "string" ? stored.providerCustomerId : input.existingCustomerId
3730
+ );
3731
+ await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs: {
3732
+ status: "checkout_ready",
3733
+ providerObjectId: checkout.providerObjectId,
3734
+ ...checkout.customerId ? { providerCustomerId: checkout.customerId } : {},
3735
+ checkoutStartedAt: input.now,
3736
+ updatedAt: input.now
3737
+ } }], { mutationId: `gift-checkout-ready:${input.id}:${checkout.providerObjectId}` });
3738
+ return {
3739
+ gift: true,
3740
+ intentId: input.id,
3741
+ clientSecret: checkout.clientSecret,
3742
+ publishableKey: input.publishableKey,
3743
+ lineItems: { standardCents: input.offer.priceCents, discountCents: 0, dueTodayCents: input.offer.priceCents },
3744
+ ...giftCode ? { giftCode } : {}
3745
+ };
3746
+ }
3747
+
2223
3748
  // src/worker-routes-payments.ts
2224
3749
  var codeOf = (err) => err && typeof err === "object" && typeof err.code === "string" ? err.code : "unknown";
2225
3750
  async function firstRow2(db, ns, q) {
2226
3751
  const res = await db.query({ [ns]: { $: q } });
2227
- const rows2 = res[ns];
2228
- return Array.isArray(rows2) ? rows2[0] : void 0;
3752
+ const rows4 = res[ns];
3753
+ return Array.isArray(rows4) ? rows4[0] : void 0;
2229
3754
  }
2230
3755
  function lineItems(group) {
2231
3756
  const standard = Number(group.standardPriceCents ?? 0);
@@ -2310,6 +3835,36 @@ async function startSubscription(req, env, ctx) {
2310
3835
  const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
2311
3836
  const secretKey = await getVaultSecret(db, "stripe_secret_key");
2312
3837
  if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
3838
+ if (typeof app.giftOfferId === "string") {
3839
+ const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
3840
+ const offer = findGiftOffer(control2?.document.giftOffers, app.giftOfferId, Date.now());
3841
+ if (!offer || typeof group.stripePublishableKey !== "string" || !group.stripePublishableKey) {
3842
+ return json({ error: "gift offer is unavailable" }, 409);
3843
+ }
3844
+ try {
3845
+ const started = await startGiftIntent(db, {
3846
+ id: typeof app.giftIntentId === "string" ? app.giftIntentId : `gift-intent:${applicationId}`,
3847
+ chapterId: ctx.chapter.id,
3848
+ environment: env.ODLA_ENV,
3849
+ offer,
3850
+ purchaserType: "application",
3851
+ purchaserEmail: String(app.email ?? ""),
3852
+ purchaserEmailNormalized: normalizeAdmissionEmail(String(app.email ?? "")),
3853
+ purchaserApplicationId: applicationId,
3854
+ stripeSecret: secretKey,
3855
+ publishableKey: group.stripePublishableKey,
3856
+ now: Date.now()
3857
+ });
3858
+ await db.transact([{ t: "update", ns: "applications", id: applicationId, attrs: {
3859
+ giftIntentId: started.intentId,
3860
+ giftCheckoutStartedAt: Date.now(),
3861
+ refundPolicyAckAt: Date.now()
3862
+ } }]);
3863
+ return json(started);
3864
+ } catch (error) {
3865
+ return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
3866
+ }
3867
+ }
2313
3868
  const tier = findTier(
2314
3869
  await loadTiers(db, group),
2315
3870
  typeof app.tierId === "string" ? app.tierId : null
@@ -2351,17 +3906,54 @@ async function ingestWebhook(req, env, ctx) {
2351
3906
  if (!ingest.ok) return json({ error: "invalid signature" }, 400);
2352
3907
  const { eventId, event } = ingest;
2353
3908
  if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
3909
+ if (event.kind === "gift_paid" || event.kind === "gift_revoked") {
3910
+ const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
3911
+ if (!intent || intent.chapterId !== ctx.chapter.id || intent.environment !== env.ODLA_ENV) {
3912
+ return json({ ok: true, matched: false });
3913
+ }
3914
+ if (event.kind === "gift_paid") {
3915
+ if (intent.providerObjectId !== event.providerObjectId) return json({ ok: true, matched: false });
3916
+ if (intent.status === "refunded" || intent.status === "canceled") return json({ ok: true, matched: false });
3917
+ try {
3918
+ const issued = await issueGiftClaim(db, intent, {
3919
+ eventId,
3920
+ objectId: event.providerObjectId
3921
+ }, Date.now());
3922
+ return json({ ok: true, matched: true, claimId: issued.claimId });
3923
+ } catch {
3924
+ return json({ error: "gift issuance failed" }, 503);
3925
+ }
3926
+ }
3927
+ await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: event.giftIntentId, attrs: {
3928
+ status: event.reason,
3929
+ updatedAt: Date.now()
3930
+ } }], { mutationId: `gift-revoked-intent:${eventId}` });
3931
+ const claim = await firstRow2(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
3932
+ if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
3933
+ return json({ ok: true, matched: Boolean(claim) });
3934
+ }
2354
3935
  const app = await findApplication(db, event);
2355
3936
  if (!app) return json({ ok: true, matched: false });
2356
3937
  const patch = webhookPatch(event, String(app.status ?? ""));
3938
+ let next = app;
2357
3939
  if (Object.keys(patch).length) {
2358
3940
  await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
2359
- const next = { ...app, ...patch };
3941
+ next = { ...app, ...patch };
2360
3942
  await syncApplicationToCrm(crmDeps(db, ctx), { app: next, stage: String(next.status ?? "") }).catch(() => void 0);
2361
3943
  }
2362
3944
  if (event.kind === "first_payment") {
2363
3945
  await notifyPaymentConfirmed(db, env, eventId, app);
2364
3946
  if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
3947
+ if (typeof next.interviewWaivedAt === "number") {
3948
+ await approveApplication({
3949
+ db,
3950
+ env,
3951
+ ctx,
3952
+ app: next,
3953
+ origin: new URL(req.url).origin,
3954
+ reason: "interview_waiver"
3955
+ });
3956
+ }
2365
3957
  }
2366
3958
  return json({ ok: true });
2367
3959
  }
@@ -2372,6 +3964,144 @@ var handlePayments = async (req, url, env, ctx) => {
2372
3964
  return null;
2373
3965
  };
2374
3966
 
3967
+ // src/worker-routes-gifts.ts
3968
+ var first3 = (value) => Array.isArray(value) ? value[0] : void 0;
3969
+ var rows2 = (value) => Array.isArray(value) ? value : [];
3970
+ var invalid = () => json({ error: "gift code is invalid or unavailable" }, 400);
3971
+ var userFor = async (req, env, ctx) => {
3972
+ const user = await ctx.verifyUser(req, env);
3973
+ return user?.email ? user : null;
3974
+ };
3975
+ var loadClaim = async (db, id) => first3((await db.query({ giftClaims: { $: { where: { id }, limit: 1 } } })).giftClaims);
3976
+ async function consumeRateLimit(db, chapterId, requester, now) {
3977
+ const bucket = await giftRequesterBucket(chapterId, requester);
3978
+ const id = `gift-rate:${chapterId}:${bucket}`;
3979
+ const current = first3((await db.query({ giftRedemptionAttempts: { $: { where: { id }, limit: 1 } } })).giftRedemptionAttempts);
3980
+ const within = Boolean(current && now - Number(current.windowStartedAt) < 15 * 6e4);
3981
+ const attemptCount = within ? Number(current?.attemptCount ?? 0) + 1 : 1;
3982
+ if (attemptCount > 10) return false;
3983
+ await db.transact([{ t: "update", ns: "giftRedemptionAttempts", id, attrs: {
3984
+ id,
3985
+ chapterId,
3986
+ bucket,
3987
+ attemptCount,
3988
+ windowStartedAt: within ? Number(current?.windowStartedAt) : now,
3989
+ updatedAt: now
3990
+ } }]);
3991
+ return true;
3992
+ }
3993
+ var handleGifts = async (req, url, env, ctx) => {
3994
+ if (ctx.chapter.mode !== "chapter" || !url.pathname.startsWith("/api/gifts")) return null;
3995
+ const db = ctx.makeDb(env);
3996
+ if (req.method === "GET" && url.pathname === "/api/gifts") {
3997
+ const user = await userFor(req, env, ctx);
3998
+ if (!user) return json({ error: "unauthorized" }, 401);
3999
+ const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
4000
+ const found = await db.query({ giftClaims: { $: { where: {
4001
+ purchaserEmailNormalized: normalizeAdmissionEmail(user.email),
4002
+ chapterId: ctx.chapter.id
4003
+ }, limit: 200 } } });
4004
+ const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
4005
+ return json({
4006
+ offers: publicGiftOffers(control2?.document.giftOffers, Date.now()),
4007
+ claims: buyerGiftClaims(rows2(found.giftClaims).sort((a, b) => Number(b.createdAt) - Number(a.createdAt)), Date.now()),
4008
+ refundPolicyText: typeof group?.refundPolicyText === "string" ? group.refundPolicyText : ""
4009
+ });
4010
+ }
4011
+ if (req.method === "POST" && url.pathname === "/api/gifts/checkout") {
4012
+ const user = await userFor(req, env, ctx);
4013
+ if (!user) return json({ error: "unauthorized" }, 401);
4014
+ let body;
4015
+ try {
4016
+ body = JSON.parse(await req.text());
4017
+ } catch {
4018
+ return json({ error: "invalid JSON body" }, 400);
4019
+ }
4020
+ const purchaseId = typeof body.purchaseId === "string" ? body.purchaseId : "";
4021
+ if (!/^[A-Za-z0-9_-]{16,128}$/.test(purchaseId)) return json({ error: "purchaseId is invalid" }, 400);
4022
+ const control2 = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
4023
+ const offer = findGiftOffer(control2?.document.giftOffers, typeof body.offerId === "string" ? body.offerId : "", Date.now());
4024
+ if (!offer) return json({ error: "gift offer is unavailable" }, 409);
4025
+ const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
4026
+ const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
4027
+ if (!group || !stripeSecret || typeof group.stripePublishableKey !== "string") return json({ error: "payments not configured" }, 503);
4028
+ const emailNormalized = normalizeAdmissionEmail(user.email);
4029
+ const applications = rows2((await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications);
4030
+ const app = applications.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
4031
+ try {
4032
+ return json(await startGiftIntent(db, {
4033
+ id: `gift-intent:${ctx.chapter.id}:${purchaseId}`,
4034
+ chapterId: ctx.chapter.id,
4035
+ environment: env.ODLA_ENV,
4036
+ offer,
4037
+ purchaserType: "member",
4038
+ purchaserEmail: user.email,
4039
+ purchaserEmailNormalized: emailNormalized,
4040
+ purchaserUserId: user.userId,
4041
+ ...typeof app?.stripeCustomerId === "string" ? { existingCustomerId: app.stripeCustomerId } : {},
4042
+ stripeSecret,
4043
+ publishableKey: group.stripePublishableKey,
4044
+ now: Date.now()
4045
+ }));
4046
+ } catch (error) {
4047
+ return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
4048
+ }
4049
+ }
4050
+ const rotateMatch = url.pathname.match(/^\/api\/gifts\/([^/]+)\/rotate$/);
4051
+ if (req.method === "POST" && rotateMatch) {
4052
+ let claimId = "";
4053
+ try {
4054
+ claimId = decodeURIComponent(rotateMatch[1]);
4055
+ } catch {
4056
+ return invalid();
4057
+ }
4058
+ if (!claimId.startsWith("gift-claim:")) return invalid();
4059
+ const claim = await loadClaim(db, claimId);
4060
+ if (!claim || claim.chapterId !== ctx.chapter.id || Number(claim.rotationCount ?? 0) >= 10) return invalid();
4061
+ const user = await userFor(req, env, ctx);
4062
+ let applicationId = "";
4063
+ if (!user) {
4064
+ try {
4065
+ applicationId = String(JSON.parse(await req.text()).applicationId ?? "");
4066
+ } catch {
4067
+ return invalid();
4068
+ }
4069
+ }
4070
+ const owns = user ? claim.purchaserEmailNormalized === normalizeAdmissionEmail(user.email) : claim.purchaserApplicationId === applicationId;
4071
+ if (!owns) return invalid();
4072
+ const rotated = await rotateGiftClaim(db, claim, Date.now());
4073
+ return rotated ? json(rotated) : invalid();
4074
+ }
4075
+ if (req.method === "POST" && url.pathname === "/api/gifts/redeem") {
4076
+ const user = await userFor(req, env, ctx);
4077
+ if (!user) return json({ error: "unauthorized" }, 401);
4078
+ const now = Date.now();
4079
+ if (!await consumeRateLimit(db, ctx.chapter.id, `${user.userId}:${req.headers.get("cf-connecting-ip") ?? "no-ip"}`, now)) {
4080
+ return json({ error: "too many attempts" }, 429);
4081
+ }
4082
+ let code = "";
4083
+ try {
4084
+ code = String(JSON.parse(await req.text()).code ?? "").trim();
4085
+ } catch {
4086
+ return invalid();
4087
+ }
4088
+ if (!/^gift1_[A-Za-z0-9_-]{43}$/.test(code)) return invalid();
4089
+ const codeHash = await hashGiftCode(ctx.chapter.id, code);
4090
+ const claim = first3((await db.query({ giftClaims: { $: { where: { codeHash }, limit: 1 } } })).giftClaims);
4091
+ if (!claim || claim.chapterId !== ctx.chapter.id) return invalid();
4092
+ if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) {
4093
+ await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
4094
+ attemptCount: Number(claim.attemptCount ?? 0) + 1,
4095
+ ...claim.status === "pending" ? { status: "expired" } : {}
4096
+ } }]).catch(() => void 0);
4097
+ return invalid();
4098
+ }
4099
+ const redeemed = await redeemGiftClaim(db, claim, { email: user.email, userId: user.userId }, now);
4100
+ return redeemed ? json({ ok: true, ...redeemed }) : invalid();
4101
+ }
4102
+ return null;
4103
+ };
4104
+
2375
4105
  // src/worker-routes-admin.ts
2376
4106
  import { initCalendar as initCalendar2 } from "@odla-ai/calendar";
2377
4107
 
@@ -2443,8 +4173,8 @@ async function upcomingEvents(env) {
2443
4173
  const res = await cal.availability.upcoming();
2444
4174
  return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
2445
4175
  }
2446
- function toReconcile(rows2) {
2447
- return rows2.map((m) => ({
4176
+ function toReconcile(rows4) {
4177
+ return rows4.map((m) => ({
2448
4178
  id: String(m.id),
2449
4179
  applicationId: String(m.applicationId),
2450
4180
  googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
@@ -2466,12 +4196,12 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
2466
4196
  const query = await db.query({
2467
4197
  meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
2468
4198
  });
2469
- let rows2 = Array.isArray(query.meetings) ? query.meetings : [];
2470
- if (Number.isFinite(from)) rows2 = rows2.filter((m) => Number(m.startAt ?? 0) >= from);
2471
- if (Number.isFinite(to)) rows2 = rows2.filter((m) => Number(m.startAt ?? 0) <= to);
4199
+ let rows4 = Array.isArray(query.meetings) ? query.meetings : [];
4200
+ if (Number.isFinite(from)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) >= from);
4201
+ if (Number.isFinite(to)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) <= to);
2472
4202
  let decisions = [];
2473
4203
  try {
2474
- decisions = reconcileMeetings(toReconcile(rows2), await upcomingEvents(env), Date.now());
4204
+ decisions = reconcileMeetings(toReconcile(rows4), await upcomingEvents(env), Date.now());
2475
4205
  } catch {
2476
4206
  }
2477
4207
  const patched = /* @__PURE__ */ new Map();
@@ -2498,69 +4228,10 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
2498
4228
  };
2499
4229
  const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
2500
4230
  const timezone = resolveScheduling(group?.schedulingJson).timezone;
2501
- const meetings = rows2.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
4231
+ const meetings = rows4.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
2502
4232
  return json({ meetings, adopted: decisions.length, timezone });
2503
4233
  };
2504
4234
 
2505
- // src/clerk-roles.ts
2506
- var CLERK_API = "https://api.clerk.com";
2507
- var DEFAULT_ROLE = "provisional";
2508
- var PAGE = 100;
2509
- function toRecord(u) {
2510
- if (typeof u.id !== "string") return null;
2511
- const pm = u.public_metadata ?? {};
2512
- const privateMetadata = u.private_metadata ?? {};
2513
- const rawProfile = privateMetadata.profile;
2514
- const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
2515
- const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
2516
- const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
2517
- const email = u.email_addresses?.[0]?.email_address;
2518
- return {
2519
- id: u.id,
2520
- email: typeof email === "string" ? email : void 0,
2521
- role,
2522
- publicMetadata: pm,
2523
- privateMetadata,
2524
- profile,
2525
- ...applicationId ? { applicationId } : {}
2526
- };
2527
- }
2528
- async function clerkGet(path, secretKey, fetchImpl) {
2529
- const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
2530
- if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
2531
- return res.json();
2532
- }
2533
- async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
2534
- const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
2535
- const user = Array.isArray(data) ? data[0] : void 0;
2536
- return user ? toRecord(user) : null;
2537
- }
2538
- async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
2539
- const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
2540
- return data ? toRecord(data) : null;
2541
- }
2542
- async function clerkListUsers(secretKey, fetchImpl = fetch) {
2543
- const out = [];
2544
- for (let offset = 0; ; offset += PAGE) {
2545
- const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
2546
- const page = Array.isArray(data) ? data : [];
2547
- for (const u of page) {
2548
- const record = toRecord(u);
2549
- if (record) out.push(record);
2550
- }
2551
- if (page.length < PAGE) break;
2552
- }
2553
- return out;
2554
- }
2555
- async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
2556
- const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
2557
- method: "PATCH",
2558
- headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
2559
- body: JSON.stringify({ public_metadata: { role } })
2560
- });
2561
- return res.ok;
2562
- }
2563
-
2564
4235
  // src/worker-routes-admin-people.ts
2565
4236
  async function adminGate(req, env, ctx) {
2566
4237
  const rawDb = ctx.makeDb(env);
@@ -2578,16 +4249,16 @@ var crmDeps2 = (db, ctx) => ({
2578
4249
  });
2579
4250
  var handleAdminCrmSync = async (req, url, env, ctx) => {
2580
4251
  if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
2581
- const gate8 = await adminGate(req, env, ctx);
2582
- if (gate8 instanceof Response) return gate8;
2583
- const result = await backfillCrm(crmDeps2(gate8.db, ctx));
4252
+ const gate9 = await adminGate(req, env, ctx);
4253
+ if (gate9 instanceof Response) return gate9;
4254
+ const result = await backfillCrm(crmDeps2(gate9.db, ctx));
2584
4255
  return json({ ok: true, ...result });
2585
4256
  };
2586
4257
  var handleAdminPeople = async (req, url, env, ctx) => {
2587
4258
  if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
2588
- const gate8 = await adminGate(req, env, ctx);
2589
- if (gate8 instanceof Response) return gate8;
2590
- const { db } = gate8;
4259
+ const gate9 = await adminGate(req, env, ctx);
4260
+ if (gate9 instanceof Response) return gate9;
4261
+ const { db } = gate9;
2591
4262
  const sk = await getVaultSecret(db, "clerk_secret_key");
2592
4263
  const [appsRes, usersRes, roleList] = await Promise.all([
2593
4264
  db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
@@ -2631,16 +4302,16 @@ var handleAdminPeople = async (req, url, env, ctx) => {
2631
4302
  });
2632
4303
  }
2633
4304
  }
2634
- const rows2 = [...people.values()].sort(
4305
+ const rows4 = [...people.values()].sort(
2635
4306
  (x, y) => (y.application?.createdAt ?? -1) - (x.application?.createdAt ?? -1)
2636
4307
  );
2637
- return json({ people: rows2 });
4308
+ return json({ people: rows4 });
2638
4309
  };
2639
4310
  var handleAdminPeopleAccess = async (req, url, env, ctx) => {
2640
4311
  if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
2641
- const gate8 = await adminGate(req, env, ctx);
2642
- if (gate8 instanceof Response) return gate8;
2643
- const { db } = gate8;
4312
+ const gate9 = await adminGate(req, env, ctx);
4313
+ if (gate9 instanceof Response) return gate9;
4314
+ const { db } = gate9;
2644
4315
  const targetId = url.searchParams.get("userId") ?? "";
2645
4316
  if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
2646
4317
  const sk = await getVaultSecret(db, "clerk_secret_key");
@@ -2651,9 +4322,9 @@ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
2651
4322
  };
2652
4323
  var handleAdminPeopleRole = async (req, url, env, ctx) => {
2653
4324
  if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
2654
- const gate8 = await adminGate(req, env, ctx);
2655
- if (gate8 instanceof Response) return gate8;
2656
- const { db, actor } = gate8;
4325
+ const gate9 = await adminGate(req, env, ctx);
4326
+ if (gate9 instanceof Response) return gate9;
4327
+ const { db, actor } = gate9;
2657
4328
  let body;
2658
4329
  try {
2659
4330
  body = await req.json();
@@ -2800,17 +4471,17 @@ function subAnnualCents(sub) {
2800
4471
  }
2801
4472
 
2802
4473
  // src/worker-routes-admin-dashboard.ts
2803
- async function gate(req, env, ctx) {
4474
+ async function gate2(req, env, ctx) {
2804
4475
  const rawDb = ctx.makeDb(env);
2805
4476
  const u = await ctx.verifyUser(req, env);
2806
4477
  if (!u) return json({ error: "unauthorized" }, 401);
2807
4478
  if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
2808
4479
  return rawDb;
2809
4480
  }
2810
- var rows = (v) => Array.isArray(v) ? v : [];
4481
+ var rows3 = (v) => Array.isArray(v) ? v : [];
2811
4482
  var handleAdminDashboard = async (req, url, env, ctx) => {
2812
4483
  if (req.method !== "GET" || url.pathname !== "/api/admin/dashboard") return null;
2813
- const got = await gate(req, env, ctx);
4484
+ const got = await gate2(req, env, ctx);
2814
4485
  if (got instanceof Response) return got;
2815
4486
  const db = got;
2816
4487
  const now = Date.now();
@@ -2822,7 +4493,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2822
4493
  db.query({ crm_record: { $: { where: { type: "person" }, limit: 1e3 } } }),
2823
4494
  db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })
2824
4495
  ]);
2825
- const apps = rows(appsRes.applications);
4496
+ const apps = rows3(appsRes.applications);
2826
4497
  const applications = {
2827
4498
  total: apps.length,
2828
4499
  last7: apps.filter((a) => a.createdAt >= d7).length,
@@ -2834,7 +4505,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2834
4505
  pipeline[s] = 0;
2835
4506
  pipelineDelta[s] = 0;
2836
4507
  }
2837
- for (const r of rows(recsRes.crm_record)) {
4508
+ for (const r of rows3(recsRes.crm_record)) {
2838
4509
  const s = r.stage;
2839
4510
  if (s in pipeline) {
2840
4511
  pipeline[s] = (pipeline[s] ?? 0) + 1;
@@ -2842,7 +4513,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2842
4513
  if (Number.isFinite(sc) && sc >= d7) pipelineDelta[s] = (pipelineDelta[s] ?? 0) + 1;
2843
4514
  }
2844
4515
  }
2845
- const meetings = rows(meetingsRes.meetings);
4516
+ const meetings = rows3(meetingsRes.meetings);
2846
4517
  const appById = new Map(apps.map((a) => [a.id, a]));
2847
4518
  const upcoming = meetings.filter((m) => m.startAt >= now - 36e5);
2848
4519
  const calls = { upcoming: upcoming.length, needsAttention: meetings.filter((m) => m.drift && m.drift !== "none").length };
@@ -2867,7 +4538,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2867
4538
  if (sk) {
2868
4539
  const subsRes = await stripeCall(sk, "GET", "/v1/subscriptions", { limit: 100, status: "all" });
2869
4540
  if (subsRes.ok) {
2870
- const subs = rows(subsRes.body.data);
4541
+ const subs = rows3(subsRes.body.data);
2871
4542
  const subMs = (s) => (s.created ?? 0) * 1e3;
2872
4543
  membersSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: 1 })), now);
2873
4544
  revenueSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: subAnnualCents(s) })), now);
@@ -2887,7 +4558,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2887
4558
  };
2888
4559
  var handleAdminBilling = async (req, url, env, ctx) => {
2889
4560
  if (req.method !== "GET" || url.pathname !== "/api/admin/billing") return null;
2890
- const got = await gate(req, env, ctx);
4561
+ const got = await gate2(req, env, ctx);
2891
4562
  if (got instanceof Response) return got;
2892
4563
  const db = got;
2893
4564
  const sk = await getVaultSecret(db, "stripe_secret_key");
@@ -2899,9 +4570,9 @@ var handleAdminBilling = async (req, url, env, ctx) => {
2899
4570
  ]);
2900
4571
  const testMode = String(groupRes.groups?.[0]?.stripePublishableKey ?? "").startsWith("pk_test");
2901
4572
  if (!subsRes.ok) return json({ error: "billing lookup failed upstream" }, 502);
2902
- const subById = new Map(rows(subsRes.body.data).map((s) => [s.id, s]));
4573
+ const subById = new Map(rows3(subsRes.body.data).map((s) => [s.id, s]));
2903
4574
  const billingRows = [];
2904
- for (const a of rows(appsRes.applications)) {
4575
+ for (const a of rows3(appsRes.applications)) {
2905
4576
  if (!a.stripeCustomerId && !a.stripeSubscriptionId) continue;
2906
4577
  const sub = a.stripeSubscriptionId ? subById.get(a.stripeSubscriptionId) : void 0;
2907
4578
  const items = sub?.items?.data ?? [];
@@ -2945,7 +4616,7 @@ var handleAdminBilling = async (req, url, env, ctx) => {
2945
4616
 
2946
4617
  // src/worker-routes-admin-lifecycle.ts
2947
4618
  import { computeBookableSlots as computeBookableSlots2, initCalendar as initCalendar3 } from "@odla-ai/calendar";
2948
- async function gate2(req, env, ctx) {
4619
+ async function gate3(req, env, ctx) {
2949
4620
  const rawDb = ctx.makeDb(env);
2950
4621
  const u = await ctx.verifyUser(req, env);
2951
4622
  if (!u) return json({ error: "unauthorized" }, 401);
@@ -2971,7 +4642,7 @@ async function loadGroup(db, id) {
2971
4642
  var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
2972
4643
  const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/reschedule$/);
2973
4644
  if (req.method !== "POST" || !m) return null;
2974
- const got = await gate2(req, env, ctx);
4645
+ const got = await gate3(req, env, ctx);
2975
4646
  if (got instanceof Response) return got;
2976
4647
  const db = got;
2977
4648
  const body = await readJson(req);
@@ -3012,7 +4683,7 @@ var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
3012
4683
  var handleAdminMeetingCancel = async (req, url, env, ctx) => {
3013
4684
  const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/cancel$/);
3014
4685
  if (req.method !== "POST" || !m) return null;
3015
- const got = await gate2(req, env, ctx);
4686
+ const got = await gate3(req, env, ctx);
3016
4687
  if (got instanceof Response) return got;
3017
4688
  const db = got;
3018
4689
  const { meetings } = await db.query({ meetings: { $: { where: { id: m[1] }, limit: 1 } } });
@@ -3035,43 +4706,20 @@ var handleAdminMeetingCancel = async (req, url, env, ctx) => {
3035
4706
  var handleAdminApprove = async (req, url, env, ctx) => {
3036
4707
  const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/approve$/);
3037
4708
  if (req.method !== "POST" || !m) return null;
3038
- const got = await gate2(req, env, ctx);
4709
+ const got = await gate3(req, env, ctx);
3039
4710
  if (got instanceof Response) return got;
3040
4711
  const db = got;
3041
4712
  const id = m[1];
3042
4713
  const app = await loadApp(db, id);
3043
4714
  if (!app) return json({ error: "not found" }, 404);
3044
- if (!canApprove(String(app.status), ctx.chapter.pipeline)) return json({ error: `cannot approve from status "${String(app.status)}"` }, 409);
3045
- const target = "approved";
3046
- await db.transact([{ t: "update", ns: "applications", id, attrs: { status: target } }]);
3047
- await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
3048
- const { promoteTo, send } = ctx.chapter.operations.onApprove;
3049
- let rolePromoted = false;
3050
- const sk = await getVaultSecret(db, "clerk_secret_key");
3051
- if (promoteTo !== false && sk) {
3052
- let userId = app.clerkUserId || null;
3053
- if (!userId) {
3054
- const found = await clerkGetUserByEmail(sk, String(app.email));
3055
- userId = found?.id ?? null;
3056
- if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
3057
- }
3058
- if (userId) rolePromoted = await clerkSetRole(sk, userId, promoteTo);
3059
- }
3060
- let emailLogged = false;
3061
- const group = await loadGroup(db, String(app.groupId ?? ctx.chapter.id));
3062
- if (send !== false && group) {
3063
- const res = await sendTemplated(
3064
- { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
3065
- { group: emailGroupFrom(group), template: send, to: String(app.email), vars: { firstName: String(app.firstName ?? ""), membersUrl: `${url.origin}/members/` }, applicationId: id, dedupeKey: `approve:${id}` }
3066
- );
3067
- emailLogged = res.sent;
3068
- }
3069
- return json({ ok: true, status: target, rolePromoted, emailLogged });
4715
+ const result = await approveApplication({ db, env, ctx, app, origin: url.origin, reason: "manual" });
4716
+ if (!result.ok) return json({ error: result.error }, result.status);
4717
+ return json({ ok: true, status: "approved", rolePromoted: result.rolePromoted, emailLogged: result.emailLogged });
3070
4718
  };
3071
4719
  var handleAdminRefund = async (req, url, env, ctx) => {
3072
4720
  const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/refund$/);
3073
4721
  if (req.method !== "POST" || !m) return null;
3074
- const got = await gate2(req, env, ctx);
4722
+ const got = await gate3(req, env, ctx);
3075
4723
  if (got instanceof Response) return got;
3076
4724
  const db = got;
3077
4725
  const app = await loadApp(db, m[1]);
@@ -3103,7 +4751,7 @@ var handleAdminRefund = async (req, url, env, ctx) => {
3103
4751
  var handleAdminApplicationPatch = async (req, url, env, ctx) => {
3104
4752
  const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)$/);
3105
4753
  if (req.method !== "PATCH" || !m) return null;
3106
- const got = await gate2(req, env, ctx);
4754
+ const got = await gate3(req, env, ctx);
3107
4755
  if (got instanceof Response) return got;
3108
4756
  const db = got;
3109
4757
  const id = m[1];
@@ -3131,7 +4779,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
3131
4779
  };
3132
4780
 
3133
4781
  // src/worker-routes-admin-comms.ts
3134
- async function gate3(req, env, ctx) {
4782
+ async function gate4(req, env, ctx) {
3135
4783
  const rawDb = ctx.makeDb(env);
3136
4784
  const u = await ctx.verifyUser(req, env);
3137
4785
  if (!u) return json({ error: "unauthorized" }, 401);
@@ -3153,7 +4801,7 @@ var notifyDeps = (db, env) => ({
3153
4801
  });
3154
4802
  var handleAdminGroupEmail = async (req, url, env, ctx) => {
3155
4803
  if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
3156
- const got = await gate3(req, env, ctx);
4804
+ const got = await gate4(req, env, ctx);
3157
4805
  if (got instanceof Response) return got;
3158
4806
  const db = got;
3159
4807
  const group = await loadGroup2(db, ctx.chapter.id);
@@ -3194,11 +4842,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
3194
4842
  for (const key of EMAIL_TEMPLATE_NAMES) {
3195
4843
  const t = templates[key];
3196
4844
  const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
3197
- const text2 = typeof t?.text === "string" ? t.text : "";
3198
- if (!subject || !text2.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
4845
+ const text3 = typeof t?.text === "string" ? t.text : "";
4846
+ if (!subject || !text3.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
3199
4847
  if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key}" subject must be a single line under 200 characters` }, 400);
3200
- if (text2.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
3201
- clean[key] = { subject, text: text2, enabled: t?.enabled !== false };
4848
+ if (text3.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
4849
+ clean[key] = { subject, text: text3, enabled: t?.enabled !== false };
3202
4850
  }
3203
4851
  const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3204
4852
  const notificationEmail = str2(body.notificationEmail).trim();
@@ -3215,7 +4863,7 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
3215
4863
  };
3216
4864
  var handleAdminEmailLog = async (req, url, env, ctx) => {
3217
4865
  if (req.method !== "GET" || url.pathname !== "/api/admin/email/log") return null;
3218
- const got = await gate3(req, env, ctx);
4866
+ const got = await gate4(req, env, ctx);
3219
4867
  if (got instanceof Response) return got;
3220
4868
  const { emailLog } = await got.query({ emailLog: { $: { order: { sentAt: "desc" }, limit: 50 } } });
3221
4869
  const sends = (emailLog ?? []).map((r) => ({
@@ -3232,7 +4880,7 @@ var handleAdminEmailLog = async (req, url, env, ctx) => {
3232
4880
  };
3233
4881
  var handleAdminEmailTest = async (req, url, env, ctx) => {
3234
4882
  if (req.method !== "POST" || url.pathname !== "/api/admin/email/test") return null;
3235
- const got = await gate3(req, env, ctx);
4883
+ const got = await gate4(req, env, ctx);
3236
4884
  if (got instanceof Response) return got;
3237
4885
  const db = got;
3238
4886
  let body;
@@ -3260,7 +4908,7 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
3260
4908
  var handleAdminComms = async (req, url, env, ctx) => {
3261
4909
  const m = url.pathname.match(/^\/api\/admin\/people\/([0-9a-fA-F-]+)\/comms$/);
3262
4910
  if (req.method !== "GET" || !m) return null;
3263
- const got = await gate3(req, env, ctx);
4911
+ const got = await gate4(req, env, ctx);
3264
4912
  if (got instanceof Response) return got;
3265
4913
  const db = got;
3266
4914
  const appId = m[1];
@@ -3293,7 +4941,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
3293
4941
 
3294
4942
  // src/worker-routes-network.ts
3295
4943
  import { getRecord, typeSummary } from "@odla-ai/crm";
3296
- import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest3 } from "@odla-ai/db";
4944
+ import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest5 } from "@odla-ai/db";
3297
4945
 
3298
4946
  // src/worker-network-fetch.ts
3299
4947
  function targetFetcher(env, target) {
@@ -3312,7 +4960,7 @@ function fetchNetworkTarget(env, target, destination, init) {
3312
4960
  // src/worker-network-push.ts
3313
4961
  import { recordDeliveryAttempt } from "@odla-ai/crm";
3314
4962
  import { signFederatedRequest } from "@odla-ai/db";
3315
- async function pushNetworkRecord(db, env, ctx, target, record) {
4963
+ async function pushNetworkRecord(db, env, ctx, target, record3) {
3316
4964
  const secret = await getVaultSecret(db, target.secretName);
3317
4965
  const crmDeps3 = {
3318
4966
  crm: ctx.chapter.crm,
@@ -3323,7 +4971,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3323
4971
  if (!secret) {
3324
4972
  const error = `vault secret "${target.secretName}" is missing`;
3325
4973
  await recordDeliveryAttempt(crmDeps3, {
3326
- recordId: record.id,
4974
+ recordId: record3.id,
3327
4975
  targetId: target.id,
3328
4976
  status: "failed",
3329
4977
  payloadVersion: 2,
@@ -3333,7 +4981,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3333
4981
  }
3334
4982
  let payload;
3335
4983
  try {
3336
- payload = sharedRecordFromCrm(ctx.chapter.crm, record, target, ctx.chapter.id);
4984
+ payload = sharedRecordFromCrm(ctx.chapter.crm, record3, target, ctx.chapter.id);
3337
4985
  } catch (err) {
3338
4986
  return {
3339
4987
  id: target.id,
@@ -3362,7 +5010,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3362
5010
  if (!res.ok) {
3363
5011
  const error = responseBody.error ?? "follower rejected the record";
3364
5012
  await recordDeliveryAttempt(crmDeps3, {
3365
- recordId: record.id,
5013
+ recordId: record3.id,
3366
5014
  targetId: target.id,
3367
5015
  status: "failed",
3368
5016
  payloadVersion: 2,
@@ -3377,7 +5025,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3377
5025
  };
3378
5026
  }
3379
5027
  await recordDeliveryAttempt(crmDeps3, {
3380
- recordId: record.id,
5028
+ recordId: record3.id,
3381
5029
  targetId: target.id,
3382
5030
  status: "delivered",
3383
5031
  payloadVersion: 2,
@@ -3393,7 +5041,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3393
5041
  } catch (err) {
3394
5042
  const error = err instanceof Error ? err.message : "delivery failed";
3395
5043
  await recordDeliveryAttempt(crmDeps3, {
3396
- recordId: record.id,
5044
+ recordId: record3.id,
3397
5045
  targetId: target.id,
3398
5046
  status: "failed",
3399
5047
  payloadVersion: 2,
@@ -3404,7 +5052,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3404
5052
  }
3405
5053
 
3406
5054
  // src/worker-routes-network.ts
3407
- async function gate4(req, env, ctx) {
5055
+ async function gate5(req, env, ctx) {
3408
5056
  const db = ctx.makeDb(env);
3409
5057
  const user = await ctx.verifyUser(req, env);
3410
5058
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3413,7 +5061,7 @@ async function gate4(req, env, ctx) {
3413
5061
  }
3414
5062
  var handleAdminNetworkTargets = async (req, url, env, ctx) => {
3415
5063
  if (req.method !== "GET" || url.pathname !== "/api/admin/network/targets") return null;
3416
- const got = await gate4(req, env, ctx);
5064
+ const got = await gate5(req, env, ctx);
3417
5065
  if ("response" in got) return got.response;
3418
5066
  return json({
3419
5067
  targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
@@ -3430,7 +5078,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
3430
5078
  const db = ctx.makeDb(env);
3431
5079
  const secret = await getVaultSecret(db, "network_share_secret");
3432
5080
  if (!secret) return json({ error: "unauthorized" }, 401);
3433
- const verified = await verifyFederatedRequest3(req, { secret });
5081
+ const verified = await verifyFederatedRequest5(req, { secret });
3434
5082
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
3435
5083
  const types = await Promise.all(
3436
5084
  Object.keys(ctx.chapter.crm.config.types).map((type) => typeSummary({ crm: ctx.chapter.crm, db }, type))
@@ -3489,7 +5137,7 @@ async function snapshotOne(db, env, ctx, target) {
3489
5137
  }
3490
5138
  var handleAdminNetworkRollup = async (req, url, env, ctx) => {
3491
5139
  if (req.method !== "GET" || url.pathname !== "/api/admin/network/rollup") return null;
3492
- const got = await gate4(req, env, ctx);
5140
+ const got = await gate5(req, env, ctx);
3493
5141
  if ("response" in got) return got.response;
3494
5142
  const targets = await Promise.all(
3495
5143
  ctx.chapter.network.targets.map((target) => snapshotOne(got.db, env, ctx, target))
@@ -3516,7 +5164,7 @@ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
3516
5164
  };
3517
5165
  var handleAdminNetworkPush = async (req, url, env, ctx) => {
3518
5166
  if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
3519
- const got = await gate4(req, env, ctx);
5167
+ const got = await gate5(req, env, ctx);
3520
5168
  if ("response" in got) return got.response;
3521
5169
  let body;
3522
5170
  try {
@@ -3531,22 +5179,22 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
3531
5179
  if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
3532
5180
  const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
3533
5181
  if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
3534
- const record = await getRecord({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
3535
- if (!record) return json({ error: "record not found" }, 404);
5182
+ const record3 = await getRecord({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
5183
+ if (!record3) return json({ error: "record not found" }, 404);
3536
5184
  const results = await Promise.all(
3537
- targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record))
5185
+ targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record3))
3538
5186
  );
3539
5187
  return json({ ok: results.every((result) => result.ok), results });
3540
5188
  };
3541
5189
 
3542
5190
  // src/worker-routes-network-records.ts
3543
5191
  import { listRecords } from "@odla-ai/crm";
3544
- import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest4 } from "@odla-ai/db";
5192
+ import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest6 } from "@odla-ai/db";
3545
5193
  var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
3546
5194
  var MAX_PAGE = 50;
3547
5195
  var MAX_OFFSET = 1e4;
3548
5196
  var MAX_SEARCH = 200;
3549
- async function gate5(req, env, ctx) {
5197
+ async function gate6(req, env, ctx) {
3550
5198
  const db = ctx.makeDb(env);
3551
5199
  const user = await ctx.verifyUser(req, env);
3552
5200
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3559,18 +5207,18 @@ function integer(value, fallback, min, max) {
3559
5207
  const parsed = Number(value);
3560
5208
  return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
3561
5209
  }
3562
- function projectRecord(record, allowed) {
5210
+ function projectRecord(record3, allowed2) {
3563
5211
  const fields = Object.fromEntries(
3564
- allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(record.fields, name) ? [[name, record.fields[name]]] : [])
5212
+ allowed2.flatMap((name) => Object.prototype.hasOwnProperty.call(record3.fields, name) ? [[name, record3.fields[name]]] : [])
3565
5213
  );
3566
5214
  return {
3567
- id: record.id,
3568
- type: record.type,
3569
- name: record.name,
3570
- ...record.stage ? { stage: record.stage } : {},
5215
+ id: record3.id,
5216
+ type: record3.type,
5217
+ name: record3.name,
5218
+ ...record3.stage ? { stage: record3.stage } : {},
3571
5219
  fields,
3572
- createdAt: record.createdAt,
3573
- updatedAt: record.updatedAt
5220
+ createdAt: record3.createdAt,
5221
+ updatedAt: record3.updatedAt
3574
5222
  };
3575
5223
  }
3576
5224
  var handleNetworkRecords = async (req, url, env, ctx) => {
@@ -3579,15 +5227,15 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
3579
5227
  const secret = await getVaultSecret(db, "network_share_secret");
3580
5228
  const readers = ctx.chapter.network.readers;
3581
5229
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
3582
- const verified = await verifyFederatedRequest4(req, {
5230
+ const verified = await verifyFederatedRequest6(req, {
3583
5231
  secret,
3584
5232
  senders: readers.map((reader2) => reader2.id)
3585
5233
  });
3586
5234
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
3587
5235
  const reader = readers.find((candidate) => candidate.id === verified.sender);
3588
5236
  const type = url.searchParams.get("type") ?? "";
3589
- const allowed = reader?.fields[type];
3590
- if (!IDENTIFIER.test(type) || !allowed) return json({ error: "record type is not shared with this reader" }, 403);
5237
+ const allowed2 = reader?.fields[type];
5238
+ if (!IDENTIFIER.test(type) || !allowed2) return json({ error: "record type is not shared with this reader" }, 403);
3591
5239
  const limit = integer(url.searchParams.get("limit"), 25, 1, MAX_PAGE);
3592
5240
  const offset = integer(url.searchParams.get("offset"), 0, 0, MAX_OFFSET);
3593
5241
  const search = (url.searchParams.get("search") ?? "").trim();
@@ -3608,7 +5256,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
3608
5256
  version: 1,
3609
5257
  site: { id: ctx.chapter.id, name: ctx.chapter.name },
3610
5258
  type,
3611
- records: page.records.map((record) => projectRecord(record, allowed)),
5259
+ records: page.records.map((record3) => projectRecord(record3, allowed2)),
3612
5260
  total: page.total,
3613
5261
  limit: page.limit,
3614
5262
  offset: page.offset
@@ -3649,7 +5297,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
3649
5297
  }
3650
5298
  var handleAdminNetworkRecords = async (req, url, env, ctx) => {
3651
5299
  if (req.method !== "GET" || url.pathname !== "/api/admin/network/records") return null;
3652
- const got = await gate5(req, env, ctx);
5300
+ const got = await gate6(req, env, ctx);
3653
5301
  if ("response" in got) return got.response;
3654
5302
  const targetId = url.searchParams.get("targetId") ?? "";
3655
5303
  const type = url.searchParams.get("type") ?? "";
@@ -3676,8 +5324,8 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
3676
5324
  // src/worker-routes-network-notes.ts
3677
5325
  var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
3678
5326
  var RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3679
- var MAX_NOTE = 4e3;
3680
- async function gate6(req, env, ctx) {
5327
+ var MAX_NOTE2 = 4e3;
5328
+ async function gate7(req, env, ctx) {
3681
5329
  const db = ctx.makeDb(env);
3682
5330
  const user = await ctx.verifyUser(req, env);
3683
5331
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3711,7 +5359,7 @@ function asNote(row) {
3711
5359
  }
3712
5360
  var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3713
5361
  if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
3714
- const got = await gate6(req, env, ctx);
5362
+ const got = await gate7(req, env, ctx);
3715
5363
  if ("response" in got) return got.response;
3716
5364
  const identity = noteIdentity(url, ctx);
3717
5365
  if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
@@ -3728,7 +5376,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3728
5376
  return json({ notes: (result.networkNotes ?? []).map((row) => asNote(row)) });
3729
5377
  }
3730
5378
  const contentLength = Number(req.headers.get("content-length") ?? 0);
3731
- if (contentLength > MAX_NOTE * 2) return json({ error: "note is too large" }, 413);
5379
+ if (contentLength > MAX_NOTE2 * 2) return json({ error: "note is too large" }, 413);
3732
5380
  let input;
3733
5381
  try {
3734
5382
  input = await req.json();
@@ -3736,7 +5384,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3736
5384
  return json({ error: "invalid JSON body" }, 400);
3737
5385
  }
3738
5386
  const body = typeof input.body === "string" ? input.body.trim() : "";
3739
- if (!body || body.length > MAX_NOTE) return json({ error: `note must be 1\u2013${MAX_NOTE} characters` }, 400);
5387
+ if (!body || body.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
3740
5388
  const id = crypto.randomUUID();
3741
5389
  const note = {
3742
5390
  id,
@@ -3763,13 +5411,13 @@ import {
3763
5411
  CRM_NS,
3764
5412
  getRecord as getRecord2
3765
5413
  } from "@odla-ai/crm";
3766
- import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest5 } from "@odla-ai/db";
5414
+ import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest7 } from "@odla-ai/db";
3767
5415
  var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
3768
5416
  var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3769
5417
  var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
3770
- var MAX_NOTE2 = 4e3;
5418
+ var MAX_NOTE3 = 4e3;
3771
5419
  var NETWORK_META = "chapterNetwork";
3772
- async function gate7(req, env, ctx) {
5420
+ async function gate8(req, env, ctx) {
3773
5421
  const db = ctx.makeDb(env);
3774
5422
  const user = await ctx.verifyUser(req, env);
3775
5423
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3815,14 +5463,14 @@ function sourceLabel(sourceId) {
3815
5463
  }
3816
5464
  var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3817
5465
  if (url.pathname !== "/api/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
3818
- if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_NOTE2 * 2) {
5466
+ if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_NOTE3 * 2) {
3819
5467
  return json({ error: "note is too large" }, 413);
3820
5468
  }
3821
5469
  const db = ctx.makeDb(env);
3822
5470
  const secret = await getVaultSecret(db, "network_share_secret");
3823
5471
  const readers = ctx.chapter.network.readers;
3824
5472
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
3825
- const verified = await verifyFederatedRequest5(req, {
5473
+ const verified = await verifyFederatedRequest7(req, {
3826
5474
  secret,
3827
5475
  senders: readers.map((reader2) => reader2.id)
3828
5476
  });
@@ -3833,8 +5481,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3833
5481
  if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
3834
5482
  return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
3835
5483
  }
3836
- const record = await getRecord2({ crm: ctx.chapter.crm, db }, recordId);
3837
- if (!record || record.type !== recordType) return json({ error: "record not found" }, 404);
5484
+ const record3 = await getRecord2({ crm: ctx.chapter.crm, db }, recordId);
5485
+ if (!record3 || record3.type !== recordType) return json({ error: "record not found" }, 404);
3838
5486
  if (req.method === "GET") {
3839
5487
  return json({
3840
5488
  version: 1,
@@ -3850,8 +5498,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3850
5498
  }
3851
5499
  const body = typeof input.body === "string" ? input.body.trim() : "";
3852
5500
  const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
3853
- if (!body || body.length > MAX_NOTE2 || !MUTATION_ID.test(mutationId)) {
3854
- return json({ error: `body must be 1\u2013${MAX_NOTE2} characters and mutationId must be valid` }, 400);
5501
+ if (!body || body.length > MAX_NOTE3 || !MUTATION_ID.test(mutationId)) {
5502
+ return json({ error: `body must be 1\u2013${MAX_NOTE3} characters and mutationId must be valid` }, 400);
3855
5503
  }
3856
5504
  const createdAt = Date.now();
3857
5505
  const id = `network:${verified.sender}:${mutationId}`;
@@ -3893,7 +5541,7 @@ function targetFor2(ctx, id) {
3893
5541
  }
3894
5542
  var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
3895
5543
  if (url.pathname !== "/api/admin/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
3896
- const got = await gate7(req, env, ctx);
5544
+ const got = await gate8(req, env, ctx);
3897
5545
  if ("response" in got) return got.response;
3898
5546
  const targetId = url.searchParams.get("targetId") ?? "";
3899
5547
  const recordType = url.searchParams.get("type") ?? "";
@@ -3911,7 +5559,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
3911
5559
  return json({ error: "invalid JSON body" }, 400);
3912
5560
  }
3913
5561
  const note = typeof input.body === "string" ? input.body.trim() : "";
3914
- if (!note || note.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
5562
+ if (!note || note.length > MAX_NOTE3) return json({ error: `note must be 1\u2013${MAX_NOTE3} characters` }, 400);
3915
5563
  body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
3916
5564
  }
3917
5565
  const secret = await getVaultSecret(got.db, target.secretName);
@@ -3988,15 +5636,15 @@ var handleFormation = async (req, url, env, ctx) => {
3988
5636
  if (submissionId && !SUBMISSION.test(submissionId)) {
3989
5637
  return json({ error: "submissionId must be an 8\u2013128 character base64url value" }, 400);
3990
5638
  }
3991
- const allowed = /* @__PURE__ */ new Set([...formation.required, ...formation.optional]);
3992
- const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !allowed.has(field));
5639
+ const allowed2 = /* @__PURE__ */ new Set([...formation.required, ...formation.optional]);
5640
+ const unknown = Object.keys(body).filter((field) => field !== "submissionId" && !allowed2.has(field));
3993
5641
  if (unknown.length) return json({ error: `field "${unknown[0]}" is not accepted` }, 400);
3994
5642
  for (const field of formation.required) {
3995
5643
  const value = body[field];
3996
5644
  if (typeof value !== "string" || value.trim() === "") return json({ error: `${field} is required` }, 400);
3997
5645
  }
3998
5646
  const input = {};
3999
- for (const field of allowed) {
5647
+ for (const field of allowed2) {
4000
5648
  const value = body[field];
4001
5649
  if (value === void 0) continue;
4002
5650
  const cap = formation.maxLen[field] ?? formation.defaultMaxLen;
@@ -4064,6 +5712,56 @@ async function deliverSignupControl(options) {
4064
5712
  return result;
4065
5713
  }
4066
5714
 
5715
+ // src/member-content-delivery.ts
5716
+ import { signFederatedRequest as signFederatedRequest6 } from "@odla-ai/db";
5717
+ async function deliverMemberContent(options) {
5718
+ const destination = new URL(MEMBER_CONTENT_CONTROL_PATH, options.targetUrl);
5719
+ const body = JSON.stringify(options.envelope);
5720
+ const signed = await signFederatedRequest6({
5721
+ secret: options.secret,
5722
+ sender: options.sender,
5723
+ method: "POST",
5724
+ url: destination,
5725
+ body
5726
+ });
5727
+ const response = await (options.fetch ?? globalThis.fetch)(destination, {
5728
+ method: "POST",
5729
+ headers: { ...signed, "content-type": "application/json" },
5730
+ body
5731
+ });
5732
+ const result = await response.json();
5733
+ if (!response.ok || !("ok" in result) || result.ok !== true) {
5734
+ const failure = result;
5735
+ throw new Error(`member content rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
5736
+ }
5737
+ return result;
5738
+ }
5739
+
5740
+ // src/admission-grants-delivery.ts
5741
+ import { signFederatedRequest as signFederatedRequest7 } from "@odla-ai/db";
5742
+ async function deliverAdmissionGrants(options) {
5743
+ const destination = new URL(ADMISSION_GRANTS_PATH, options.targetUrl);
5744
+ const body = JSON.stringify(options.envelope);
5745
+ const signed = await signFederatedRequest7({
5746
+ secret: options.secret,
5747
+ sender: options.sender,
5748
+ method: "POST",
5749
+ url: destination,
5750
+ body
5751
+ });
5752
+ const response = await (options.fetch ?? globalThis.fetch)(destination, {
5753
+ method: "POST",
5754
+ headers: { ...signed, "content-type": "application/json" },
5755
+ body
5756
+ });
5757
+ const result = await response.json();
5758
+ if (!response.ok || !("ok" in result) || result.ok !== true) {
5759
+ const failure = result;
5760
+ throw new Error(`admission grants rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
5761
+ }
5762
+ return result;
5763
+ }
5764
+
4067
5765
  // src/signup-control-theme.ts
4068
5766
  var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
4069
5767
  var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
@@ -4203,11 +5901,15 @@ var BUILTIN_ROUTES = [
4203
5901
  handleCrm,
4204
5902
  handleNetworkShared,
4205
5903
  handleSignupControl,
5904
+ handleMemberContentControl,
5905
+ handleAdmissionGrants,
4206
5906
  handleNetworkSnapshot,
4207
5907
  handleNetworkRecords,
4208
5908
  handleNetworkSharedNotes,
4209
5909
  handleFormation,
4210
5910
  handleMember,
5911
+ handleMemberContent,
5912
+ handleGifts,
4211
5913
  handleSchedule,
4212
5914
  handlePayments,
4213
5915
  handleAdminMeetings,
@@ -4217,10 +5919,12 @@ var BUILTIN_ROUTES = [
4217
5919
  handleAdminPeopleAccess,
4218
5920
  handleAdminPeopleRole,
4219
5921
  handleAdminCrmSync,
5922
+ handleAdminAdmissionGrants,
4220
5923
  handleAdminClerkPrivateProfileSync,
4221
5924
  // Aggregation
4222
5925
  handleAdminDashboard,
4223
5926
  handleAdminBilling,
5927
+ handleAdminMemberContent,
4224
5928
  // Lifecycle actions
4225
5929
  handleAdminMeetingReschedule,
4226
5930
  handleAdminMeetingCancel,
@@ -4261,6 +5965,8 @@ function chapterWorker(options) {
4261
5965
  export {
4262
5966
  chapterWorker,
4263
5967
  createWorkerContext,
5968
+ deliverAdmissionGrants,
5969
+ deliverMemberContent,
4264
5970
  deliverSignupControl,
4265
5971
  renderActiveSignupControlHtml,
4266
5972
  withActiveSignupControlTheme