@odla-ai/chapter 0.34.0 → 0.36.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 record2 = value;
183
+ const entries = Object.keys(record2).sort().flatMap((key) => {
184
+ const item = record2[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;
@@ -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 allowed = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
735
+ for (const key of Object.keys(item)) if (!allowed.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 record2 = toRecord(u);
833
+ if (record2) out.push(record2);
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(record2, fallbackSourceId = "legacy-source") {
896
+ if ("input" in record2 && record2.version === 2) {
897
+ return {
898
+ version: 2,
899
+ sourceId: record2.source.siteId,
900
+ sourceRecordId: record2.source.recordId,
901
+ type: record2.type,
902
+ input: record2.input
903
+ };
904
+ }
905
+ if ("input" in record2) {
906
+ return {
907
+ version: 1,
908
+ sourceId: fallbackSourceId,
909
+ sourceRecordId: record2.hubRecordId,
910
+ type: record2.type,
911
+ input: record2.input
912
+ };
913
+ }
914
+ if ("type" in record2 && record2.type === "company") {
915
+ const input = { name: record2.name };
916
+ for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
917
+ if (record2[key]) input[key] = record2[key];
918
+ }
919
+ return {
920
+ version: 1,
921
+ sourceId: fallbackSourceId,
922
+ sourceRecordId: record2.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: record2.hubRecordId,
931
+ type: "person",
932
+ input: sharedPersonInput(record2)
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, record2, target, sourceId) {
936
+ if (target.fields && !target.fields[record2.type]) {
937
+ throw new Error(`${target.name} does not accept "${record2.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(record2.type);
940
+ const fields = target.fields?.[record2.type] ?? DEFAULT_SHARE_FIELDS[record2.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 "${record2.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 = record2.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] = record2.name;
951
+ return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record2.id }, type: record2.type, input } : { version: 1, type: record2.type, hubRecordId: record2.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, record2, 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
+ record2.sourceId,
969
+ record2.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(record2.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 record2.input[emailField] === "string") {
981
+ const primaryEmail = record2.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: record2.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 = record2.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: record2.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 = record2.input[nameField];
997
+ if (record2.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: record2.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 record2 = normalizeSharedRecord(shared, options.sourceId);
1007
+ if (!record2.type.trim() || !record2.sourceId.trim() || !record2.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 !== record2.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(record2.type, record2.sourceRecordId, record2.version === 2 ? record2.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, record2, 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: record2.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(`${record2.sourceId}\0${record2.type}\0${record2.sourceRecordId}`)}`;
894
1022
  await createRecord({ ...crmDeps3, newId: () => recordId }, {
895
- type: record.type,
896
- input: record.input,
1023
+ type: record2.type,
1024
+ input: record2.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: record2.sourceId,
1035
+ sourceRecordId: record2.sourceRecordId,
908
1036
  ...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
909
- payloadVersion: record.version
1037
+ payloadVersion: record2.version
910
1038
  });
911
1039
  return { recordId };
912
1040
  }
@@ -929,28 +1057,114 @@ 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];
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];
954
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);
@@ -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
@@ -997,39 +1211,372 @@ async function sendTemplated(deps, input) {
997
1211
  } catch (e) {
998
1212
  error = e instanceof Error ? e.message : String(e);
999
1213
  }
1000
- }
1001
- const id = deps.newId();
1002
- const row = {
1003
- id,
1004
- groupId: input.group.id,
1005
- to: decision.to,
1006
- template: input.template,
1007
- subject: decision.subject,
1008
- body: decision.text,
1009
- transport: decision.transport,
1010
- redirected: decision.redirected,
1011
- dedupeKey: input.dedupeKey,
1012
- sentAt: deps.now(),
1013
- ...input.applicationId ? { applicationId: input.applicationId } : {},
1014
- ...messageId ? { messageId } : {},
1015
- ...error ? { error } : {}
1214
+ }
1215
+ const id = deps.newId();
1216
+ const row = {
1217
+ id,
1218
+ groupId: input.group.id,
1219
+ to: decision.to,
1220
+ template: input.template,
1221
+ subject: decision.subject,
1222
+ body: decision.text,
1223
+ transport: decision.transport,
1224
+ redirected: decision.redirected,
1225
+ dedupeKey: input.dedupeKey,
1226
+ sentAt: deps.now(),
1227
+ ...input.applicationId ? { applicationId: input.applicationId } : {},
1228
+ ...messageId ? { messageId } : {},
1229
+ ...error ? { error } : {}
1230
+ };
1231
+ await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
1232
+ return error ? { sent: false, reason: error } : { sent: true };
1233
+ }
1234
+ function emailGroupFrom(row) {
1235
+ const str3 = (v) => typeof v === "string" ? v : void 0;
1236
+ const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
1237
+ return {
1238
+ id: String(row.id),
1239
+ name: String(row.name ?? ""),
1240
+ replyTo: str3(row.replyTo) ?? "",
1241
+ debugEmail: str3(row.debugEmail),
1242
+ refundPolicyText: str3(row.refundPolicyText),
1243
+ commitmentText: str3(row.commitmentText),
1244
+ normsText: str3(row.normsText),
1245
+ emailTemplates: templates
1246
+ };
1247
+ }
1248
+
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;
1254
+ }
1255
+ function canBook(status, p) {
1256
+ return p.bookableFrom.includes(status);
1257
+ }
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
+ }
1276
+ }
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 } }]);
1292
+ }
1293
+ if (userId) rolePromoted = await clerkSetRole(secret, userId, promoteTo);
1294
+ }
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() },
1301
+ {
1302
+ group: emailGroupFrom(group),
1303
+ template: send,
1304
+ to: app.email,
1305
+ vars: { firstName: String(app.firstName ?? ""), membersUrl: `${input.origin}/members/` },
1306
+ applicationId: id,
1307
+ dedupeKey: `approve:${id}`
1308
+ }
1309
+ );
1310
+ emailLogged = result.sent;
1311
+ }
1312
+ return { ok: true, status: 200, rolePromoted, emailLogged, duplicate: write.duplicate };
1313
+ }
1314
+
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
+ };
1488
+ }
1489
+ function memberApplication(app, meeting, defaultTimezone) {
1490
+ const summary = applicationSummary(app);
1491
+ let meetingAt = app.meetingAt ?? null;
1492
+ let meetUrl = null;
1493
+ let timezone = defaultTimezone;
1494
+ if (meeting) {
1495
+ timezone = meeting.timezone ?? timezone;
1496
+ if (meeting.status === "scheduled") {
1497
+ meetingAt = meeting.startAt ?? null;
1498
+ meetUrl = meeting.meetUrl ?? null;
1499
+ } else {
1500
+ meetingAt = null;
1501
+ }
1502
+ }
1503
+ return { ...summary, meetingAt, meetUrl, timezone };
1504
+ }
1505
+
1506
+ // src/clerk.ts
1507
+ var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
1508
+ function clerkInviteRequest(input) {
1509
+ return {
1510
+ path: "/v1/invitations",
1511
+ body: {
1512
+ email_address: input.email,
1513
+ notify: true,
1514
+ ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
1515
+ ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
1516
+ }
1016
1517
  };
1017
- await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
1018
- return error ? { sent: false, reason: error } : { sent: true };
1019
1518
  }
1020
- function emailGroupFrom(row) {
1021
- const str3 = (v) => typeof v === "string" ? v : void 0;
1022
- const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
1519
+ async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
1520
+ const { path, body } = clerkInviteRequest(input);
1521
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
1522
+ method: "POST",
1523
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1524
+ body: JSON.stringify(body)
1525
+ });
1526
+ return res.ok ? { ok: true, status: res.status } : heal(res.status);
1527
+ }
1528
+ function clerkUserRequest(input) {
1023
1529
  return {
1024
- id: String(row.id),
1025
- name: String(row.name ?? ""),
1026
- replyTo: str3(row.replyTo) ?? "",
1027
- debugEmail: str3(row.debugEmail),
1028
- refundPolicyText: str3(row.refundPolicyText),
1029
- commitmentText: str3(row.commitmentText),
1030
- normsText: str3(row.normsText),
1031
- emailTemplates: templates
1530
+ path: "/v1/users",
1531
+ body: {
1532
+ email_address: [input.email],
1533
+ skip_password_requirement: true,
1534
+ ...input.firstName ? { first_name: input.firstName } : {},
1535
+ ...input.lastName ? { last_name: input.lastName } : {},
1536
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
1537
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
1538
+ }
1539
+ };
1540
+ }
1541
+ async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
1542
+ const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
1543
+ method: "PATCH",
1544
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1545
+ body: JSON.stringify({
1546
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
1547
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
1548
+ })
1549
+ });
1550
+ return res.ok;
1551
+ }
1552
+ async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
1553
+ const auth = { authorization: `Bearer ${secretKey}` };
1554
+ const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
1555
+ if (!found.ok) return false;
1556
+ const users = await found.json().catch(() => null);
1557
+ const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
1558
+ if (!id) return false;
1559
+ return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
1560
+ }
1561
+ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
1562
+ const { path, body } = clerkUserRequest(input);
1563
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
1564
+ method: "POST",
1565
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1566
+ body: JSON.stringify(body)
1567
+ });
1568
+ if (res.ok) return { ok: true, status: res.status };
1569
+ const healed = heal(res.status);
1570
+ const repair = input.repairMetadata ?? {
1571
+ ...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
1572
+ ...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
1032
1573
  };
1574
+ if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
1575
+ const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
1576
+ ...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
1577
+ ...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
1578
+ }, fetchImpl).catch(() => false);
1579
+ return { ...healed, refreshed };
1033
1580
  }
1034
1581
 
1035
1582
  // src/worker-provisioning.ts
@@ -1159,8 +1706,12 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
1159
1706
 
1160
1707
  // src/worker-routes.ts
1161
1708
  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;
1709
+ let apps = (await db.query({ applications: { $: {
1710
+ where: { emailNormalized: normalizeAdmissionEmail(email) },
1711
+ limit: 20
1712
+ } } })).applications;
1713
+ if (!apps?.length) apps = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
1714
+ const app = Array.isArray(apps) ? [...apps].sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0] : void 0;
1164
1715
  if (!app) return null;
1165
1716
  const meetings = (await db.query({ meetings: { $: { where: { applicationId: app.id, status: "scheduled" }, order: { createdAt: "desc" }, limit: 1 } } })).meetings;
1166
1717
  const meeting = Array.isArray(meetings) ? meetings[0] : void 0;
@@ -1262,7 +1813,8 @@ var handleMember = async (req, url, env, ctx) => {
1262
1813
  membershipPage: activeControl.document.membershipPage,
1263
1814
  application: activeControl.document.application,
1264
1815
  theme: activeControl.document.theme
1265
- } } : {}
1816
+ } } : {},
1817
+ giftOffers: publicGiftOffers(activeControl?.document.giftOffers, Date.now())
1266
1818
  });
1267
1819
  }
1268
1820
  if (req.method === "GET" && url.pathname === "/api/join/resume") {
@@ -1301,18 +1853,9 @@ var handleMember = async (req, url, env, ctx) => {
1301
1853
  })).groups;
1302
1854
  const group = Array.isArray(groups) ? groups[0] : void 0;
1303
1855
  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);
1856
+ const state = await continueJoin(db, ctx, env, url.origin, app, group);
1857
+ if (state) return json({ state, applicationId, ...state === "gift" ? { claimId: app.giftClaimId } : {} });
1858
+ return json({ error: `application cannot resume from status "${String(app.status ?? "")}"` }, 409);
1316
1859
  }
1317
1860
  if (req.method === "POST" && url.pathname === "/api/applications") {
1318
1861
  const raw = await req.text();
@@ -1328,12 +1871,19 @@ var handleMember = async (req, url, env, ctx) => {
1328
1871
  const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
1329
1872
  const effectiveChapter = activeControl ? { ...chapter, application: controlledApplication(chapter.application, activeControl.document) } : chapter;
1330
1873
  const offeredTierIds = typeof parsed.tierId === "string" && parsed.tierId ? (await loadTiers(db, { id: chapter.id })).map((tier) => tier.id) : void 0;
1874
+ const now = Date.now();
1875
+ const requestedGiftId = typeof parsed.giftOfferId === "string" ? parsed.giftOfferId : "";
1876
+ const giftOffer = requestedGiftId ? findGiftOffer(activeControl?.document.giftOffers, requestedGiftId, now) : null;
1877
+ if (requestedGiftId && !giftOffer) return json({ error: "gift offer is unavailable" }, 400);
1878
+ const effectiveGrant = !giftOffer && typeof parsed.email === "string" ? await findAdmissionGrant(db, chapter.id, parsed.email, now) : null;
1331
1879
  const result = await submitApplication(db, effectiveChapter, parsed, {
1332
1880
  submissionId,
1333
1881
  groupId: chapter.id,
1334
- now: Date.now(),
1882
+ now,
1335
1883
  newId: () => crypto.randomUUID(),
1336
- ...offeredTierIds ? { tierIds: offeredTierIds } : {}
1884
+ ...offeredTierIds ? { tierIds: offeredTierIds } : {},
1885
+ ...effectiveGrant?.decision === "waive_interview" ? { admissionGrant: effectiveGrant } : {},
1886
+ ...giftOffer ? { giftOfferId: giftOffer.id } : {}
1337
1887
  });
1338
1888
  if (!result.ok) return json({ error: result.error }, 400);
1339
1889
  if (!result.duplicate) {
@@ -1342,10 +1892,16 @@ var handleMember = async (req, url, env, ctx) => {
1342
1892
  }
1343
1893
  await provisionApplicant(db, effectiveChapter, result.id, parsed);
1344
1894
  }
1895
+ const apps = (await db.query({ applications: { $: { where: { id: result.id }, limit: 1 } } })).applications;
1896
+ const canonical2 = Array.isArray(apps) ? apps[0] : void 0;
1897
+ const groups = (await db.query({ groups: { $: { where: { id: chapter.id }, limit: 1 } } })).groups;
1898
+ const group = Array.isArray(groups) ? groups[0] : void 0;
1899
+ const state = canonical2 && group ? await continueJoin(db, ctx, env, url.origin, canonical2, group) : null;
1345
1900
  return json({
1346
1901
  id: result.id,
1347
1902
  duplicate: result.duplicate,
1348
1903
  status: result.status,
1904
+ ...state ? { state } : {},
1349
1905
  // Echoed so a site can see (and assert in an integration test) whether its
1350
1906
  // join page actually posted the ack. Absent consent is otherwise invisible.
1351
1907
  disclaimerAckAt: result.disclaimerAckAt
@@ -1401,8 +1957,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
1401
1957
  payload,
1402
1958
  sourceId ? { sourceId } : {}
1403
1959
  );
1404
- const record = normalizeSharedRecord(payload, sourceId);
1405
- return json({ recordId, type: record.type });
1960
+ const record2 = normalizeSharedRecord(payload, sourceId);
1961
+ return json({ recordId, type: record2.type });
1406
1962
  } catch (error) {
1407
1963
  return json({
1408
1964
  error: error instanceof Error ? error.message : "invalid shared record"
@@ -1413,29 +1969,6 @@ var handleNetworkShared = async (req, url, env, ctx) => {
1413
1969
  // src/worker-routes-signup-control.ts
1414
1970
  import { verifyFederatedRequest as verifyFederatedRequest2 } from "@odla-ai/db";
1415
1971
 
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
- }
1438
-
1439
1972
  // src/signup-control-contract.ts
1440
1973
  var SIGNUP_CONTROL_PATH = "/api/signup-control";
1441
1974
 
@@ -1479,10 +2012,10 @@ function boundedString(value, path, errors, max, required = false) {
1479
2012
  errors.push(`${path} must be a string`);
1480
2013
  return void 0;
1481
2014
  }
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;
2015
+ const text3 = value.trim();
2016
+ if (required && !text3) errors.push(`${path} is required`);
2017
+ if (text3.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
2018
+ return text3;
1486
2019
  }
1487
2020
  function validateMembershipPageDocument(input, options = {}) {
1488
2021
  const errors = [];
@@ -1531,8 +2064,8 @@ function validateMembershipPageDocument(input, options = {}) {
1531
2064
  else {
1532
2065
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
1533
2066
  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] : [];
2067
+ const text3 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
2068
+ return text3 ? [text3] : [];
1536
2069
  });
1537
2070
  }
1538
2071
  }
@@ -1606,7 +2139,7 @@ function validateMembershipPageDocument(input, options = {}) {
1606
2139
  var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
1607
2140
  var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
1608
2141
  var TIER = /^[a-z][a-z0-9-]{0,79}$/;
1609
- var PRICE = /^price_[A-Za-z0-9]{12,}$/;
2142
+ var PRICE2 = /^price_[A-Za-z0-9]{12,}$/;
1610
2143
  var ROOT_KEYS2 = /* @__PURE__ */ new Set([
1611
2144
  "schemaVersion",
1612
2145
  "revision",
@@ -1618,14 +2151,15 @@ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
1618
2151
  "application",
1619
2152
  "policy",
1620
2153
  "scheduling",
1621
- "theme"
2154
+ "theme",
2155
+ "giftOffers"
1622
2156
  ]);
1623
2157
  var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
1624
2158
  var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1625
2159
  function unknownKeys(value, allowed, path, errors) {
1626
2160
  for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
1627
2161
  }
1628
- function text(value, path, errors, max = 65536) {
2162
+ function text2(value, path, errors, max = 65536) {
1629
2163
  if (typeof value !== "string" || !value.trim()) {
1630
2164
  errors.push(`${path} is required`);
1631
2165
  return "";
@@ -1648,23 +2182,23 @@ function tiersOf(value, errors) {
1648
2182
  return [];
1649
2183
  }
1650
2184
  unknownKeys(item, TIER_KEYS2, path, errors);
1651
- const id = text(item.id, `${path}.id`, errors, 80);
2185
+ const id = text2(item.id, `${path}.id`, errors, 80);
1652
2186
  if (id && !TIER.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
1653
2187
  if (seen.has(id)) errors.push(`${path}.id duplicates "${id}"`);
1654
2188
  seen.add(id);
1655
- const name = text(item.name, `${path}.name`, errors, 160);
2189
+ const name = text2(item.name, `${path}.name`, errors, 160);
1656
2190
  if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
1657
2191
  if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
1658
2192
  if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
1659
2193
  errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
1660
2194
  }
1661
- if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE.test(item.stripePriceId))) {
2195
+ if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE2.test(item.stripePriceId))) {
1662
2196
  errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
1663
2197
  }
1664
2198
  if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
1665
2199
  errors.push(`${path}.stripePriceId is required for an available paid tier`);
1666
2200
  }
1667
- const blurb = item.blurb === void 0 ? void 0 : text(item.blurb, `${path}.blurb`, errors, 4e3);
2201
+ const blurb = item.blurb === void 0 ? void 0 : text2(item.blurb, `${path}.blurb`, errors, 4e3);
1668
2202
  return [{
1669
2203
  id,
1670
2204
  name,
@@ -1713,7 +2247,7 @@ function policyOf(value, errors) {
1713
2247
  const result = {};
1714
2248
  for (const key of allowed) {
1715
2249
  if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
1716
- result[key] = text(input[key], `document.policy.${key}`, errors);
2250
+ result[key] = text2(input[key], `document.policy.${key}`, errors);
1717
2251
  }
1718
2252
  return result;
1719
2253
  }
@@ -1727,12 +2261,18 @@ function themeOf(value, errors) {
1727
2261
  }
1728
2262
  unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
1729
2263
  if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
2264
+ let serializedLength = 0;
1730
2265
  for (const [key, token] of Object.entries(tokens)) {
1731
- if (!/^--[a-z0-9-]{1,100}$/.test(key)) errors.push(`document.theme.tokens.${key} must be a CSS custom-property name`);
1732
- if (typeof token !== "string" && typeof token !== "number" || String(token).length > 1e3) {
1733
- errors.push(`document.theme.tokens.${key} must be a bounded string or number`);
2266
+ if (!/^--ui-[a-z0-9][a-z0-9-]{0,94}$/.test(key)) {
2267
+ errors.push(`document.theme.tokens.${key} must be an allowlisted --ui-* custom property`);
2268
+ }
2269
+ const rendered = String(token).trim();
2270
+ serializedLength += key.length + rendered.length + 4;
2271
+ 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)) {
2272
+ errors.push(`document.theme.tokens.${key} must be a bounded breakout-safe CSS value`);
1734
2273
  }
1735
2274
  }
2275
+ if (serializedLength > 65536) errors.push("document.theme.tokens exceeds the serialized size bound");
1736
2276
  return { tokens };
1737
2277
  }
1738
2278
  function validateSignupControlDocument(input) {
@@ -1745,9 +2285,9 @@ function validateSignupControlDocument(input) {
1745
2285
  if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
1746
2286
  const source = recordOf(root.source);
1747
2287
  const target = recordOf(root.target);
1748
- const appId = text(source?.appId, "document.source.appId", errors, 63);
1749
- const chapterId = text(target?.chapterId, "document.target.chapterId", errors, 63);
1750
- const environment = text(target?.environment, "document.target.environment", errors, 63);
2288
+ const appId = text2(source?.appId, "document.source.appId", errors, 63);
2289
+ const chapterId = text2(target?.chapterId, "document.target.chapterId", errors, 63);
2290
+ const environment = text2(target?.environment, "document.target.environment", errors, 63);
1751
2291
  if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
1752
2292
  if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
1753
2293
  if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
@@ -1768,6 +2308,8 @@ function validateSignupControlDocument(input) {
1768
2308
  else scheduling = root.scheduling;
1769
2309
  }
1770
2310
  const theme = themeOf(root.theme, errors);
2311
+ const giftOffers = root.giftOffers === void 0 ? void 0 : validateGiftOfferCatalog(root.giftOffers, new Set(tiers.map((tier) => tier.id)));
2312
+ if (giftOffers && !giftOffers.ok) errors.push(...giftOffers.errors);
1771
2313
  if (errors.length || !page.ok) return { ok: false, errors };
1772
2314
  return { ok: true, value: {
1773
2315
  schemaVersion: 1,
@@ -1780,7 +2322,8 @@ function validateSignupControlDocument(input) {
1780
2322
  ...application ? { application } : {},
1781
2323
  ...policy ? { policy } : {},
1782
2324
  ...scheduling ? { scheduling } : {},
1783
- ...theme ? { theme } : {}
2325
+ ...theme ? { theme } : {},
2326
+ ...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
1784
2327
  } };
1785
2328
  }
1786
2329
 
@@ -1841,92 +2384,156 @@ var handleSignupControl = async (req, url, env, ctx) => {
1841
2384
  });
1842
2385
  };
1843
2386
 
1844
- // src/crm-sync.ts
1845
- import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
1846
- var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
1847
- var crmDeps = (db, ctx) => ({
1848
- crm: ctx.chapter.crm,
1849
- db,
1850
- now: () => Date.now(),
1851
- newId: () => crypto.randomUUID(),
1852
- chapter: ctx.chapter
1853
- });
1854
- function personInputFromApp(chapter, app) {
1855
- const input = sharedPersonInput({
1856
- email: str(app.email),
1857
- firstName: str(app.firstName) || void 0,
1858
- lastName: str(app.lastName) || void 0,
1859
- phone: str(app.phone) || void 0,
1860
- linkedin: str(app.linkedin) || void 0,
1861
- hubRecordId: str(app.id)
1862
- });
1863
- for (const f of chapter.application.crmFields) {
1864
- if (app[f] !== void 0) input[f] = app[f];
2387
+ // src/worker-routes-admission-grants.ts
2388
+ import { verifyFederatedRequest as verifyFederatedRequest3 } from "@odla-ai/db";
2389
+ var reject2 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
2390
+ var handleAdmissionGrants = async (req, url, env, ctx) => {
2391
+ if (req.method !== "POST" || url.pathname !== ADMISSION_GRANTS_PATH) return null;
2392
+ const authority = ctx.chapter.signupControl;
2393
+ if (!authority) return json({ error: "not found" }, 404);
2394
+ const db = ctx.makeDb(env);
2395
+ const secret = await getVaultSecret(db, authority.secretName);
2396
+ if (!secret) return json({ error: "unauthorized" }, 401);
2397
+ const verified = await verifyFederatedRequest3(req, { secret, senders: [authority.sourceId] });
2398
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
2399
+ let input;
2400
+ try {
2401
+ input = JSON.parse(verified.body);
2402
+ } catch {
2403
+ return reject2("invalid_document", 400, ["request body must be JSON"]);
1865
2404
  }
1866
- if (app.id !== void 0) input.applicationId = str(app.id);
1867
- return input;
1868
- }
1869
- function billingColumns(app) {
1870
- const status = str(app.status);
1871
- const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
1872
- const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
1873
- const cols = { billingStatus };
1874
- if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
1875
- if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
1876
- if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
1877
- return cols;
1878
- }
1879
- async function syncApplicationToCrm(deps, opts) {
1880
- const emailKey = str(opts.app.email).toLowerCase();
1881
- if (!emailKey) return null;
1882
- const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1883
- const input = personInputFromApp(deps.chapter, opts.app);
1884
- const { crm_record } = await deps.db.query({
1885
- crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
1886
- });
1887
- const existing = crm_record?.[0] ?? null;
1888
- const stage = opts.stage || void 0;
1889
- let recordId;
1890
- if (existing && typeof existing.id === "string") {
1891
- recordId = existing.id;
1892
- await updateRecord2(recordDeps, { id: recordId, input });
1893
- } else {
1894
- const created = await createRecord2(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
1895
- recordId = created.id;
2405
+ const digest = typeof input.digest === "string" ? input.digest : "";
2406
+ const checked = validateAdmissionGrantDocument(input.document);
2407
+ if (!checked.ok) return reject2("invalid_document", 400, checked.errors);
2408
+ const document = checked.value;
2409
+ if (document.source.appId !== authority.sourceId) return reject2("wrong_source", 403);
2410
+ if (document.target.chapterId !== ctx.chapter.id) return reject2("wrong_target", 409);
2411
+ if (document.target.environment !== env.ODLA_ENV) return reject2("wrong_environment", 409);
2412
+ if (!digest || await signupControlDigest(document) !== digest) return reject2("invalid_digest", 400);
2413
+ const latest = await loadLatestAdmissionRevision(db, authority.sourceId, ctx.chapter.id, env.ODLA_ENV);
2414
+ if (latest && document.revision < latest.revision) return reject2("stale_revision", 409);
2415
+ if (latest && document.revision === latest.revision) {
2416
+ if (latest.digest !== digest) return reject2("conflicting_revision", 409);
2417
+ return json({
2418
+ ok: true,
2419
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
2420
+ revision: latest.revision,
2421
+ digest: latest.digest,
2422
+ status: "replayed",
2423
+ acknowledgedAt: latest.receivedAt
2424
+ });
1896
2425
  }
1897
- if (existing && stage && existing.stage !== stage) {
1898
- await setStage(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
2426
+ const receivedAt = Date.now();
2427
+ try {
2428
+ await materializeAdmissionGrants(db, { digest, document }, latest, receivedAt);
2429
+ } catch {
2430
+ return reject2("materialization_failed", 503);
1899
2431
  }
1900
- await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1901
- await linkIdentity(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1902
- return recordId;
1903
- }
1904
- async function backfillCrm(deps) {
1905
- const [appsRes, usersRes] = await Promise.all([
1906
- deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
1907
- deps.db.query({ $users: { $: { limit: 1e3 } } })
1908
- ]);
1909
- const seen = /* @__PURE__ */ new Set();
1910
- let synced = 0;
1911
- const errors = [];
1912
- const run = async (app, stage) => {
1913
- const key = str(app.email).toLowerCase();
1914
- if (!key || seen.has(key)) return;
1915
- seen.add(key);
2432
+ return json({
2433
+ ok: true,
2434
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
2435
+ revision: document.revision,
2436
+ digest,
2437
+ status: "accepted",
2438
+ acknowledgedAt: receivedAt
2439
+ });
2440
+ };
2441
+
2442
+ // src/worker-routes-admin-admission.ts
2443
+ async function gate(req, env, ctx) {
2444
+ const rawDb = ctx.makeDb(env);
2445
+ const user = await ctx.verifyUser(req, env);
2446
+ if (!user) return json({ error: "unauthorized" }, 401);
2447
+ if (!await ctx.isAdmin(rawDb, user)) return json({ error: "forbidden" }, 403);
2448
+ return { db: rawDb, user };
2449
+ }
2450
+ var handleAdminAdmissionGrants = async (req, url, env, ctx) => {
2451
+ if (url.pathname === "/api/admin/admission-grants" && req.method === "GET") {
2452
+ const auth = await gate(req, env, ctx);
2453
+ if (auth instanceof Response) return auth;
2454
+ const result = await auth.db.query({ admissionGrants: { $: {
2455
+ where: { targetId: ctx.chapter.id },
2456
+ order: { createdAt: "desc" },
2457
+ limit: 500
2458
+ } } });
2459
+ const grants = Array.isArray(result.admissionGrants) ? result.admissionGrants.map((row) => ({ ...row, emailNormalized: void 0 })) : [];
2460
+ return json({ grants });
2461
+ }
2462
+ if (url.pathname === "/api/admin/admission-grants" && req.method === "POST") {
2463
+ const auth = await gate(req, env, ctx);
2464
+ if (auth instanceof Response) return auth;
2465
+ let body;
1916
2466
  try {
1917
- await syncApplicationToCrm(deps, { app, stage });
1918
- synced += 1;
1919
- } catch (err) {
1920
- errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
2467
+ body = JSON.parse(await req.text());
2468
+ } catch {
2469
+ return json({ error: "invalid JSON body" }, 400);
2470
+ }
2471
+ const email = typeof body.email === "string" ? body.email.trim() : "";
2472
+ const emailNormalized = normalizeAdmissionEmail(email);
2473
+ if (!isValidEmail(emailNormalized)) return json({ error: "email must be valid" }, 400);
2474
+ const decision = body.decision ?? "waive_interview";
2475
+ if (decision !== "waive_interview" && decision !== "require_interview") {
2476
+ return json({ error: "decision must be waive_interview or require_interview" }, 400);
2477
+ }
2478
+ const note = typeof body.note === "string" ? body.note.trim() : "";
2479
+ if (note.length > 2e3) return json({ error: "note is too long" }, 400);
2480
+ const expiresAt = body.expiresAt === void 0 || body.expiresAt === null ? void 0 : Number(body.expiresAt);
2481
+ if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) {
2482
+ return json({ error: "expiresAt must be a future epoch timestamp" }, 400);
2483
+ }
2484
+ const sourceGrantId = crypto.randomUUID();
2485
+ const id = `admission:local:${ctx.chapter.id}:${sourceGrantId}`;
2486
+ const createdAt = Date.now();
2487
+ const actor = auth.user.email ?? auth.user.userId;
2488
+ await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
2489
+ id,
2490
+ sourceGrantId,
2491
+ sourceScope: "local",
2492
+ sourceId: ctx.chapter.id,
2493
+ targetId: ctx.chapter.id,
2494
+ email,
2495
+ emailNormalized,
2496
+ decision,
2497
+ ...note ? { note } : {},
2498
+ ...expiresAt !== void 0 ? { expiresAt } : {},
2499
+ active: true,
2500
+ createdAt,
2501
+ createdBy: actor,
2502
+ updatedAt: createdAt,
2503
+ updatedBy: actor
2504
+ } }], { mutationId: `admission-local-create:${sourceGrantId}` });
2505
+ return json({ grant: { id, sourceGrantId, sourceScope: "local", email, decision, active: true, createdAt, ...note ? { note } : {}, ...expiresAt ? { expiresAt } : {} } }, 201);
2506
+ }
2507
+ const prefix = "/api/admin/admission-grants/";
2508
+ let revokeId = "";
2509
+ if (url.pathname.startsWith(prefix)) {
2510
+ try {
2511
+ revokeId = decodeURIComponent(url.pathname.slice(prefix.length));
2512
+ } catch {
2513
+ revokeId = "";
1921
2514
  }
1922
- };
1923
- for (const a of appsRes.applications ?? []) await run(a, str(a.status));
1924
- for (const u of usersRes.$users ?? []) {
1925
- if (u.deleted === true) continue;
1926
- await run({ email: u.email, firstName: str(u.name) });
1927
2515
  }
1928
- return { synced, errors };
1929
- }
2516
+ if (/^admission:local:[a-z0-9-]+:[0-9a-f-]+$/.test(revokeId) && req.method === "DELETE") {
2517
+ const auth = await gate(req, env, ctx);
2518
+ if (auth instanceof Response) return auth;
2519
+ const id = revokeId;
2520
+ const found = await auth.db.query({ admissionGrants: { $: { where: { id }, limit: 1 } } });
2521
+ const row = Array.isArray(found.admissionGrants) ? found.admissionGrants[0] : void 0;
2522
+ if (!row || row.targetId !== ctx.chapter.id || row.sourceScope !== "local") return json({ error: "not found" }, 404);
2523
+ if (row.active !== true) return json({ ok: true, replayed: true });
2524
+ const revokedAt = Date.now();
2525
+ const actor = auth.user.email ?? auth.user.userId;
2526
+ await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
2527
+ active: false,
2528
+ revokedAt,
2529
+ revokedBy: actor,
2530
+ updatedAt: revokedAt,
2531
+ updatedBy: actor
2532
+ } }], { mutationId: `admission-local-revoke:${id}` });
2533
+ return json({ ok: true, replayed: false });
2534
+ }
2535
+ return null;
2536
+ };
1930
2537
 
1931
2538
  // src/worker-routes-schedule.ts
1932
2539
  import { computeBookableSlots, initCalendar } from "@odla-ai/calendar";
@@ -1942,8 +2549,8 @@ function makeCalendar(env) {
1942
2549
  }
1943
2550
  async function firstRow(db, ns, q) {
1944
2551
  const res = await db.query({ [ns]: { $: q } });
1945
- const rows2 = res[ns];
1946
- return Array.isArray(rows2) ? rows2[0] : void 0;
2552
+ const rows4 = res[ns];
2553
+ return Array.isArray(rows4) ? rows4[0] : void 0;
1947
2554
  }
1948
2555
  async function computeSlots(cal, cfg) {
1949
2556
  const { from, to } = slotWindow(Date.now(), cfg.windowDays);
@@ -2080,8 +2687,26 @@ function findApplicationRef(obj) {
2080
2687
  const customerId = typeof obj.customer === "string" ? obj.customer : void 0;
2081
2688
  return { ...applicationId ? { applicationId } : {}, ...customerId ? { customerId } : {} };
2082
2689
  }
2690
+ function findGiftRef(obj) {
2691
+ const metaOf = (value) => value && typeof value === "object" ? value.metadata ?? {} : {};
2692
+ const pick = (metadata) => typeof metadata.giftIntentId === "string" ? metadata.giftIntentId : void 0;
2693
+ const parent = obj.parent;
2694
+ const subscription = obj.subscription_details ?? parent?.subscription_details;
2695
+ const giftIntentId = pick(metaOf(obj)) ?? pick(metaOf(subscription));
2696
+ const subscriptionId = typeof obj.subscription === "string" ? obj.subscription : typeof subscription?.subscription === "string" ? String(subscription.subscription) : void 0;
2697
+ return { ...giftIntentId ? { giftIntentId } : {}, providerObjectId: subscriptionId ?? String(obj.id ?? "") };
2698
+ }
2083
2699
  function normalizeWebhookEvent(event) {
2084
2700
  const obj = event.data?.object ?? {};
2701
+ const gift = findGiftRef(obj);
2702
+ if (gift.giftIntentId) {
2703
+ if (event.type === "payment_intent.succeeded" || event.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
2704
+ return { kind: "gift_paid", giftIntentId: gift.giftIntentId, providerObjectId: gift.providerObjectId };
2705
+ }
2706
+ if (event.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "refunded" };
2707
+ if (event.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "canceled" };
2708
+ return { kind: "ignored", type: event.type };
2709
+ }
2085
2710
  const ref = findApplicationRef(obj);
2086
2711
  switch (event.type) {
2087
2712
  case "invoice.paid": {
@@ -2182,9 +2807,9 @@ function createStripeProvider(config) {
2182
2807
  `sub:${input.applicationId}`
2183
2808
  );
2184
2809
  if (!sub.ok) fail("subscription create", sub);
2185
- const clientSecret = clientSecretOf(sub.body);
2186
- if (!clientSecret) fail("subscription confirmation-secret missing", sub);
2187
- return { customerId, subscriptionId: String(sub.body.id), clientSecret };
2810
+ const clientSecret2 = clientSecretOf(sub.body);
2811
+ if (!clientSecret2) fail("subscription confirmation-secret missing", sub);
2812
+ return { customerId, subscriptionId: String(sub.body.id), clientSecret: clientSecret2 };
2188
2813
  },
2189
2814
  async ingestWebhook(rawBody, sigHeader) {
2190
2815
  if (!webhookSecret || !await verifyStripeSignature2(rawBody, sigHeader, webhookSecret)) {
@@ -2197,8 +2822,8 @@ function createStripeProvider(config) {
2197
2822
  const sk = requireSecret(secretKey);
2198
2823
  const charges = await stripeCall(sk, "GET", "/v1/charges", { customer: input.customerId, limit: 100 });
2199
2824
  if (!charges.ok) fail("charges list", charges);
2200
- const rows2 = charges.body.data ?? [];
2201
- const paid = rows2.filter((c) => c.status === "succeeded" && c.refunded !== true);
2825
+ const rows4 = charges.body.data ?? [];
2826
+ const paid = rows4.filter((c) => c.status === "succeeded" && c.refunded !== true);
2202
2827
  const charge = paid[paid.length - 1];
2203
2828
  if (!charge) {
2204
2829
  const err = new Error("no paid charge to refund");
@@ -2214,12 +2839,337 @@ function createStripeProvider(config) {
2214
2839
  };
2215
2840
  }
2216
2841
 
2842
+ // src/gift-codes.ts
2843
+ var GIFT_CODE_VERSION = 1;
2844
+ var GIFT_CODE_BYTES = 32;
2845
+ var base64url = (bytes) => {
2846
+ let binary = "";
2847
+ for (const byte of bytes) binary += String.fromCharCode(byte);
2848
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
2849
+ };
2850
+ function createGiftCode() {
2851
+ const bytes = new Uint8Array(GIFT_CODE_BYTES);
2852
+ crypto.getRandomValues(bytes);
2853
+ return `gift${GIFT_CODE_VERSION}_${base64url(bytes)}`;
2854
+ }
2855
+ async function hashGiftCode(chapterId, code, version = GIFT_CODE_VERSION) {
2856
+ const bytes = new TextEncoder().encode(`odla-gift:${version}:${chapterId}:${code}`);
2857
+ return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
2858
+ }
2859
+ async function giftRequesterBucket(chapterId, requester) {
2860
+ const bytes = new TextEncoder().encode(`odla-gift-rate:${chapterId}:${requester}`);
2861
+ return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))).slice(0, 32);
2862
+ }
2863
+
2864
+ // src/gift-store.ts
2865
+ var first = (value) => Array.isArray(value) ? value[0] : void 0;
2866
+ async function prepareGiftCode(chapterId) {
2867
+ const code = createGiftCode();
2868
+ return { code, codeHash: await hashGiftCode(chapterId, code), codeVersion: GIFT_CODE_VERSION };
2869
+ }
2870
+ async function issueGiftClaim(db, intent, provider, now) {
2871
+ const existing = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
2872
+ if (existing) return { claimId: String(existing.id), orderId: String(existing.orderId), duplicate: true };
2873
+ const offer = intent.offerSnapshot;
2874
+ const orderId = `gift-order:${intent.id}`;
2875
+ const claimId = `gift-claim:${intent.id}`;
2876
+ const shared = {
2877
+ chapterId: intent.chapterId,
2878
+ offerId: intent.offerId,
2879
+ ...intent.purchaserApplicationId ? { purchaserApplicationId: intent.purchaserApplicationId } : {},
2880
+ ...intent.purchaserUserId ? { purchaserUserId: intent.purchaserUserId } : {},
2881
+ purchaserEmailNormalized: intent.purchaserEmailNormalized
2882
+ };
2883
+ const ops = [
2884
+ { t: "update", ns: "giftOrders", id: orderId, attrs: {
2885
+ id: orderId,
2886
+ intentId: intent.id,
2887
+ ...shared,
2888
+ environment: intent.environment,
2889
+ offerSnapshot: offer,
2890
+ purchaserType: intent.purchaserType,
2891
+ providerEventId: provider.eventId,
2892
+ providerObjectId: provider.objectId,
2893
+ amountCents: offer.priceCents,
2894
+ currency: offer.currency,
2895
+ billingMode: offer.billingMode,
2896
+ createdAt: now
2897
+ } },
2898
+ { t: "update", ns: "giftClaims", id: claimId, attrs: {
2899
+ id: claimId,
2900
+ orderId,
2901
+ intentId: intent.id,
2902
+ ...shared,
2903
+ target: offer.target,
2904
+ codeHash: intent.codeHash,
2905
+ codeVersion: intent.codeVersion,
2906
+ rotationCount: 0,
2907
+ codeIssuedAt: intent.codeIssuedAt,
2908
+ codeExpiresAt: now + offer.redemptionLifetimeHours * 36e5,
2909
+ attemptCount: 0,
2910
+ attemptWindowStartedAt: now,
2911
+ status: "pending",
2912
+ createdAt: now
2913
+ } },
2914
+ { t: "update", ns: "giftPurchaseIntents", id: intent.id, attrs: { status: "paid", paidAt: now, claimId, updatedAt: now } }
2915
+ ];
2916
+ if (intent.purchaserApplicationId) ops.push({
2917
+ t: "update",
2918
+ ns: "applications",
2919
+ id: intent.purchaserApplicationId,
2920
+ attrs: { giftPaidAt: now, giftClaimId: claimId }
2921
+ });
2922
+ try {
2923
+ const result = await db.transact(ops, {
2924
+ mutationId: `gift-paid:${provider.eventId}`,
2925
+ guards: [{ ns: "giftPurchaseIntents", id: intent.id, exists: true, equals: { status: "checkout_ready" } }]
2926
+ });
2927
+ return { claimId, orderId, duplicate: result.duplicate };
2928
+ } catch {
2929
+ const raced = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
2930
+ if (raced) return { claimId: String(raced.id), orderId: String(raced.orderId), duplicate: true };
2931
+ throw new Error("gift issuance conflict");
2932
+ }
2933
+ }
2934
+ function buyerGiftClaims(rows4, now) {
2935
+ return rows4.map((row) => {
2936
+ const stored = String(row.status ?? "pending");
2937
+ const status = stored === "pending" && Number(row.codeExpiresAt) <= now ? "expired" : stored;
2938
+ return {
2939
+ id: String(row.id),
2940
+ offerId: String(row.offerId),
2941
+ status,
2942
+ createdAt: Number(row.createdAt),
2943
+ codeExpiresAt: Number(row.codeExpiresAt),
2944
+ ...typeof row.redeemedAt === "number" ? { redeemedAt: row.redeemedAt } : {},
2945
+ ...typeof row.revokedAt === "number" ? { revokedAt: row.revokedAt } : {}
2946
+ };
2947
+ });
2948
+ }
2949
+ async function rotateGiftClaim(db, claim, now) {
2950
+ if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
2951
+ const prepared = await prepareGiftCode(String(claim.chapterId));
2952
+ const currentRotation = Number(claim.rotationCount ?? 0);
2953
+ try {
2954
+ const write = await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
2955
+ codeHash: prepared.codeHash,
2956
+ codeVersion: prepared.codeVersion,
2957
+ rotationCount: currentRotation + 1,
2958
+ codeIssuedAt: now,
2959
+ rotatedAt: now,
2960
+ attemptCount: 0,
2961
+ attemptWindowStartedAt: now
2962
+ } }], {
2963
+ mutationId: `gift-rotate:${String(claim.id)}:${currentRotation + 1}`,
2964
+ guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: "pending", rotationCount: currentRotation } }]
2965
+ });
2966
+ return write.duplicate ? null : { code: prepared.code };
2967
+ } catch {
2968
+ return null;
2969
+ }
2970
+ }
2971
+ async function redeemGiftClaim(db, claim, beneficiary, now) {
2972
+ if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
2973
+ const chapterId = String(claim.chapterId);
2974
+ const emailNormalized = normalizeAdmissionEmail(beneficiary.email);
2975
+ const target = claim.target;
2976
+ const ops = [];
2977
+ let result;
2978
+ if (target.type === "tier") {
2979
+ const apps = (await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications;
2980
+ const existing = apps?.filter((row) => row.groupId === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
2981
+ const id = existing ? String(existing.id) : `gift-application:${String(claim.id)}`;
2982
+ const status = existing && existing.status !== "submitted" ? String(existing.status) : "paid_pending_vetting";
2983
+ ops.push({ t: "update", ns: "applications", id, attrs: {
2984
+ id,
2985
+ groupId: chapterId,
2986
+ email: beneficiary.email,
2987
+ emailNormalized,
2988
+ tierId: target.tierId,
2989
+ status,
2990
+ giftClaimId: String(claim.id),
2991
+ giftOrderId: String(claim.orderId),
2992
+ clerkUserId: beneficiary.userId,
2993
+ ...existing ? {} : { createdAt: now }
2994
+ } });
2995
+ result = { kind: "application", id };
2996
+ } else {
2997
+ const id = `gift-entitlement:${String(claim.id)}`;
2998
+ ops.push({ t: "update", ns: "giftEntitlements", id, attrs: {
2999
+ id,
3000
+ claimId: String(claim.id),
3001
+ orderId: String(claim.orderId),
3002
+ chapterId,
3003
+ entitlementKey: target.entitlementKey,
3004
+ beneficiaryEmailNormalized: emailNormalized,
3005
+ beneficiaryUserId: beneficiary.userId,
3006
+ active: true,
3007
+ createdAt: now
3008
+ } });
3009
+ result = { kind: "entitlement", id };
3010
+ }
3011
+ ops.unshift({ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
3012
+ status: "redeemed",
3013
+ redeemedAt: now,
3014
+ beneficiaryEmailNormalized: emailNormalized,
3015
+ beneficiaryUserId: beneficiary.userId,
3016
+ ...result.kind === "application" ? { applicationId: result.id } : { entitlementId: result.id },
3017
+ attemptCount: Number(claim.attemptCount ?? 0) + 1
3018
+ } });
3019
+ try {
3020
+ await db.transact(ops, {
3021
+ mutationId: `gift-redeem:${String(claim.id)}:${beneficiary.userId}`,
3022
+ guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: {
3023
+ status: "pending",
3024
+ codeVersion: Number(claim.codeVersion),
3025
+ codeHash: String(claim.codeHash)
3026
+ } }]
3027
+ });
3028
+ return result;
3029
+ } catch {
3030
+ return null;
3031
+ }
3032
+ }
3033
+ async function revokeGiftClaim(db, claim, reason, now) {
3034
+ if (claim.status === "revoked") return false;
3035
+ const ops = [{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
3036
+ status: "revoked",
3037
+ revokedAt: now,
3038
+ revokedReason: reason
3039
+ } }];
3040
+ if (claim.entitlementId) ops.push({ t: "update", ns: "giftEntitlements", id: String(claim.entitlementId), attrs: { active: false, revokedAt: now } });
3041
+ if (claim.applicationId) ops.push({ t: "update", ns: "applications", id: String(claim.applicationId), attrs: {
3042
+ giftRevokedAt: now,
3043
+ ...reason === "refunded" ? { status: "refunded" } : { canceled: true }
3044
+ } });
3045
+ try {
3046
+ await db.transact(ops, {
3047
+ mutationId: `gift-revoke:${String(claim.id)}:${reason}`,
3048
+ guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: String(claim.status) } }]
3049
+ });
3050
+ return true;
3051
+ } catch {
3052
+ return false;
3053
+ }
3054
+ }
3055
+
3056
+ // src/gift-checkout.ts
3057
+ var clientSecret = (value) => {
3058
+ const direct = value.client_secret;
3059
+ if (typeof direct === "string" && direct) return direct;
3060
+ const invoice = value.latest_invoice;
3061
+ const confirmation = invoice?.confirmation_secret;
3062
+ const intent = invoice?.payment_intent;
3063
+ const nested = confirmation?.client_secret ?? intent?.client_secret;
3064
+ return typeof nested === "string" && nested ? nested : null;
3065
+ };
3066
+ async function startGiftProviderCheckout(secretKey, intent, email, existingCustomerId) {
3067
+ const offer = intent.offerSnapshot;
3068
+ const metadata = {
3069
+ giftIntentId: intent.id,
3070
+ chapterId: intent.chapterId,
3071
+ environment: intent.environment,
3072
+ offerId: intent.offerId
3073
+ };
3074
+ let customerId = existingCustomerId;
3075
+ if (!customerId) {
3076
+ const customer = await stripeCall(secretKey, "POST", "/v1/customers", { email, metadata }, `gift-customer:${intent.id}`);
3077
+ if (!customer.ok) throw new Error(`gift customer create failed (${customer.status})`);
3078
+ customerId = String(customer.body.id);
3079
+ }
3080
+ if (offer.billingMode === "subscription") {
3081
+ const created2 = await stripeCall(secretKey, "POST", "/v1/subscriptions", {
3082
+ customer: customerId,
3083
+ "items[0][price]": offer.stripePriceId,
3084
+ payment_behavior: "default_incomplete",
3085
+ "payment_settings[save_default_payment_method]": "on_subscription",
3086
+ "payment_settings[payment_method_types][0]": "card",
3087
+ "expand[0]": "latest_invoice.confirmation_secret",
3088
+ metadata
3089
+ }, `gift-subscription:${intent.id}`);
3090
+ const secret2 = created2.ok ? clientSecret(created2.body) : null;
3091
+ if (!created2.ok || !secret2) throw new Error(`gift subscription create failed (${created2.status})`);
3092
+ return { clientSecret: secret2, providerObjectId: String(created2.body.id), customerId };
3093
+ }
3094
+ const price = await stripeCall(secretKey, "GET", `/v1/prices/${encodeURIComponent(offer.stripePriceId)}`);
3095
+ if (!price.ok || price.body.active === false || price.body.unit_amount !== offer.priceCents || price.body.currency !== offer.currency) {
3096
+ throw new Error("gift price does not match the signed offer");
3097
+ }
3098
+ const created = await stripeCall(secretKey, "POST", "/v1/payment_intents", {
3099
+ customer: customerId,
3100
+ amount: offer.priceCents,
3101
+ currency: offer.currency,
3102
+ "automatic_payment_methods[enabled]": true,
3103
+ metadata
3104
+ }, `gift-payment:${intent.id}`);
3105
+ const secret = created.ok ? clientSecret(created.body) : null;
3106
+ if (!created.ok || !secret) throw new Error(`gift payment create failed (${created.status})`);
3107
+ return { clientSecret: secret, providerObjectId: String(created.body.id), customerId };
3108
+ }
3109
+
3110
+ // src/gift-intents.ts
3111
+ var first2 = (value) => Array.isArray(value) ? value[0] : void 0;
3112
+ async function startGiftIntent(db, input) {
3113
+ let stored = first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents);
3114
+ let giftCode;
3115
+ if (!stored) {
3116
+ const prepared = await prepareGiftCode(input.chapterId);
3117
+ const attrs = {
3118
+ id: input.id,
3119
+ chapterId: input.chapterId,
3120
+ environment: input.environment,
3121
+ purchaserType: input.purchaserType,
3122
+ purchaserEmailNormalized: input.purchaserEmailNormalized,
3123
+ ...input.purchaserApplicationId ? { purchaserApplicationId: input.purchaserApplicationId } : {},
3124
+ ...input.purchaserUserId ? { purchaserUserId: input.purchaserUserId } : {},
3125
+ offerId: input.offer.id,
3126
+ offerSnapshot: input.offer,
3127
+ status: "created",
3128
+ codeHash: prepared.codeHash,
3129
+ codeVersion: prepared.codeVersion,
3130
+ codeIssuedAt: input.now,
3131
+ createdAt: input.now,
3132
+ updatedAt: input.now
3133
+ };
3134
+ const write = await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs }], {
3135
+ mutationId: `gift-intent:${input.id}`
3136
+ });
3137
+ stored = write.duplicate ? first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents) : attrs;
3138
+ if (!write.duplicate) giftCode = prepared.code;
3139
+ }
3140
+ if (!stored || stored.chapterId !== input.chapterId || stored.environment !== input.environment || stored.offerId !== input.offer.id || stored.purchaserEmailNormalized !== input.purchaserEmailNormalized) {
3141
+ throw new Error("gift purchase intent does not match this checkout");
3142
+ }
3143
+ if (stored.status === "paid") throw new Error("gift purchase is already paid");
3144
+ const checkout = await startGiftProviderCheckout(
3145
+ input.stripeSecret,
3146
+ stored,
3147
+ input.purchaserEmail,
3148
+ typeof stored.providerCustomerId === "string" ? stored.providerCustomerId : input.existingCustomerId
3149
+ );
3150
+ await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs: {
3151
+ status: "checkout_ready",
3152
+ providerObjectId: checkout.providerObjectId,
3153
+ ...checkout.customerId ? { providerCustomerId: checkout.customerId } : {},
3154
+ checkoutStartedAt: input.now,
3155
+ updatedAt: input.now
3156
+ } }], { mutationId: `gift-checkout-ready:${input.id}:${checkout.providerObjectId}` });
3157
+ return {
3158
+ gift: true,
3159
+ intentId: input.id,
3160
+ clientSecret: checkout.clientSecret,
3161
+ publishableKey: input.publishableKey,
3162
+ lineItems: { standardCents: input.offer.priceCents, discountCents: 0, dueTodayCents: input.offer.priceCents },
3163
+ ...giftCode ? { giftCode } : {}
3164
+ };
3165
+ }
3166
+
2217
3167
  // src/worker-routes-payments.ts
2218
3168
  var codeOf = (err) => err && typeof err === "object" && typeof err.code === "string" ? err.code : "unknown";
2219
3169
  async function firstRow2(db, ns, q) {
2220
3170
  const res = await db.query({ [ns]: { $: q } });
2221
- const rows2 = res[ns];
2222
- return Array.isArray(rows2) ? rows2[0] : void 0;
3171
+ const rows4 = res[ns];
3172
+ return Array.isArray(rows4) ? rows4[0] : void 0;
2223
3173
  }
2224
3174
  function lineItems(group) {
2225
3175
  const standard = Number(group.standardPriceCents ?? 0);
@@ -2304,6 +3254,36 @@ async function startSubscription(req, env, ctx) {
2304
3254
  const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
2305
3255
  const secretKey = await getVaultSecret(db, "stripe_secret_key");
2306
3256
  if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
3257
+ if (typeof app.giftOfferId === "string") {
3258
+ const control = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
3259
+ const offer = findGiftOffer(control?.document.giftOffers, app.giftOfferId, Date.now());
3260
+ if (!offer || typeof group.stripePublishableKey !== "string" || !group.stripePublishableKey) {
3261
+ return json({ error: "gift offer is unavailable" }, 409);
3262
+ }
3263
+ try {
3264
+ const started = await startGiftIntent(db, {
3265
+ id: typeof app.giftIntentId === "string" ? app.giftIntentId : `gift-intent:${applicationId}`,
3266
+ chapterId: ctx.chapter.id,
3267
+ environment: env.ODLA_ENV,
3268
+ offer,
3269
+ purchaserType: "application",
3270
+ purchaserEmail: String(app.email ?? ""),
3271
+ purchaserEmailNormalized: normalizeAdmissionEmail(String(app.email ?? "")),
3272
+ purchaserApplicationId: applicationId,
3273
+ stripeSecret: secretKey,
3274
+ publishableKey: group.stripePublishableKey,
3275
+ now: Date.now()
3276
+ });
3277
+ await db.transact([{ t: "update", ns: "applications", id: applicationId, attrs: {
3278
+ giftIntentId: started.intentId,
3279
+ giftCheckoutStartedAt: Date.now(),
3280
+ refundPolicyAckAt: Date.now()
3281
+ } }]);
3282
+ return json(started);
3283
+ } catch (error) {
3284
+ return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
3285
+ }
3286
+ }
2307
3287
  const tier = findTier(
2308
3288
  await loadTiers(db, group),
2309
3289
  typeof app.tierId === "string" ? app.tierId : null
@@ -2345,17 +3325,54 @@ async function ingestWebhook(req, env, ctx) {
2345
3325
  if (!ingest.ok) return json({ error: "invalid signature" }, 400);
2346
3326
  const { eventId, event } = ingest;
2347
3327
  if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
3328
+ if (event.kind === "gift_paid" || event.kind === "gift_revoked") {
3329
+ const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
3330
+ if (!intent || intent.chapterId !== ctx.chapter.id || intent.environment !== env.ODLA_ENV) {
3331
+ return json({ ok: true, matched: false });
3332
+ }
3333
+ if (event.kind === "gift_paid") {
3334
+ if (intent.providerObjectId !== event.providerObjectId) return json({ ok: true, matched: false });
3335
+ if (intent.status === "refunded" || intent.status === "canceled") return json({ ok: true, matched: false });
3336
+ try {
3337
+ const issued = await issueGiftClaim(db, intent, {
3338
+ eventId,
3339
+ objectId: event.providerObjectId
3340
+ }, Date.now());
3341
+ return json({ ok: true, matched: true, claimId: issued.claimId });
3342
+ } catch {
3343
+ return json({ error: "gift issuance failed" }, 503);
3344
+ }
3345
+ }
3346
+ await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: event.giftIntentId, attrs: {
3347
+ status: event.reason,
3348
+ updatedAt: Date.now()
3349
+ } }], { mutationId: `gift-revoked-intent:${eventId}` });
3350
+ const claim = await firstRow2(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
3351
+ if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
3352
+ return json({ ok: true, matched: Boolean(claim) });
3353
+ }
2348
3354
  const app = await findApplication(db, event);
2349
3355
  if (!app) return json({ ok: true, matched: false });
2350
3356
  const patch = webhookPatch(event, String(app.status ?? ""));
3357
+ let next = app;
2351
3358
  if (Object.keys(patch).length) {
2352
3359
  await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
2353
- const next = { ...app, ...patch };
3360
+ next = { ...app, ...patch };
2354
3361
  await syncApplicationToCrm(crmDeps(db, ctx), { app: next, stage: String(next.status ?? "") }).catch(() => void 0);
2355
3362
  }
2356
3363
  if (event.kind === "first_payment") {
2357
3364
  await notifyPaymentConfirmed(db, env, eventId, app);
2358
3365
  if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
3366
+ if (typeof next.interviewWaivedAt === "number") {
3367
+ await approveApplication({
3368
+ db,
3369
+ env,
3370
+ ctx,
3371
+ app: next,
3372
+ origin: new URL(req.url).origin,
3373
+ reason: "interview_waiver"
3374
+ });
3375
+ }
2359
3376
  }
2360
3377
  return json({ ok: true });
2361
3378
  }
@@ -2366,6 +3383,144 @@ var handlePayments = async (req, url, env, ctx) => {
2366
3383
  return null;
2367
3384
  };
2368
3385
 
3386
+ // src/worker-routes-gifts.ts
3387
+ var first3 = (value) => Array.isArray(value) ? value[0] : void 0;
3388
+ var rows2 = (value) => Array.isArray(value) ? value : [];
3389
+ var invalid = () => json({ error: "gift code is invalid or unavailable" }, 400);
3390
+ var userFor = async (req, env, ctx) => {
3391
+ const user = await ctx.verifyUser(req, env);
3392
+ return user?.email ? user : null;
3393
+ };
3394
+ var loadClaim = async (db, id) => first3((await db.query({ giftClaims: { $: { where: { id }, limit: 1 } } })).giftClaims);
3395
+ async function consumeRateLimit(db, chapterId, requester, now) {
3396
+ const bucket = await giftRequesterBucket(chapterId, requester);
3397
+ const id = `gift-rate:${chapterId}:${bucket}`;
3398
+ const current = first3((await db.query({ giftRedemptionAttempts: { $: { where: { id }, limit: 1 } } })).giftRedemptionAttempts);
3399
+ const within = Boolean(current && now - Number(current.windowStartedAt) < 15 * 6e4);
3400
+ const attemptCount = within ? Number(current?.attemptCount ?? 0) + 1 : 1;
3401
+ if (attemptCount > 10) return false;
3402
+ await db.transact([{ t: "update", ns: "giftRedemptionAttempts", id, attrs: {
3403
+ id,
3404
+ chapterId,
3405
+ bucket,
3406
+ attemptCount,
3407
+ windowStartedAt: within ? Number(current?.windowStartedAt) : now,
3408
+ updatedAt: now
3409
+ } }]);
3410
+ return true;
3411
+ }
3412
+ var handleGifts = async (req, url, env, ctx) => {
3413
+ if (ctx.chapter.mode !== "chapter" || !url.pathname.startsWith("/api/gifts")) return null;
3414
+ const db = ctx.makeDb(env);
3415
+ if (req.method === "GET" && url.pathname === "/api/gifts") {
3416
+ const user = await userFor(req, env, ctx);
3417
+ if (!user) return json({ error: "unauthorized" }, 401);
3418
+ const control = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
3419
+ const found = await db.query({ giftClaims: { $: { where: {
3420
+ purchaserEmailNormalized: normalizeAdmissionEmail(user.email),
3421
+ chapterId: ctx.chapter.id
3422
+ }, limit: 200 } } });
3423
+ const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
3424
+ return json({
3425
+ offers: publicGiftOffers(control?.document.giftOffers, Date.now()),
3426
+ claims: buyerGiftClaims(rows2(found.giftClaims).sort((a, b) => Number(b.createdAt) - Number(a.createdAt)), Date.now()),
3427
+ refundPolicyText: typeof group?.refundPolicyText === "string" ? group.refundPolicyText : ""
3428
+ });
3429
+ }
3430
+ if (req.method === "POST" && url.pathname === "/api/gifts/checkout") {
3431
+ const user = await userFor(req, env, ctx);
3432
+ if (!user) return json({ error: "unauthorized" }, 401);
3433
+ let body;
3434
+ try {
3435
+ body = JSON.parse(await req.text());
3436
+ } catch {
3437
+ return json({ error: "invalid JSON body" }, 400);
3438
+ }
3439
+ const purchaseId = typeof body.purchaseId === "string" ? body.purchaseId : "";
3440
+ if (!/^[A-Za-z0-9_-]{16,128}$/.test(purchaseId)) return json({ error: "purchaseId is invalid" }, 400);
3441
+ const control = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
3442
+ const offer = findGiftOffer(control?.document.giftOffers, typeof body.offerId === "string" ? body.offerId : "", Date.now());
3443
+ if (!offer) return json({ error: "gift offer is unavailable" }, 409);
3444
+ const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
3445
+ const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
3446
+ if (!group || !stripeSecret || typeof group.stripePublishableKey !== "string") return json({ error: "payments not configured" }, 503);
3447
+ const emailNormalized = normalizeAdmissionEmail(user.email);
3448
+ const applications = rows2((await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications);
3449
+ const app = applications.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
3450
+ try {
3451
+ return json(await startGiftIntent(db, {
3452
+ id: `gift-intent:${ctx.chapter.id}:${purchaseId}`,
3453
+ chapterId: ctx.chapter.id,
3454
+ environment: env.ODLA_ENV,
3455
+ offer,
3456
+ purchaserType: "member",
3457
+ purchaserEmail: user.email,
3458
+ purchaserEmailNormalized: emailNormalized,
3459
+ purchaserUserId: user.userId,
3460
+ ...typeof app?.stripeCustomerId === "string" ? { existingCustomerId: app.stripeCustomerId } : {},
3461
+ stripeSecret,
3462
+ publishableKey: group.stripePublishableKey,
3463
+ now: Date.now()
3464
+ }));
3465
+ } catch (error) {
3466
+ return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
3467
+ }
3468
+ }
3469
+ const rotateMatch = url.pathname.match(/^\/api\/gifts\/([^/]+)\/rotate$/);
3470
+ if (req.method === "POST" && rotateMatch) {
3471
+ let claimId = "";
3472
+ try {
3473
+ claimId = decodeURIComponent(rotateMatch[1]);
3474
+ } catch {
3475
+ return invalid();
3476
+ }
3477
+ if (!claimId.startsWith("gift-claim:")) return invalid();
3478
+ const claim = await loadClaim(db, claimId);
3479
+ if (!claim || claim.chapterId !== ctx.chapter.id || Number(claim.rotationCount ?? 0) >= 10) return invalid();
3480
+ const user = await userFor(req, env, ctx);
3481
+ let applicationId = "";
3482
+ if (!user) {
3483
+ try {
3484
+ applicationId = String(JSON.parse(await req.text()).applicationId ?? "");
3485
+ } catch {
3486
+ return invalid();
3487
+ }
3488
+ }
3489
+ const owns = user ? claim.purchaserEmailNormalized === normalizeAdmissionEmail(user.email) : claim.purchaserApplicationId === applicationId;
3490
+ if (!owns) return invalid();
3491
+ const rotated = await rotateGiftClaim(db, claim, Date.now());
3492
+ return rotated ? json(rotated) : invalid();
3493
+ }
3494
+ if (req.method === "POST" && url.pathname === "/api/gifts/redeem") {
3495
+ const user = await userFor(req, env, ctx);
3496
+ if (!user) return json({ error: "unauthorized" }, 401);
3497
+ const now = Date.now();
3498
+ if (!await consumeRateLimit(db, ctx.chapter.id, `${user.userId}:${req.headers.get("cf-connecting-ip") ?? "no-ip"}`, now)) {
3499
+ return json({ error: "too many attempts" }, 429);
3500
+ }
3501
+ let code = "";
3502
+ try {
3503
+ code = String(JSON.parse(await req.text()).code ?? "").trim();
3504
+ } catch {
3505
+ return invalid();
3506
+ }
3507
+ if (!/^gift1_[A-Za-z0-9_-]{43}$/.test(code)) return invalid();
3508
+ const codeHash = await hashGiftCode(ctx.chapter.id, code);
3509
+ const claim = first3((await db.query({ giftClaims: { $: { where: { codeHash }, limit: 1 } } })).giftClaims);
3510
+ if (!claim || claim.chapterId !== ctx.chapter.id) return invalid();
3511
+ if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) {
3512
+ await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
3513
+ attemptCount: Number(claim.attemptCount ?? 0) + 1,
3514
+ ...claim.status === "pending" ? { status: "expired" } : {}
3515
+ } }]).catch(() => void 0);
3516
+ return invalid();
3517
+ }
3518
+ const redeemed = await redeemGiftClaim(db, claim, { email: user.email, userId: user.userId }, now);
3519
+ return redeemed ? json({ ok: true, ...redeemed }) : invalid();
3520
+ }
3521
+ return null;
3522
+ };
3523
+
2369
3524
  // src/worker-routes-admin.ts
2370
3525
  import { initCalendar as initCalendar2 } from "@odla-ai/calendar";
2371
3526
 
@@ -2437,8 +3592,8 @@ async function upcomingEvents(env) {
2437
3592
  const res = await cal.availability.upcoming();
2438
3593
  return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
2439
3594
  }
2440
- function toReconcile(rows2) {
2441
- return rows2.map((m) => ({
3595
+ function toReconcile(rows4) {
3596
+ return rows4.map((m) => ({
2442
3597
  id: String(m.id),
2443
3598
  applicationId: String(m.applicationId),
2444
3599
  googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
@@ -2460,12 +3615,12 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
2460
3615
  const query = await db.query({
2461
3616
  meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
2462
3617
  });
2463
- let rows2 = Array.isArray(query.meetings) ? query.meetings : [];
2464
- if (Number.isFinite(from)) rows2 = rows2.filter((m) => Number(m.startAt ?? 0) >= from);
2465
- if (Number.isFinite(to)) rows2 = rows2.filter((m) => Number(m.startAt ?? 0) <= to);
3618
+ let rows4 = Array.isArray(query.meetings) ? query.meetings : [];
3619
+ if (Number.isFinite(from)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) >= from);
3620
+ if (Number.isFinite(to)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) <= to);
2466
3621
  let decisions = [];
2467
3622
  try {
2468
- decisions = reconcileMeetings(toReconcile(rows2), await upcomingEvents(env), Date.now());
3623
+ decisions = reconcileMeetings(toReconcile(rows4), await upcomingEvents(env), Date.now());
2469
3624
  } catch {
2470
3625
  }
2471
3626
  const patched = /* @__PURE__ */ new Map();
@@ -2492,69 +3647,10 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
2492
3647
  };
2493
3648
  const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
2494
3649
  const timezone = resolveScheduling(group?.schedulingJson).timezone;
2495
- const meetings = rows2.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
3650
+ const meetings = rows4.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
2496
3651
  return json({ meetings, adopted: decisions.length, timezone });
2497
3652
  };
2498
3653
 
2499
- // src/clerk-roles.ts
2500
- var CLERK_API = "https://api.clerk.com";
2501
- var DEFAULT_ROLE = "provisional";
2502
- var PAGE = 100;
2503
- function toRecord(u) {
2504
- if (typeof u.id !== "string") return null;
2505
- const pm = u.public_metadata ?? {};
2506
- const privateMetadata = u.private_metadata ?? {};
2507
- const rawProfile = privateMetadata.profile;
2508
- const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
2509
- const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
2510
- const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
2511
- const email = u.email_addresses?.[0]?.email_address;
2512
- return {
2513
- id: u.id,
2514
- email: typeof email === "string" ? email : void 0,
2515
- role,
2516
- publicMetadata: pm,
2517
- privateMetadata,
2518
- profile,
2519
- ...applicationId ? { applicationId } : {}
2520
- };
2521
- }
2522
- async function clerkGet(path, secretKey, fetchImpl) {
2523
- const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
2524
- if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
2525
- return res.json();
2526
- }
2527
- async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
2528
- const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
2529
- const user = Array.isArray(data) ? data[0] : void 0;
2530
- return user ? toRecord(user) : null;
2531
- }
2532
- async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
2533
- const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
2534
- return data ? toRecord(data) : null;
2535
- }
2536
- async function clerkListUsers(secretKey, fetchImpl = fetch) {
2537
- const out = [];
2538
- for (let offset = 0; ; offset += PAGE) {
2539
- const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
2540
- const page = Array.isArray(data) ? data : [];
2541
- for (const u of page) {
2542
- const record = toRecord(u);
2543
- if (record) out.push(record);
2544
- }
2545
- if (page.length < PAGE) break;
2546
- }
2547
- return out;
2548
- }
2549
- async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
2550
- const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
2551
- method: "PATCH",
2552
- headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
2553
- body: JSON.stringify({ public_metadata: { role } })
2554
- });
2555
- return res.ok;
2556
- }
2557
-
2558
3654
  // src/worker-routes-admin-people.ts
2559
3655
  async function adminGate(req, env, ctx) {
2560
3656
  const rawDb = ctx.makeDb(env);
@@ -2572,16 +3668,16 @@ var crmDeps2 = (db, ctx) => ({
2572
3668
  });
2573
3669
  var handleAdminCrmSync = async (req, url, env, ctx) => {
2574
3670
  if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
2575
- const gate8 = await adminGate(req, env, ctx);
2576
- if (gate8 instanceof Response) return gate8;
2577
- const result = await backfillCrm(crmDeps2(gate8.db, ctx));
3671
+ const gate9 = await adminGate(req, env, ctx);
3672
+ if (gate9 instanceof Response) return gate9;
3673
+ const result = await backfillCrm(crmDeps2(gate9.db, ctx));
2578
3674
  return json({ ok: true, ...result });
2579
3675
  };
2580
3676
  var handleAdminPeople = async (req, url, env, ctx) => {
2581
3677
  if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
2582
- const gate8 = await adminGate(req, env, ctx);
2583
- if (gate8 instanceof Response) return gate8;
2584
- const { db } = gate8;
3678
+ const gate9 = await adminGate(req, env, ctx);
3679
+ if (gate9 instanceof Response) return gate9;
3680
+ const { db } = gate9;
2585
3681
  const sk = await getVaultSecret(db, "clerk_secret_key");
2586
3682
  const [appsRes, usersRes, roleList] = await Promise.all([
2587
3683
  db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
@@ -2625,16 +3721,16 @@ var handleAdminPeople = async (req, url, env, ctx) => {
2625
3721
  });
2626
3722
  }
2627
3723
  }
2628
- const rows2 = [...people.values()].sort(
3724
+ const rows4 = [...people.values()].sort(
2629
3725
  (x, y) => (y.application?.createdAt ?? -1) - (x.application?.createdAt ?? -1)
2630
3726
  );
2631
- return json({ people: rows2 });
3727
+ return json({ people: rows4 });
2632
3728
  };
2633
3729
  var handleAdminPeopleAccess = async (req, url, env, ctx) => {
2634
3730
  if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
2635
- const gate8 = await adminGate(req, env, ctx);
2636
- if (gate8 instanceof Response) return gate8;
2637
- const { db } = gate8;
3731
+ const gate9 = await adminGate(req, env, ctx);
3732
+ if (gate9 instanceof Response) return gate9;
3733
+ const { db } = gate9;
2638
3734
  const targetId = url.searchParams.get("userId") ?? "";
2639
3735
  if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
2640
3736
  const sk = await getVaultSecret(db, "clerk_secret_key");
@@ -2645,9 +3741,9 @@ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
2645
3741
  };
2646
3742
  var handleAdminPeopleRole = async (req, url, env, ctx) => {
2647
3743
  if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
2648
- const gate8 = await adminGate(req, env, ctx);
2649
- if (gate8 instanceof Response) return gate8;
2650
- const { db, actor } = gate8;
3744
+ const gate9 = await adminGate(req, env, ctx);
3745
+ if (gate9 instanceof Response) return gate9;
3746
+ const { db, actor } = gate9;
2651
3747
  let body;
2652
3748
  try {
2653
3749
  body = await req.json();
@@ -2794,17 +3890,17 @@ function subAnnualCents(sub) {
2794
3890
  }
2795
3891
 
2796
3892
  // src/worker-routes-admin-dashboard.ts
2797
- async function gate(req, env, ctx) {
3893
+ async function gate2(req, env, ctx) {
2798
3894
  const rawDb = ctx.makeDb(env);
2799
3895
  const u = await ctx.verifyUser(req, env);
2800
3896
  if (!u) return json({ error: "unauthorized" }, 401);
2801
3897
  if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
2802
3898
  return rawDb;
2803
3899
  }
2804
- var rows = (v) => Array.isArray(v) ? v : [];
3900
+ var rows3 = (v) => Array.isArray(v) ? v : [];
2805
3901
  var handleAdminDashboard = async (req, url, env, ctx) => {
2806
3902
  if (req.method !== "GET" || url.pathname !== "/api/admin/dashboard") return null;
2807
- const got = await gate(req, env, ctx);
3903
+ const got = await gate2(req, env, ctx);
2808
3904
  if (got instanceof Response) return got;
2809
3905
  const db = got;
2810
3906
  const now = Date.now();
@@ -2816,7 +3912,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2816
3912
  db.query({ crm_record: { $: { where: { type: "person" }, limit: 1e3 } } }),
2817
3913
  db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })
2818
3914
  ]);
2819
- const apps = rows(appsRes.applications);
3915
+ const apps = rows3(appsRes.applications);
2820
3916
  const applications = {
2821
3917
  total: apps.length,
2822
3918
  last7: apps.filter((a) => a.createdAt >= d7).length,
@@ -2828,7 +3924,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2828
3924
  pipeline[s] = 0;
2829
3925
  pipelineDelta[s] = 0;
2830
3926
  }
2831
- for (const r of rows(recsRes.crm_record)) {
3927
+ for (const r of rows3(recsRes.crm_record)) {
2832
3928
  const s = r.stage;
2833
3929
  if (s in pipeline) {
2834
3930
  pipeline[s] = (pipeline[s] ?? 0) + 1;
@@ -2836,7 +3932,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2836
3932
  if (Number.isFinite(sc) && sc >= d7) pipelineDelta[s] = (pipelineDelta[s] ?? 0) + 1;
2837
3933
  }
2838
3934
  }
2839
- const meetings = rows(meetingsRes.meetings);
3935
+ const meetings = rows3(meetingsRes.meetings);
2840
3936
  const appById = new Map(apps.map((a) => [a.id, a]));
2841
3937
  const upcoming = meetings.filter((m) => m.startAt >= now - 36e5);
2842
3938
  const calls = { upcoming: upcoming.length, needsAttention: meetings.filter((m) => m.drift && m.drift !== "none").length };
@@ -2861,7 +3957,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2861
3957
  if (sk) {
2862
3958
  const subsRes = await stripeCall(sk, "GET", "/v1/subscriptions", { limit: 100, status: "all" });
2863
3959
  if (subsRes.ok) {
2864
- const subs = rows(subsRes.body.data);
3960
+ const subs = rows3(subsRes.body.data);
2865
3961
  const subMs = (s) => (s.created ?? 0) * 1e3;
2866
3962
  membersSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: 1 })), now);
2867
3963
  revenueSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: subAnnualCents(s) })), now);
@@ -2881,7 +3977,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2881
3977
  };
2882
3978
  var handleAdminBilling = async (req, url, env, ctx) => {
2883
3979
  if (req.method !== "GET" || url.pathname !== "/api/admin/billing") return null;
2884
- const got = await gate(req, env, ctx);
3980
+ const got = await gate2(req, env, ctx);
2885
3981
  if (got instanceof Response) return got;
2886
3982
  const db = got;
2887
3983
  const sk = await getVaultSecret(db, "stripe_secret_key");
@@ -2893,9 +3989,9 @@ var handleAdminBilling = async (req, url, env, ctx) => {
2893
3989
  ]);
2894
3990
  const testMode = String(groupRes.groups?.[0]?.stripePublishableKey ?? "").startsWith("pk_test");
2895
3991
  if (!subsRes.ok) return json({ error: "billing lookup failed upstream" }, 502);
2896
- const subById = new Map(rows(subsRes.body.data).map((s) => [s.id, s]));
3992
+ const subById = new Map(rows3(subsRes.body.data).map((s) => [s.id, s]));
2897
3993
  const billingRows = [];
2898
- for (const a of rows(appsRes.applications)) {
3994
+ for (const a of rows3(appsRes.applications)) {
2899
3995
  if (!a.stripeCustomerId && !a.stripeSubscriptionId) continue;
2900
3996
  const sub = a.stripeSubscriptionId ? subById.get(a.stripeSubscriptionId) : void 0;
2901
3997
  const items = sub?.items?.data ?? [];
@@ -2939,7 +4035,7 @@ var handleAdminBilling = async (req, url, env, ctx) => {
2939
4035
 
2940
4036
  // src/worker-routes-admin-lifecycle.ts
2941
4037
  import { computeBookableSlots as computeBookableSlots2, initCalendar as initCalendar3 } from "@odla-ai/calendar";
2942
- async function gate2(req, env, ctx) {
4038
+ async function gate3(req, env, ctx) {
2943
4039
  const rawDb = ctx.makeDb(env);
2944
4040
  const u = await ctx.verifyUser(req, env);
2945
4041
  if (!u) return json({ error: "unauthorized" }, 401);
@@ -2965,7 +4061,7 @@ async function loadGroup(db, id) {
2965
4061
  var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
2966
4062
  const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/reschedule$/);
2967
4063
  if (req.method !== "POST" || !m) return null;
2968
- const got = await gate2(req, env, ctx);
4064
+ const got = await gate3(req, env, ctx);
2969
4065
  if (got instanceof Response) return got;
2970
4066
  const db = got;
2971
4067
  const body = await readJson(req);
@@ -3006,7 +4102,7 @@ var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
3006
4102
  var handleAdminMeetingCancel = async (req, url, env, ctx) => {
3007
4103
  const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/cancel$/);
3008
4104
  if (req.method !== "POST" || !m) return null;
3009
- const got = await gate2(req, env, ctx);
4105
+ const got = await gate3(req, env, ctx);
3010
4106
  if (got instanceof Response) return got;
3011
4107
  const db = got;
3012
4108
  const { meetings } = await db.query({ meetings: { $: { where: { id: m[1] }, limit: 1 } } });
@@ -3029,43 +4125,20 @@ var handleAdminMeetingCancel = async (req, url, env, ctx) => {
3029
4125
  var handleAdminApprove = async (req, url, env, ctx) => {
3030
4126
  const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/approve$/);
3031
4127
  if (req.method !== "POST" || !m) return null;
3032
- const got = await gate2(req, env, ctx);
4128
+ const got = await gate3(req, env, ctx);
3033
4129
  if (got instanceof Response) return got;
3034
4130
  const db = got;
3035
4131
  const id = m[1];
3036
4132
  const app = await loadApp(db, id);
3037
4133
  if (!app) return json({ error: "not found" }, 404);
3038
- if (!canApprove(String(app.status), ctx.chapter.pipeline)) return json({ error: `cannot approve from status "${String(app.status)}"` }, 409);
3039
- const target = "approved";
3040
- await db.transact([{ t: "update", ns: "applications", id, attrs: { status: target } }]);
3041
- await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
3042
- const { promoteTo, send } = ctx.chapter.operations.onApprove;
3043
- let rolePromoted = false;
3044
- const sk = await getVaultSecret(db, "clerk_secret_key");
3045
- if (promoteTo !== false && sk) {
3046
- let userId = app.clerkUserId || null;
3047
- if (!userId) {
3048
- const found = await clerkGetUserByEmail(sk, String(app.email));
3049
- userId = found?.id ?? null;
3050
- if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
3051
- }
3052
- if (userId) rolePromoted = await clerkSetRole(sk, userId, promoteTo);
3053
- }
3054
- let emailLogged = false;
3055
- const group = await loadGroup(db, String(app.groupId ?? ctx.chapter.id));
3056
- if (send !== false && group) {
3057
- const res = await sendTemplated(
3058
- { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
3059
- { group: emailGroupFrom(group), template: send, to: String(app.email), vars: { firstName: String(app.firstName ?? ""), membersUrl: `${url.origin}/members/` }, applicationId: id, dedupeKey: `approve:${id}` }
3060
- );
3061
- emailLogged = res.sent;
3062
- }
3063
- return json({ ok: true, status: target, rolePromoted, emailLogged });
4134
+ const result = await approveApplication({ db, env, ctx, app, origin: url.origin, reason: "manual" });
4135
+ if (!result.ok) return json({ error: result.error }, result.status);
4136
+ return json({ ok: true, status: "approved", rolePromoted: result.rolePromoted, emailLogged: result.emailLogged });
3064
4137
  };
3065
4138
  var handleAdminRefund = async (req, url, env, ctx) => {
3066
4139
  const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/refund$/);
3067
4140
  if (req.method !== "POST" || !m) return null;
3068
- const got = await gate2(req, env, ctx);
4141
+ const got = await gate3(req, env, ctx);
3069
4142
  if (got instanceof Response) return got;
3070
4143
  const db = got;
3071
4144
  const app = await loadApp(db, m[1]);
@@ -3097,7 +4170,7 @@ var handleAdminRefund = async (req, url, env, ctx) => {
3097
4170
  var handleAdminApplicationPatch = async (req, url, env, ctx) => {
3098
4171
  const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)$/);
3099
4172
  if (req.method !== "PATCH" || !m) return null;
3100
- const got = await gate2(req, env, ctx);
4173
+ const got = await gate3(req, env, ctx);
3101
4174
  if (got instanceof Response) return got;
3102
4175
  const db = got;
3103
4176
  const id = m[1];
@@ -3125,7 +4198,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
3125
4198
  };
3126
4199
 
3127
4200
  // src/worker-routes-admin-comms.ts
3128
- async function gate3(req, env, ctx) {
4201
+ async function gate4(req, env, ctx) {
3129
4202
  const rawDb = ctx.makeDb(env);
3130
4203
  const u = await ctx.verifyUser(req, env);
3131
4204
  if (!u) return json({ error: "unauthorized" }, 401);
@@ -3147,7 +4220,7 @@ var notifyDeps = (db, env) => ({
3147
4220
  });
3148
4221
  var handleAdminGroupEmail = async (req, url, env, ctx) => {
3149
4222
  if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
3150
- const got = await gate3(req, env, ctx);
4223
+ const got = await gate4(req, env, ctx);
3151
4224
  if (got instanceof Response) return got;
3152
4225
  const db = got;
3153
4226
  const group = await loadGroup2(db, ctx.chapter.id);
@@ -3188,11 +4261,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
3188
4261
  for (const key of EMAIL_TEMPLATE_NAMES) {
3189
4262
  const t = templates[key];
3190
4263
  const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
3191
- const text2 = typeof t?.text === "string" ? t.text : "";
3192
- if (!subject || !text2.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
4264
+ const text3 = typeof t?.text === "string" ? t.text : "";
4265
+ if (!subject || !text3.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
3193
4266
  if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key}" subject must be a single line under 200 characters` }, 400);
3194
- if (text2.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
3195
- clean[key] = { subject, text: text2, enabled: t?.enabled !== false };
4267
+ if (text3.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
4268
+ clean[key] = { subject, text: text3, enabled: t?.enabled !== false };
3196
4269
  }
3197
4270
  const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3198
4271
  const notificationEmail = str2(body.notificationEmail).trim();
@@ -3209,7 +4282,7 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
3209
4282
  };
3210
4283
  var handleAdminEmailLog = async (req, url, env, ctx) => {
3211
4284
  if (req.method !== "GET" || url.pathname !== "/api/admin/email/log") return null;
3212
- const got = await gate3(req, env, ctx);
4285
+ const got = await gate4(req, env, ctx);
3213
4286
  if (got instanceof Response) return got;
3214
4287
  const { emailLog } = await got.query({ emailLog: { $: { order: { sentAt: "desc" }, limit: 50 } } });
3215
4288
  const sends = (emailLog ?? []).map((r) => ({
@@ -3226,7 +4299,7 @@ var handleAdminEmailLog = async (req, url, env, ctx) => {
3226
4299
  };
3227
4300
  var handleAdminEmailTest = async (req, url, env, ctx) => {
3228
4301
  if (req.method !== "POST" || url.pathname !== "/api/admin/email/test") return null;
3229
- const got = await gate3(req, env, ctx);
4302
+ const got = await gate4(req, env, ctx);
3230
4303
  if (got instanceof Response) return got;
3231
4304
  const db = got;
3232
4305
  let body;
@@ -3254,7 +4327,7 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
3254
4327
  var handleAdminComms = async (req, url, env, ctx) => {
3255
4328
  const m = url.pathname.match(/^\/api\/admin\/people\/([0-9a-fA-F-]+)\/comms$/);
3256
4329
  if (req.method !== "GET" || !m) return null;
3257
- const got = await gate3(req, env, ctx);
4330
+ const got = await gate4(req, env, ctx);
3258
4331
  if (got instanceof Response) return got;
3259
4332
  const db = got;
3260
4333
  const appId = m[1];
@@ -3287,7 +4360,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
3287
4360
 
3288
4361
  // src/worker-routes-network.ts
3289
4362
  import { getRecord, typeSummary } from "@odla-ai/crm";
3290
- import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest3 } from "@odla-ai/db";
4363
+ import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest4 } from "@odla-ai/db";
3291
4364
 
3292
4365
  // src/worker-network-fetch.ts
3293
4366
  function targetFetcher(env, target) {
@@ -3306,7 +4379,7 @@ function fetchNetworkTarget(env, target, destination, init) {
3306
4379
  // src/worker-network-push.ts
3307
4380
  import { recordDeliveryAttempt } from "@odla-ai/crm";
3308
4381
  import { signFederatedRequest } from "@odla-ai/db";
3309
- async function pushNetworkRecord(db, env, ctx, target, record) {
4382
+ async function pushNetworkRecord(db, env, ctx, target, record2) {
3310
4383
  const secret = await getVaultSecret(db, target.secretName);
3311
4384
  const crmDeps3 = {
3312
4385
  crm: ctx.chapter.crm,
@@ -3317,7 +4390,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3317
4390
  if (!secret) {
3318
4391
  const error = `vault secret "${target.secretName}" is missing`;
3319
4392
  await recordDeliveryAttempt(crmDeps3, {
3320
- recordId: record.id,
4393
+ recordId: record2.id,
3321
4394
  targetId: target.id,
3322
4395
  status: "failed",
3323
4396
  payloadVersion: 2,
@@ -3327,7 +4400,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3327
4400
  }
3328
4401
  let payload;
3329
4402
  try {
3330
- payload = sharedRecordFromCrm(ctx.chapter.crm, record, target, ctx.chapter.id);
4403
+ payload = sharedRecordFromCrm(ctx.chapter.crm, record2, target, ctx.chapter.id);
3331
4404
  } catch (err) {
3332
4405
  return {
3333
4406
  id: target.id,
@@ -3356,7 +4429,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3356
4429
  if (!res.ok) {
3357
4430
  const error = responseBody.error ?? "follower rejected the record";
3358
4431
  await recordDeliveryAttempt(crmDeps3, {
3359
- recordId: record.id,
4432
+ recordId: record2.id,
3360
4433
  targetId: target.id,
3361
4434
  status: "failed",
3362
4435
  payloadVersion: 2,
@@ -3371,7 +4444,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3371
4444
  };
3372
4445
  }
3373
4446
  await recordDeliveryAttempt(crmDeps3, {
3374
- recordId: record.id,
4447
+ recordId: record2.id,
3375
4448
  targetId: target.id,
3376
4449
  status: "delivered",
3377
4450
  payloadVersion: 2,
@@ -3387,7 +4460,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3387
4460
  } catch (err) {
3388
4461
  const error = err instanceof Error ? err.message : "delivery failed";
3389
4462
  await recordDeliveryAttempt(crmDeps3, {
3390
- recordId: record.id,
4463
+ recordId: record2.id,
3391
4464
  targetId: target.id,
3392
4465
  status: "failed",
3393
4466
  payloadVersion: 2,
@@ -3398,7 +4471,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3398
4471
  }
3399
4472
 
3400
4473
  // src/worker-routes-network.ts
3401
- async function gate4(req, env, ctx) {
4474
+ async function gate5(req, env, ctx) {
3402
4475
  const db = ctx.makeDb(env);
3403
4476
  const user = await ctx.verifyUser(req, env);
3404
4477
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3407,7 +4480,7 @@ async function gate4(req, env, ctx) {
3407
4480
  }
3408
4481
  var handleAdminNetworkTargets = async (req, url, env, ctx) => {
3409
4482
  if (req.method !== "GET" || url.pathname !== "/api/admin/network/targets") return null;
3410
- const got = await gate4(req, env, ctx);
4483
+ const got = await gate5(req, env, ctx);
3411
4484
  if ("response" in got) return got.response;
3412
4485
  return json({
3413
4486
  targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
@@ -3424,7 +4497,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
3424
4497
  const db = ctx.makeDb(env);
3425
4498
  const secret = await getVaultSecret(db, "network_share_secret");
3426
4499
  if (!secret) return json({ error: "unauthorized" }, 401);
3427
- const verified = await verifyFederatedRequest3(req, { secret });
4500
+ const verified = await verifyFederatedRequest4(req, { secret });
3428
4501
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
3429
4502
  const types = await Promise.all(
3430
4503
  Object.keys(ctx.chapter.crm.config.types).map((type) => typeSummary({ crm: ctx.chapter.crm, db }, type))
@@ -3483,7 +4556,7 @@ async function snapshotOne(db, env, ctx, target) {
3483
4556
  }
3484
4557
  var handleAdminNetworkRollup = async (req, url, env, ctx) => {
3485
4558
  if (req.method !== "GET" || url.pathname !== "/api/admin/network/rollup") return null;
3486
- const got = await gate4(req, env, ctx);
4559
+ const got = await gate5(req, env, ctx);
3487
4560
  if ("response" in got) return got.response;
3488
4561
  const targets = await Promise.all(
3489
4562
  ctx.chapter.network.targets.map((target) => snapshotOne(got.db, env, ctx, target))
@@ -3510,7 +4583,7 @@ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
3510
4583
  };
3511
4584
  var handleAdminNetworkPush = async (req, url, env, ctx) => {
3512
4585
  if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
3513
- const got = await gate4(req, env, ctx);
4586
+ const got = await gate5(req, env, ctx);
3514
4587
  if ("response" in got) return got.response;
3515
4588
  let body;
3516
4589
  try {
@@ -3525,22 +4598,22 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
3525
4598
  if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
3526
4599
  const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
3527
4600
  if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
3528
- const record = await getRecord({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
3529
- if (!record) return json({ error: "record not found" }, 404);
4601
+ const record2 = await getRecord({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
4602
+ if (!record2) return json({ error: "record not found" }, 404);
3530
4603
  const results = await Promise.all(
3531
- targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record))
4604
+ targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record2))
3532
4605
  );
3533
4606
  return json({ ok: results.every((result) => result.ok), results });
3534
4607
  };
3535
4608
 
3536
4609
  // src/worker-routes-network-records.ts
3537
4610
  import { listRecords } from "@odla-ai/crm";
3538
- import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest4 } from "@odla-ai/db";
4611
+ import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest5 } from "@odla-ai/db";
3539
4612
  var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
3540
4613
  var MAX_PAGE = 50;
3541
4614
  var MAX_OFFSET = 1e4;
3542
4615
  var MAX_SEARCH = 200;
3543
- async function gate5(req, env, ctx) {
4616
+ async function gate6(req, env, ctx) {
3544
4617
  const db = ctx.makeDb(env);
3545
4618
  const user = await ctx.verifyUser(req, env);
3546
4619
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3553,18 +4626,18 @@ function integer(value, fallback, min, max) {
3553
4626
  const parsed = Number(value);
3554
4627
  return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
3555
4628
  }
3556
- function projectRecord(record, allowed) {
4629
+ function projectRecord(record2, allowed) {
3557
4630
  const fields = Object.fromEntries(
3558
- allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(record.fields, name) ? [[name, record.fields[name]]] : [])
4631
+ allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(record2.fields, name) ? [[name, record2.fields[name]]] : [])
3559
4632
  );
3560
4633
  return {
3561
- id: record.id,
3562
- type: record.type,
3563
- name: record.name,
3564
- ...record.stage ? { stage: record.stage } : {},
4634
+ id: record2.id,
4635
+ type: record2.type,
4636
+ name: record2.name,
4637
+ ...record2.stage ? { stage: record2.stage } : {},
3565
4638
  fields,
3566
- createdAt: record.createdAt,
3567
- updatedAt: record.updatedAt
4639
+ createdAt: record2.createdAt,
4640
+ updatedAt: record2.updatedAt
3568
4641
  };
3569
4642
  }
3570
4643
  var handleNetworkRecords = async (req, url, env, ctx) => {
@@ -3573,7 +4646,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
3573
4646
  const secret = await getVaultSecret(db, "network_share_secret");
3574
4647
  const readers = ctx.chapter.network.readers;
3575
4648
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
3576
- const verified = await verifyFederatedRequest4(req, {
4649
+ const verified = await verifyFederatedRequest5(req, {
3577
4650
  secret,
3578
4651
  senders: readers.map((reader2) => reader2.id)
3579
4652
  });
@@ -3602,7 +4675,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
3602
4675
  version: 1,
3603
4676
  site: { id: ctx.chapter.id, name: ctx.chapter.name },
3604
4677
  type,
3605
- records: page.records.map((record) => projectRecord(record, allowed)),
4678
+ records: page.records.map((record2) => projectRecord(record2, allowed)),
3606
4679
  total: page.total,
3607
4680
  limit: page.limit,
3608
4681
  offset: page.offset
@@ -3643,7 +4716,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
3643
4716
  }
3644
4717
  var handleAdminNetworkRecords = async (req, url, env, ctx) => {
3645
4718
  if (req.method !== "GET" || url.pathname !== "/api/admin/network/records") return null;
3646
- const got = await gate5(req, env, ctx);
4719
+ const got = await gate6(req, env, ctx);
3647
4720
  if ("response" in got) return got.response;
3648
4721
  const targetId = url.searchParams.get("targetId") ?? "";
3649
4722
  const type = url.searchParams.get("type") ?? "";
@@ -3670,8 +4743,8 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
3670
4743
  // src/worker-routes-network-notes.ts
3671
4744
  var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
3672
4745
  var RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3673
- var MAX_NOTE = 4e3;
3674
- async function gate6(req, env, ctx) {
4746
+ var MAX_NOTE2 = 4e3;
4747
+ async function gate7(req, env, ctx) {
3675
4748
  const db = ctx.makeDb(env);
3676
4749
  const user = await ctx.verifyUser(req, env);
3677
4750
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3705,7 +4778,7 @@ function asNote(row) {
3705
4778
  }
3706
4779
  var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3707
4780
  if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
3708
- const got = await gate6(req, env, ctx);
4781
+ const got = await gate7(req, env, ctx);
3709
4782
  if ("response" in got) return got.response;
3710
4783
  const identity = noteIdentity(url, ctx);
3711
4784
  if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
@@ -3722,7 +4795,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3722
4795
  return json({ notes: (result.networkNotes ?? []).map((row) => asNote(row)) });
3723
4796
  }
3724
4797
  const contentLength = Number(req.headers.get("content-length") ?? 0);
3725
- if (contentLength > MAX_NOTE * 2) return json({ error: "note is too large" }, 413);
4798
+ if (contentLength > MAX_NOTE2 * 2) return json({ error: "note is too large" }, 413);
3726
4799
  let input;
3727
4800
  try {
3728
4801
  input = await req.json();
@@ -3730,7 +4803,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3730
4803
  return json({ error: "invalid JSON body" }, 400);
3731
4804
  }
3732
4805
  const body = typeof input.body === "string" ? input.body.trim() : "";
3733
- if (!body || body.length > MAX_NOTE) return json({ error: `note must be 1\u2013${MAX_NOTE} characters` }, 400);
4806
+ if (!body || body.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
3734
4807
  const id = crypto.randomUUID();
3735
4808
  const note = {
3736
4809
  id,
@@ -3757,13 +4830,13 @@ import {
3757
4830
  CRM_NS,
3758
4831
  getRecord as getRecord2
3759
4832
  } from "@odla-ai/crm";
3760
- import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest5 } from "@odla-ai/db";
4833
+ import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest6 } from "@odla-ai/db";
3761
4834
  var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
3762
4835
  var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3763
4836
  var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
3764
- var MAX_NOTE2 = 4e3;
4837
+ var MAX_NOTE3 = 4e3;
3765
4838
  var NETWORK_META = "chapterNetwork";
3766
- async function gate7(req, env, ctx) {
4839
+ async function gate8(req, env, ctx) {
3767
4840
  const db = ctx.makeDb(env);
3768
4841
  const user = await ctx.verifyUser(req, env);
3769
4842
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3809,14 +4882,14 @@ function sourceLabel(sourceId) {
3809
4882
  }
3810
4883
  var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3811
4884
  if (url.pathname !== "/api/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
3812
- if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_NOTE2 * 2) {
4885
+ if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_NOTE3 * 2) {
3813
4886
  return json({ error: "note is too large" }, 413);
3814
4887
  }
3815
4888
  const db = ctx.makeDb(env);
3816
4889
  const secret = await getVaultSecret(db, "network_share_secret");
3817
4890
  const readers = ctx.chapter.network.readers;
3818
4891
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
3819
- const verified = await verifyFederatedRequest5(req, {
4892
+ const verified = await verifyFederatedRequest6(req, {
3820
4893
  secret,
3821
4894
  senders: readers.map((reader2) => reader2.id)
3822
4895
  });
@@ -3827,8 +4900,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3827
4900
  if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
3828
4901
  return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
3829
4902
  }
3830
- const record = await getRecord2({ crm: ctx.chapter.crm, db }, recordId);
3831
- if (!record || record.type !== recordType) return json({ error: "record not found" }, 404);
4903
+ const record2 = await getRecord2({ crm: ctx.chapter.crm, db }, recordId);
4904
+ if (!record2 || record2.type !== recordType) return json({ error: "record not found" }, 404);
3832
4905
  if (req.method === "GET") {
3833
4906
  return json({
3834
4907
  version: 1,
@@ -3844,8 +4917,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3844
4917
  }
3845
4918
  const body = typeof input.body === "string" ? input.body.trim() : "";
3846
4919
  const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
3847
- if (!body || body.length > MAX_NOTE2 || !MUTATION_ID.test(mutationId)) {
3848
- return json({ error: `body must be 1\u2013${MAX_NOTE2} characters and mutationId must be valid` }, 400);
4920
+ if (!body || body.length > MAX_NOTE3 || !MUTATION_ID.test(mutationId)) {
4921
+ return json({ error: `body must be 1\u2013${MAX_NOTE3} characters and mutationId must be valid` }, 400);
3849
4922
  }
3850
4923
  const createdAt = Date.now();
3851
4924
  const id = `network:${verified.sender}:${mutationId}`;
@@ -3887,7 +4960,7 @@ function targetFor2(ctx, id) {
3887
4960
  }
3888
4961
  var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
3889
4962
  if (url.pathname !== "/api/admin/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
3890
- const got = await gate7(req, env, ctx);
4963
+ const got = await gate8(req, env, ctx);
3891
4964
  if ("response" in got) return got.response;
3892
4965
  const targetId = url.searchParams.get("targetId") ?? "";
3893
4966
  const recordType = url.searchParams.get("type") ?? "";
@@ -3905,7 +4978,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
3905
4978
  return json({ error: "invalid JSON body" }, 400);
3906
4979
  }
3907
4980
  const note = typeof input.body === "string" ? input.body.trim() : "";
3908
- if (!note || note.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
4981
+ if (!note || note.length > MAX_NOTE3) return json({ error: `note must be 1\u2013${MAX_NOTE3} characters` }, 400);
3909
4982
  body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
3910
4983
  }
3911
4984
  const secret = await getVaultSecret(got.db, target.secretName);
@@ -4058,6 +5131,162 @@ async function deliverSignupControl(options) {
4058
5131
  return result;
4059
5132
  }
4060
5133
 
5134
+ // src/admission-grants-delivery.ts
5135
+ import { signFederatedRequest as signFederatedRequest6 } from "@odla-ai/db";
5136
+ async function deliverAdmissionGrants(options) {
5137
+ const destination = new URL(ADMISSION_GRANTS_PATH, options.targetUrl);
5138
+ const body = JSON.stringify(options.envelope);
5139
+ const signed = await signFederatedRequest6({
5140
+ secret: options.secret,
5141
+ sender: options.sender,
5142
+ method: "POST",
5143
+ url: destination,
5144
+ body
5145
+ });
5146
+ const response = await (options.fetch ?? globalThis.fetch)(destination, {
5147
+ method: "POST",
5148
+ headers: { ...signed, "content-type": "application/json" },
5149
+ body
5150
+ });
5151
+ const result = await response.json();
5152
+ if (!response.ok || !("ok" in result) || result.ok !== true) {
5153
+ const failure = result;
5154
+ throw new Error(`admission grants rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
5155
+ }
5156
+ return result;
5157
+ }
5158
+
5159
+ // src/signup-control-theme.ts
5160
+ var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
5161
+ var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
5162
+ var TOKEN_NAME = /^--ui-[a-z0-9][a-z0-9-]{0,94}$/;
5163
+ var DIGEST = /^sha256:[0-9a-f]{64}$/;
5164
+ var UNSAFE_VALUE = /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i;
5165
+ var MAX_TOKENS = 256;
5166
+ var MAX_VALUE_LENGTH = 1e3;
5167
+ var MAX_SERIALIZED_LENGTH = 65536;
5168
+ function validatedTokens(input) {
5169
+ const entries = Object.entries(input ?? {});
5170
+ if (entries.length > MAX_TOKENS) {
5171
+ throw new TypeError(`signup-control theme supports at most ${MAX_TOKENS} tokens`);
5172
+ }
5173
+ const tokens = {};
5174
+ let serializedLength = 0;
5175
+ for (const [name, raw] of entries.sort(([left], [right]) => left.localeCompare(right))) {
5176
+ if (!TOKEN_NAME.test(name)) {
5177
+ throw new TypeError(`signup-control theme token "${name}" must be an allowlisted --ui-* property`);
5178
+ }
5179
+ if (typeof raw !== "string" && typeof raw !== "number" || typeof raw === "number" && !Number.isFinite(raw)) {
5180
+ throw new TypeError(`signup-control theme token "${name}" must be a finite string or number`);
5181
+ }
5182
+ const value = String(raw).trim();
5183
+ if (!value || value.length > MAX_VALUE_LENGTH || UNSAFE_VALUE.test(value)) {
5184
+ throw new TypeError(`signup-control theme token "${name}" has an unsafe or oversized value`);
5185
+ }
5186
+ serializedLength += name.length + value.length + 4;
5187
+ if (serializedLength > MAX_SERIALIZED_LENGTH) {
5188
+ throw new TypeError("signup-control theme exceeds the serialized size bound");
5189
+ }
5190
+ tokens[name] = value;
5191
+ }
5192
+ return tokens;
5193
+ }
5194
+ function cssFor(tokens) {
5195
+ const declarations = Object.entries(tokens).map(([name, value]) => ` ${name}: ${value};`);
5196
+ return declarations.length ? `:root {
5197
+ ${declarations.join("\n")}
5198
+ }` : "";
5199
+ }
5200
+ function stateFor(revision, options) {
5201
+ if (!revision) {
5202
+ return {
5203
+ css: cssFor(validatedTokens(options.bootstrapTokens)),
5204
+ metadata: { source: "bootstrap", revision: null, digest: null, cacheKey: "signup-control:bootstrap" }
5205
+ };
5206
+ }
5207
+ if (!Number.isSafeInteger(revision.revision) || revision.revision < 1 || !DIGEST.test(revision.digest)) {
5208
+ throw new TypeError("signup-control theme revision metadata is invalid");
5209
+ }
5210
+ return {
5211
+ css: cssFor(validatedTokens(revision.theme?.tokens)),
5212
+ metadata: {
5213
+ source: "active",
5214
+ revision: revision.revision,
5215
+ digest: revision.digest,
5216
+ cacheKey: `signup-control:${revision.digest}`
5217
+ }
5218
+ };
5219
+ }
5220
+ function attributes(metadata) {
5221
+ return [
5222
+ `id="${SIGNUP_CONTROL_THEME_STYLE_ID}"`,
5223
+ `data-signup-control-source="${metadata.source}"`,
5224
+ `data-signup-control-cache-key="${metadata.cacheKey}"`,
5225
+ ...metadata.revision === null ? [] : [`data-signup-control-revision="${metadata.revision}"`],
5226
+ ...metadata.digest === null ? [] : [`data-signup-control-digest="${metadata.digest}"`]
5227
+ ].join(" ");
5228
+ }
5229
+ function renderSignupControlThemeHead(revision, options = {}) {
5230
+ const { css, metadata } = stateFor(revision, options);
5231
+ return { css, metadata, head: `<style ${attributes(metadata)}>${css}</style>` };
5232
+ }
5233
+ function injectSignupControlThemeHead(html, rendered, marker = SIGNUP_CONTROL_THEME_MARKER) {
5234
+ const existing = /<style\b(?=[^>]*\bid\s*=\s*["']odla-signup-control-theme["'])[^>]*>[\s\S]*?<\/style>/gi;
5235
+ const existingBlocks = html.match(existing) ?? [];
5236
+ if (existingBlocks.length > 1) {
5237
+ throw new TypeError("signup-control theme style block must appear at most once");
5238
+ }
5239
+ if (existingBlocks.length === 1) return html.replace(existing, rendered.head);
5240
+ const markerIndex = html.indexOf(marker);
5241
+ if (markerIndex >= 0) {
5242
+ if (html.indexOf(marker, markerIndex + marker.length) >= 0) {
5243
+ throw new TypeError("signup-control theme marker must appear exactly once");
5244
+ }
5245
+ return `${html.slice(0, markerIndex)}${rendered.head}${html.slice(markerIndex + marker.length)}`;
5246
+ }
5247
+ const closingHead = html.search(/<\/head\s*>/i);
5248
+ if (closingHead < 0) throw new TypeError("signup-control theme requires an HTML head");
5249
+ return `${html.slice(0, closingHead)}${rendered.head}${html.slice(closingHead)}`;
5250
+ }
5251
+
5252
+ // src/signup-control-theme-worker.ts
5253
+ async function activeTheme(options) {
5254
+ const active = await loadActiveSignupControl(options.db, options.chapterId, options.environment);
5255
+ if (!active) return null;
5256
+ const checked = validateSignupControlDocument(active.document);
5257
+ if (!checked.ok) throw new TypeError(`active signup-control document is invalid: ${checked.errors.join("; ")}`);
5258
+ const document = checked.value;
5259
+ if (document.revision !== active.revision || document.target.chapterId !== options.chapterId || document.target.environment !== options.environment || active.digest !== await signupControlDigest(document)) {
5260
+ throw new TypeError("active signup-control revision metadata or digest is invalid");
5261
+ }
5262
+ return { revision: active.revision, digest: active.digest, theme: document.theme };
5263
+ }
5264
+ async function renderActiveSignupControlHtml(html, options) {
5265
+ const theme = renderSignupControlThemeHead(await activeTheme(options), {
5266
+ bootstrapTokens: options.bootstrapTokens
5267
+ });
5268
+ return { html: injectSignupControlThemeHead(html, theme, options.marker), theme };
5269
+ }
5270
+ async function contentEtag(html) {
5271
+ const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(html));
5272
+ const hex = Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
5273
+ return `W/"sha256-${hex}"`;
5274
+ }
5275
+ async function withActiveSignupControlTheme(response, options) {
5276
+ if (!response.ok || !(response.headers.get("content-type") ?? "").toLowerCase().includes("text/html")) {
5277
+ throw new TypeError("signup-control theme can only transform a successful HTML response");
5278
+ }
5279
+ const rendered = await renderActiveSignupControlHtml(await response.text(), options);
5280
+ const headers = new Headers(response.headers);
5281
+ headers.delete("content-length");
5282
+ headers.set("cache-control", "no-store");
5283
+ headers.set("etag", await contentEtag(rendered.html));
5284
+ headers.set("x-odla-signup-control-digest", rendered.theme.metadata.digest ?? "bootstrap");
5285
+ if (rendered.theme.metadata.revision === null) headers.delete("x-odla-signup-control-revision");
5286
+ else headers.set("x-odla-signup-control-revision", String(rendered.theme.metadata.revision));
5287
+ return new Response(rendered.html, { status: response.status, statusText: response.statusText, headers });
5288
+ }
5289
+
4061
5290
  // src/worker.ts
4062
5291
  var BUILTIN_ROUTES = [
4063
5292
  handleHealth,
@@ -4066,11 +5295,13 @@ var BUILTIN_ROUTES = [
4066
5295
  handleCrm,
4067
5296
  handleNetworkShared,
4068
5297
  handleSignupControl,
5298
+ handleAdmissionGrants,
4069
5299
  handleNetworkSnapshot,
4070
5300
  handleNetworkRecords,
4071
5301
  handleNetworkSharedNotes,
4072
5302
  handleFormation,
4073
5303
  handleMember,
5304
+ handleGifts,
4074
5305
  handleSchedule,
4075
5306
  handlePayments,
4076
5307
  handleAdminMeetings,
@@ -4080,6 +5311,7 @@ var BUILTIN_ROUTES = [
4080
5311
  handleAdminPeopleAccess,
4081
5312
  handleAdminPeopleRole,
4082
5313
  handleAdminCrmSync,
5314
+ handleAdminAdmissionGrants,
4083
5315
  handleAdminClerkPrivateProfileSync,
4084
5316
  // Aggregation
4085
5317
  handleAdminDashboard,
@@ -4124,6 +5356,9 @@ function chapterWorker(options) {
4124
5356
  export {
4125
5357
  chapterWorker,
4126
5358
  createWorkerContext,
4127
- deliverSignupControl
5359
+ deliverAdmissionGrants,
5360
+ deliverSignupControl,
5361
+ renderActiveSignupControlHtml,
5362
+ withActiveSignupControlTheme
4128
5363
  };
4129
5364
  //# sourceMappingURL=index.js.map