@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.
@@ -22,7 +22,10 @@ var worker_exports = {};
22
22
  __export(worker_exports, {
23
23
  chapterWorker: () => chapterWorker,
24
24
  createWorkerContext: () => createWorkerContext,
25
- deliverSignupControl: () => deliverSignupControl
25
+ deliverAdmissionGrants: () => deliverAdmissionGrants,
26
+ deliverSignupControl: () => deliverSignupControl,
27
+ renderActiveSignupControlHtml: () => renderActiveSignupControlHtml,
28
+ withActiveSignupControlTheme: () => withActiveSignupControlTheme
26
29
  });
27
30
  module.exports = __toCommonJS(worker_exports);
28
31
 
@@ -137,7 +140,7 @@ function createWorkerContext(options) {
137
140
  }
138
141
 
139
142
  // src/worker-routes.ts
140
- var import_crm3 = require("@odla-ai/crm");
143
+ var import_crm4 = require("@odla-ai/crm");
141
144
 
142
145
  // src/discussion-reference-security.ts
143
146
  async function authorizeCrmDiscussionReference(req, env, fetcher = fetch) {
@@ -195,6 +198,205 @@ async function applicationIdForSubmission(submissionId) {
195
198
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
196
199
  }
197
200
 
201
+ // src/admission-grants-contract.ts
202
+ var ADMISSION_GRANTS_SCHEMA_VERSION = 1;
203
+ var ADMISSION_GRANTS_PATH = "/api/admission-grants";
204
+
205
+ // src/signup-control-canonical.ts
206
+ var encoder = new TextEncoder();
207
+ function canonicalValue(value) {
208
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
209
+ return JSON.stringify(value);
210
+ }
211
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
212
+ if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
213
+ const record2 = value;
214
+ const entries = Object.keys(record2).sort().flatMap((key) => {
215
+ const item = record2[key];
216
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
217
+ });
218
+ return `{${entries.join(",")}}`;
219
+ }
220
+ function canonicalSignupControl(value) {
221
+ return canonicalValue(value);
222
+ }
223
+ async function signupControlDigest(document) {
224
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
225
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
226
+ }
227
+
228
+ // src/admission-grants.ts
229
+ var ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
230
+ var MAX_GRANTS = 2e3;
231
+ var MAX_NOTE = 2e3;
232
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
233
+ var isAdmissionEmail = (value) => EMAIL_RE.test(value);
234
+ function normalizeAdmissionEmail(email) {
235
+ return email.trim().toLowerCase();
236
+ }
237
+ function validateAdmissionGrantDocument(input) {
238
+ const errors = [];
239
+ const root = input && typeof input === "object" ? input : {};
240
+ if (root.schemaVersion !== ADMISSION_GRANTS_SCHEMA_VERSION) errors.push("document.schemaVersion must be 1");
241
+ const revision = Number(root.revision);
242
+ const generatedAt = Number(root.generatedAt);
243
+ if (!Number.isSafeInteger(revision) || revision < 1) errors.push("document.revision must be a positive integer");
244
+ if (!Number.isFinite(generatedAt) || generatedAt <= 0) errors.push("document.generatedAt must be epoch milliseconds");
245
+ const source = root.source && typeof root.source === "object" ? root.source : {};
246
+ const target = root.target && typeof root.target === "object" ? root.target : {};
247
+ const sourceId = typeof source.appId === "string" ? source.appId : "";
248
+ const sourceActor = typeof source.actor === "string" ? source.actor.trim() : "";
249
+ const chapterId = typeof target.chapterId === "string" ? target.chapterId : "";
250
+ const environment = typeof target.environment === "string" ? target.environment : "";
251
+ if (!ID.test(sourceId)) errors.push("document.source.appId must be an identifier");
252
+ if (!sourceActor || sourceActor.length > 256) errors.push("document.source.actor must be 1-256 characters");
253
+ if (!ID.test(chapterId)) errors.push("document.target.chapterId must be an identifier");
254
+ if (!ID.test(environment)) errors.push("document.target.environment must be an identifier");
255
+ if (!Array.isArray(root.grants)) errors.push("document.grants must be an array");
256
+ const rawGrants = Array.isArray(root.grants) ? root.grants : [];
257
+ if (rawGrants.length > MAX_GRANTS) errors.push(`document.grants supports at most ${MAX_GRANTS} entries`);
258
+ const grants = [];
259
+ const seenIds = /* @__PURE__ */ new Set();
260
+ const seenEmails = /* @__PURE__ */ new Set();
261
+ for (const [index, raw] of rawGrants.slice(0, MAX_GRANTS).entries()) {
262
+ const grant = raw && typeof raw === "object" ? raw : {};
263
+ const id = typeof grant.id === "string" ? grant.id : "";
264
+ const email = typeof grant.email === "string" ? grant.email.trim() : "";
265
+ const normalized = normalizeAdmissionEmail(email);
266
+ const decision = grant.decision;
267
+ const note = typeof grant.note === "string" ? grant.note.trim() : void 0;
268
+ const expiresAt = grant.expiresAt === void 0 ? void 0 : Number(grant.expiresAt);
269
+ if (!ID.test(id)) errors.push(`document.grants[${index}].id must be an identifier`);
270
+ if (seenIds.has(id)) errors.push(`document.grants repeats id "${id}"`);
271
+ if (!isAdmissionEmail(normalized)) errors.push(`document.grants[${index}].email must be valid`);
272
+ if (seenEmails.has(normalized)) errors.push(`document.grants repeats email "${normalized}"`);
273
+ if (decision !== "waive_interview" && decision !== "require_interview") {
274
+ errors.push(`document.grants[${index}].decision is invalid`);
275
+ }
276
+ if (note && note.length > MAX_NOTE) errors.push(`document.grants[${index}].note is too long`);
277
+ if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= 0)) {
278
+ errors.push(`document.grants[${index}].expiresAt must be epoch milliseconds`);
279
+ }
280
+ seenIds.add(id);
281
+ seenEmails.add(normalized);
282
+ grants.push({
283
+ id,
284
+ email,
285
+ decision,
286
+ ...note ? { note } : {},
287
+ ...expiresAt !== void 0 ? { expiresAt } : {}
288
+ });
289
+ }
290
+ if (errors.length) return { ok: false, errors };
291
+ return {
292
+ ok: true,
293
+ value: {
294
+ schemaVersion: ADMISSION_GRANTS_SCHEMA_VERSION,
295
+ revision,
296
+ generatedAt,
297
+ source: { appId: sourceId, actor: sourceActor },
298
+ target: { chapterId, environment },
299
+ grants
300
+ }
301
+ };
302
+ }
303
+ var rows = (value) => Array.isArray(value) ? value : [];
304
+ function asGrant(row) {
305
+ if (typeof row.id !== "string" || typeof row.emailNormalized !== "string") return null;
306
+ if (row.sourceScope !== "local" && row.sourceScope !== "parent") return null;
307
+ if (row.decision !== "waive_interview" && row.decision !== "require_interview") return null;
308
+ return row;
309
+ }
310
+ async function findAdmissionGrant(db, targetId, email, now) {
311
+ const emailNormalized = normalizeAdmissionEmail(email);
312
+ if (!isAdmissionEmail(emailNormalized)) return null;
313
+ const result = await db.query({ admissionGrants: { $: { where: { emailNormalized }, limit: 100 } } });
314
+ const active = rows(result.admissionGrants).map(asGrant).filter((grant) => Boolean(
315
+ grant && grant.targetId === targetId && grant.active === true && !grant.revokedAt && (!grant.expiresAt || grant.expiresAt > now) && !grant.consumedApplicationId
316
+ )).sort((a, b) => {
317
+ const scope = Number(b.sourceScope === "local") - Number(a.sourceScope === "local");
318
+ return scope || b.createdAt - a.createdAt;
319
+ });
320
+ return active[0] ?? null;
321
+ }
322
+ async function hasAdmissionRevision(db, sourceId, targetId, environment) {
323
+ const where = { sourceId, targetId, environment };
324
+ if (db.aggregate) {
325
+ return Number((await db.aggregate("admissionGrantRevisions", { count: true }, { where })).count ?? 0) > 0;
326
+ }
327
+ const found = await db.query({ admissionGrantRevisions: {} });
328
+ return rows(found.admissionGrantRevisions).some((row) => row.sourceId === sourceId && row.targetId === targetId && row.environment === environment);
329
+ }
330
+ async function loadLatestAdmissionRevision(db, sourceId, targetId, environment) {
331
+ if (!await hasAdmissionRevision(db, sourceId, targetId, environment)) return null;
332
+ const result = await db.query({ admissionGrantRevisions: { $: {
333
+ where: { sourceId, targetId, environment },
334
+ order: { revision: "desc" },
335
+ limit: 1
336
+ } } });
337
+ const row = rows(result.admissionGrantRevisions).sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0];
338
+ return row ? row : null;
339
+ }
340
+ async function materializeAdmissionGrants(db, envelope, previous, receivedAt) {
341
+ const { document, digest } = envelope;
342
+ const current = rows((await db.query({ admissionGrants: { $: {
343
+ where: { sourceScope: "parent", sourceId: document.source.appId, targetId: document.target.chapterId },
344
+ limit: MAX_GRANTS
345
+ } } })).admissionGrants);
346
+ const incomingIds = new Set(document.grants.map((grant) => `admission:parent:${document.source.appId}:${grant.id}`));
347
+ const ops = [];
348
+ for (const row of current) {
349
+ const id = String(row.id ?? "");
350
+ if (id && !incomingIds.has(id)) ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
351
+ active: false,
352
+ revokedAt: receivedAt,
353
+ revokedBy: document.source.actor,
354
+ updatedAt: receivedAt,
355
+ updatedBy: document.source.actor
356
+ } });
357
+ }
358
+ for (const grant of document.grants) {
359
+ const id = `admission:parent:${document.source.appId}:${grant.id}`;
360
+ const existing = current.find((row) => row.id === id);
361
+ ops.push({ t: "update", ns: "admissionGrants", id, attrs: {
362
+ id,
363
+ sourceGrantId: grant.id,
364
+ sourceScope: "parent",
365
+ sourceId: document.source.appId,
366
+ targetId: document.target.chapterId,
367
+ email: grant.email,
368
+ emailNormalized: normalizeAdmissionEmail(grant.email),
369
+ decision: grant.decision,
370
+ ...grant.note ? { note: grant.note } : {},
371
+ ...grant.expiresAt ? { expiresAt: grant.expiresAt } : {},
372
+ active: true,
373
+ sourceRevision: document.revision,
374
+ createdAt: Number(existing?.createdAt ?? receivedAt),
375
+ createdBy: String(existing?.createdBy ?? document.source.actor),
376
+ updatedAt: receivedAt,
377
+ updatedBy: document.source.actor
378
+ } });
379
+ const retract = [
380
+ ...!grant.note && existing?.note !== void 0 ? ["note"] : [],
381
+ ...grant.expiresAt === void 0 && existing?.expiresAt !== void 0 ? ["expiresAt"] : []
382
+ ];
383
+ if (retract.length) ops.push({ t: "retract", ns: "admissionGrants", id, attrs: retract });
384
+ }
385
+ if (previous?.active) ops.push({ t: "update", ns: "admissionGrantRevisions", id: previous.id, attrs: { active: false } });
386
+ const revisionId = `admission-revision:${document.source.appId}:${document.target.environment}:${document.revision}`;
387
+ ops.push({ t: "update", ns: "admissionGrantRevisions", id: revisionId, attrs: {
388
+ id: revisionId,
389
+ sourceId: document.source.appId,
390
+ targetId: document.target.chapterId,
391
+ environment: document.target.environment,
392
+ revision: document.revision,
393
+ digest,
394
+ active: true,
395
+ receivedAt
396
+ } });
397
+ await db.transact(ops, { mutationId: `admission-grants:${document.source.appId}:${document.target.environment}:${document.revision}:${digest}` });
398
+ }
399
+
198
400
  // src/member.ts
199
401
  var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
200
402
  var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
@@ -246,9 +448,9 @@ function resolveApplication(a) {
246
448
  validateEmail: a?.validateEmail ?? true
247
449
  };
248
450
  }
249
- var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
451
+ var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
250
452
  function isValidEmail(value) {
251
- return typeof value === "string" && EMAIL_RE.test(value);
453
+ return typeof value === "string" && EMAIL_RE2.test(value);
252
454
  }
253
455
  function clampArray(value, max) {
254
456
  if (!Array.isArray(value)) return value;
@@ -298,6 +500,15 @@ async function submitApplication(db, chapter, fields, opts) {
298
500
  }
299
501
  if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
300
502
  if (opts.groupId) row.groupId = opts.groupId;
503
+ if (typeof fields.email === "string" && isValidEmail(fields.email.trim())) {
504
+ row.emailNormalized = normalizeAdmissionEmail(fields.email);
505
+ }
506
+ if (opts.admissionGrant?.decision === "waive_interview") {
507
+ row.admissionGrantId = opts.admissionGrant.id;
508
+ row.admissionSource = opts.admissionGrant.sourceScope;
509
+ row.interviewWaivedAt = opts.now;
510
+ }
511
+ if (opts.giftOfferId) row.giftOfferId = opts.giftOfferId;
301
512
  if (typeof fields.tierId === "string" && fields.tierId) {
302
513
  if (opts.tierIds && !opts.tierIds.includes(fields.tierId)) {
303
514
  return { ok: false, error: "tierId is not an offered tier" };
@@ -305,8 +516,17 @@ async function submitApplication(db, chapter, fields, opts) {
305
516
  row.tierId = fields.tierId;
306
517
  }
307
518
  if (acked) row.disclaimerAckAt = opts.now;
519
+ const ops = [
520
+ { t: "update", ns: "applications", id, attrs: row }
521
+ ];
522
+ if (opts.admissionGrant?.decision === "waive_interview") {
523
+ ops.push({ t: "update", ns: "admissionGrants", id: opts.admissionGrant.id, attrs: {
524
+ consumedAt: opts.now,
525
+ consumedApplicationId: id
526
+ } });
527
+ }
308
528
  const { duplicate } = await db.transact(
309
- [{ t: "update", ns: "applications", id, attrs: row }],
529
+ ops,
310
530
  opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
311
531
  );
312
532
  return { ok: true, id, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
@@ -340,8 +560,8 @@ function tierPayable(tier, group, hasSecretKey) {
340
560
  function byOrder(a, b) {
341
561
  return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
342
562
  }
343
- function resolveTiers(group, rows2 = []) {
344
- const stored = rows2.filter((tier) => tier.active);
563
+ function resolveTiers(group, rows4 = []) {
564
+ const stored = rows4.filter((tier) => tier.active);
345
565
  if (stored.length) return [...stored].sort(byOrder);
346
566
  return [{
347
567
  id: "standard",
@@ -387,15 +607,15 @@ function rowToTier(row, groupId) {
387
607
  }
388
608
  async function loadTiers(db, group) {
389
609
  const groupId = String(group.id ?? "");
390
- let rows2;
610
+ let rows4;
391
611
  try {
392
- rows2 = (await db.query({
612
+ rows4 = (await db.query({
393
613
  tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
394
614
  })).tiers;
395
615
  } catch {
396
- rows2 = void 0;
616
+ rows4 = void 0;
397
617
  }
398
- const list = Array.isArray(rows2) ? rows2.map((row) => rowToTier(row, groupId)) : [];
618
+ const list = Array.isArray(rows4) ? rows4.map((row) => rowToTier(row, groupId)) : [];
399
619
  return resolveTiers(group, list);
400
620
  }
401
621
  async function loadOfferableTiers(db, group, hasSecretKey) {
@@ -509,341 +729,252 @@ function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
509
729
  };
510
730
  }
511
731
 
512
- // src/pipeline.ts
513
- function canTransition(from, to, p) {
514
- const fi = p.stages.indexOf(from);
515
- const ti = p.stages.indexOf(to);
516
- return fi >= 0 && ti >= 0 && ti >= fi;
732
+ // src/gift-offers.ts
733
+ var GIFT_OFFER_CATALOG_VERSION = 1;
734
+ var MAX_GIFT_OFFERS = 20;
735
+ var ID2 = /^[a-z][a-z0-9-]{1,79}$/;
736
+ var PRICE = /^price_[A-Za-z0-9]{12,}$/;
737
+ var CURRENCY = /^[a-z]{3}$/;
738
+ var record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
739
+ var text = (value, path, errors, max) => {
740
+ if (typeof value !== "string" || !value.trim()) {
741
+ errors.push(`${path} is required`);
742
+ return "";
743
+ }
744
+ if (value.trim().length > max) errors.push(`${path} must be at most ${max} characters`);
745
+ return value.trim();
746
+ };
747
+ function validateGiftOfferCatalog(input, tierIds) {
748
+ const errors = [];
749
+ const root = record(input);
750
+ if (!root) return { ok: false, errors: ["document.giftOffers must be an object"] };
751
+ for (const key of Object.keys(root)) if (key !== "schemaVersion" && key !== "offers") errors.push(`document.giftOffers.${key} is not supported`);
752
+ if (root.schemaVersion !== GIFT_OFFER_CATALOG_VERSION) errors.push("document.giftOffers.schemaVersion must be 1");
753
+ if (!Array.isArray(root.offers)) errors.push("document.giftOffers.offers must be an array");
754
+ const rawOffers = Array.isArray(root.offers) ? root.offers : [];
755
+ if (rawOffers.length > MAX_GIFT_OFFERS) errors.push(`document.giftOffers.offers supports at most ${MAX_GIFT_OFFERS} entries`);
756
+ const ids = /* @__PURE__ */ new Set();
757
+ const offers = [];
758
+ for (const [index, raw] of rawOffers.slice(0, MAX_GIFT_OFFERS).entries()) {
759
+ const path = `document.giftOffers.offers[${index}]`;
760
+ const item = record(raw);
761
+ if (!item) {
762
+ errors.push(`${path} must be an object`);
763
+ continue;
764
+ }
765
+ const allowed = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
766
+ for (const key of Object.keys(item)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
767
+ const id = text(item.id, `${path}.id`, errors, 80);
768
+ if (id && !ID2.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
769
+ if (ids.has(id)) errors.push(`${path}.id duplicates "${id}"`);
770
+ ids.add(id);
771
+ const targetRaw = record(item.target);
772
+ let target = { type: "entitlement", entitlementKey: "invalid" };
773
+ if (targetRaw?.type === "tier") {
774
+ const tierId = text(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
775
+ if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
776
+ target = { type: "tier", tierId };
777
+ } else if (targetRaw?.type === "entitlement") {
778
+ const entitlementKey = text(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
779
+ if (entitlementKey && !ID2.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
780
+ target = { type: "entitlement", entitlementKey };
781
+ } else errors.push(`${path}.target.type must be tier or entitlement`);
782
+ if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
783
+ const stripePriceId = text(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
784
+ if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
785
+ 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`);
786
+ const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
787
+ if (!CURRENCY.test(currency)) errors.push(`${path}.currency must be a lowercase ISO currency code`);
788
+ const lifetime = Number(item.redemptionLifetimeHours);
789
+ if (!Number.isSafeInteger(lifetime) || lifetime < 1 || lifetime > 8760) errors.push(`${path}.redemptionLifetimeHours must be an integer from 1 to 8760`);
790
+ const activeFrom = item.activeFrom === void 0 ? void 0 : Number(item.activeFrom);
791
+ const activeUntil = item.activeUntil === void 0 ? void 0 : Number(item.activeUntil);
792
+ if (activeFrom !== void 0 && (!Number.isSafeInteger(activeFrom) || activeFrom < 1)) errors.push(`${path}.activeFrom must be epoch milliseconds`);
793
+ if (activeUntil !== void 0 && (!Number.isSafeInteger(activeUntil) || activeUntil < 1)) errors.push(`${path}.activeUntil must be epoch milliseconds`);
794
+ if (activeFrom !== void 0 && activeUntil !== void 0 && activeUntil <= activeFrom) errors.push(`${path}.activeUntil must follow activeFrom`);
795
+ offers.push({
796
+ id,
797
+ target,
798
+ billingMode: item.billingMode,
799
+ stripePriceId,
800
+ priceCents: Number(item.priceCents),
801
+ currency,
802
+ redemptionLifetimeHours: lifetime,
803
+ ...activeFrom !== void 0 ? { activeFrom } : {},
804
+ ...activeUntil !== void 0 ? { activeUntil } : {},
805
+ title: text(item.title, `${path}.title`, errors, 160),
806
+ description: text(item.description, `${path}.description`, errors, 2e3),
807
+ ctaLabel: text(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
808
+ });
809
+ }
810
+ return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
517
811
  }
518
- function canBook(status, p) {
519
- return p.bookableFrom.includes(status);
812
+ var giftOfferActive = (offer, now) => (!offer.activeFrom || offer.activeFrom <= now) && (!offer.activeUntil || offer.activeUntil > now);
813
+ function publicGiftOffers(catalog, now) {
814
+ return (catalog?.offers ?? []).filter((offer) => giftOfferActive(offer, now)).map(({ stripePriceId: _stripe, target: _target, ...offer }) => offer);
520
815
  }
521
- function canApprove(status, p) {
522
- return p.approvableFrom.includes(status);
816
+ function findGiftOffer(catalog, offerId, now) {
817
+ return catalog?.offers.find((offer) => offer.id === offerId && giftOfferActive(offer, now)) ?? null;
523
818
  }
524
819
 
525
- // src/signup-control-store.ts
526
- function asStored(row) {
527
- if (!row || typeof row.document !== "object" || row.document === null) return null;
528
- return row;
820
+ // src/clerk-roles.ts
821
+ var CLERK_API = "https://api.clerk.com";
822
+ var DEFAULT_ROLE = "provisional";
823
+ var PAGE = 100;
824
+ function toRecord(u) {
825
+ if (typeof u.id !== "string") return null;
826
+ const pm = u.public_metadata ?? {};
827
+ const privateMetadata = u.private_metadata ?? {};
828
+ const rawProfile = privateMetadata.profile;
829
+ const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
830
+ const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
831
+ const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
832
+ const email = u.email_addresses?.[0]?.email_address;
833
+ return {
834
+ id: u.id,
835
+ email: typeof email === "string" ? email : void 0,
836
+ role,
837
+ publicMetadata: pm,
838
+ privateMetadata,
839
+ profile,
840
+ ...applicationId ? { applicationId } : {}
841
+ };
529
842
  }
530
- async function hasSignupControl(db, targetId, environment, active) {
531
- const where = { targetId, environment, ...active ? { active: true } : {} };
532
- if (db.aggregate) {
533
- return Number((await db.aggregate("signupControls", { count: true }, { where })).count ?? 0) > 0;
843
+ async function clerkGet(path, secretKey, fetchImpl) {
844
+ const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
845
+ if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
846
+ return res.json();
847
+ }
848
+ async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
849
+ const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
850
+ const user = Array.isArray(data) ? data[0] : void 0;
851
+ return user ? toRecord(user) : null;
852
+ }
853
+ async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
854
+ const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
855
+ return data ? toRecord(data) : null;
856
+ }
857
+ async function clerkListUsers(secretKey, fetchImpl = fetch) {
858
+ const out = [];
859
+ for (let offset = 0; ; offset += PAGE) {
860
+ const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
861
+ const page = Array.isArray(data) ? data : [];
862
+ for (const u of page) {
863
+ const record2 = toRecord(u);
864
+ if (record2) out.push(record2);
865
+ }
866
+ if (page.length < PAGE) break;
534
867
  }
535
- const rows2 = (await db.query({ signupControls: {} })).signupControls;
536
- return Array.isArray(rows2) && rows2.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
868
+ return out;
537
869
  }
538
- async function loadLatestSignupControl(db, targetId, environment) {
539
- if (!await hasSignupControl(db, targetId, environment)) return null;
540
- const rows2 = (await db.query({ signupControls: { $: {
541
- where: { targetId, environment },
542
- order: { revision: "desc" },
543
- limit: 1
544
- } } })).signupControls;
545
- const latest = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
546
- return asStored(latest);
870
+ async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
871
+ const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
872
+ method: "PATCH",
873
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
874
+ body: JSON.stringify({ public_metadata: { role } })
875
+ });
876
+ return res.ok;
547
877
  }
548
- async function loadActiveSignupControl(db, targetId, environment) {
549
- if (!await hasSignupControl(db, targetId, environment, true)) return null;
550
- const rows2 = (await db.query({ signupControls: { $: {
551
- where: { targetId, environment, active: true },
552
- order: { revision: "desc" },
553
- limit: 1
554
- } } })).signupControls;
555
- const active = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
556
- return asStored(active);
878
+
879
+ // src/crm-sync.ts
880
+ var import_crm3 = require("@odla-ai/crm");
881
+
882
+ // src/network.ts
883
+ var import_crm2 = require("@odla-ai/crm");
884
+
885
+ // src/network-contract.ts
886
+ var DEFAULT_SHARE_FIELDS = {
887
+ person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
888
+ company: ["name", "domain", "industry", "location", "linkedin", "notes"]
889
+ };
890
+
891
+ // src/network.ts
892
+ function sharedPersonInput(person) {
893
+ const email = person.email.toLowerCase();
894
+ const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
895
+ const input = { name: person.name ?? fullName ?? email, email };
896
+ if (input.name === "") input.name = email;
897
+ if (person.firstName) input.firstName = person.firstName;
898
+ if (person.lastName) input.lastName = person.lastName;
899
+ if (person.phone) input.phone = person.phone;
900
+ if (person.linkedin) input.linkedin = person.linkedin;
901
+ return input;
557
902
  }
558
- function controlledApplication(base, document) {
559
- const delegated = document?.application;
560
- return delegated ? { ...base, required: delegated.required, optional: delegated.optional, conditions: { ...delegated.conditions ?? {} } } : base;
903
+ function shortHash(value) {
904
+ let a = 2166136261;
905
+ let b = 2654435769;
906
+ for (let i = 0; i < value.length; i += 1) {
907
+ const n = value.charCodeAt(i);
908
+ a = Math.imul(a ^ n, 16777619);
909
+ b = Math.imul(b ^ n, 2246822507);
910
+ }
911
+ return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
561
912
  }
562
- function validateControlledApplication(chapter, document) {
563
- if (!document.application) return [];
564
- const declared = /* @__PURE__ */ new Set([...chapter.application.required, ...chapter.application.optional]);
565
- 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`);
913
+ function networkSourceTag(type, hubRecordId, sourceId) {
914
+ const typeKey = type.toLowerCase();
915
+ const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
916
+ const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
917
+ const raw = `${prefix}:${hubRecordId}`;
918
+ if (readable && raw.length <= 64) return raw;
919
+ return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
566
920
  }
567
- function groupAttrs(document) {
568
- const first = [...document.tiers].filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder)[0];
569
- const firstPaid = [...document.tiers].find((tier) => tier.available && tier.priceCents > 0);
921
+ function normalizeSharedRecord(record2, fallbackSourceId = "legacy-source") {
922
+ if ("input" in record2 && record2.version === 2) {
923
+ return {
924
+ version: 2,
925
+ sourceId: record2.source.siteId,
926
+ sourceRecordId: record2.source.recordId,
927
+ type: record2.type,
928
+ input: record2.input
929
+ };
930
+ }
931
+ if ("input" in record2) {
932
+ return {
933
+ version: 1,
934
+ sourceId: fallbackSourceId,
935
+ sourceRecordId: record2.hubRecordId,
936
+ type: record2.type,
937
+ input: record2.input
938
+ };
939
+ }
940
+ if ("type" in record2 && record2.type === "company") {
941
+ const input = { name: record2.name };
942
+ for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
943
+ if (record2[key]) input[key] = record2[key];
944
+ }
945
+ return {
946
+ version: 1,
947
+ sourceId: fallbackSourceId,
948
+ sourceRecordId: record2.hubRecordId,
949
+ type: "company",
950
+ input
951
+ };
952
+ }
570
953
  return {
571
- ...first ? { standardPriceCents: first.priceCents } : {},
572
- foundingDiscountCents: 0,
573
- ...firstPaid?.stripePriceId ? { stripePriceId: firstPaid.stripePriceId } : {},
574
- ...document.policy ?? {},
575
- ...document.scheduling ? { schedulingJson: document.scheduling } : {}
954
+ version: 1,
955
+ sourceId: fallbackSourceId,
956
+ sourceRecordId: record2.hubRecordId,
957
+ type: "person",
958
+ input: sharedPersonInput(record2)
576
959
  };
577
960
  }
578
- var byPublicId = (ns, value) => ({
579
- ns,
580
- attr: "id",
581
- value
582
- });
583
- async function materializeSignupControl(db, envelope, previous, receivedAt) {
584
- const { document, digest } = envelope;
585
- const currentRows = (await db.query({ tiers: { $: {
586
- where: { groupId: document.target.chapterId },
587
- limit: 100
588
- } } })).tiers ?? [];
589
- const incoming = new Set(document.tiers.map((tier) => tier.id));
590
- const ops = [];
591
- if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
592
- for (const row of currentRows) {
593
- const id2 = String(row.id ?? "");
594
- if (id2 && !incoming.has(id2)) {
595
- ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
596
- }
597
- }
598
- for (const tier of document.tiers) {
599
- const tierRef = byPublicId("tiers", tier.id);
600
- ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
601
- id: tier.id,
602
- groupId: document.target.chapterId,
603
- name: tier.name,
604
- priceCents: tier.priceCents,
605
- ...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
606
- ...tier.blurb ? { blurb: tier.blurb } : {},
607
- sortOrder: tier.sortOrder,
608
- active: tier.available,
609
- createdAt: receivedAt
610
- } });
611
- const retract = [
612
- ...!tier.stripePriceId ? ["stripePriceId"] : [],
613
- ...!tier.blurb ? ["blurb"] : []
614
- ];
615
- if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
616
- }
617
- const groupRef = byPublicId("groups", document.target.chapterId);
618
- const attrs = groupAttrs(document);
619
- ops.push({
620
- t: "update",
621
- ns: "groups",
622
- id: groupRef,
623
- attrs
624
- });
625
- if (!("stripePriceId" in attrs)) {
626
- ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
627
- }
628
- const id = `signup-control:${document.target.environment}:${document.revision}`;
629
- ops.push({ t: "update", ns: "signupControls", id, attrs: {
630
- id,
631
- sourceId: document.source.appId,
632
- targetId: document.target.chapterId,
633
- environment: document.target.environment,
634
- revision: document.revision,
635
- digest,
636
- document,
637
- active: true,
638
- receivedAt
639
- } });
640
- await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
641
- }
642
-
643
- // src/session.ts
644
- function applicationSummary(app) {
645
- return {
646
- id: app.id,
647
- firstName: app.firstName ?? null,
648
- lastName: app.lastName ?? null,
649
- email: app.email ?? null,
650
- status: app.status,
651
- createdAt: app.createdAt ?? null,
652
- meetingLink: app.meetingLink ?? null,
653
- paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
654
- renewalAt: app.renewalAt ?? null,
655
- canceled: app.canceled === true
656
- };
657
- }
658
- function memberApplication(app, meeting, defaultTimezone) {
659
- const summary = applicationSummary(app);
660
- let meetingAt = app.meetingAt ?? null;
661
- let meetUrl = null;
662
- let timezone = defaultTimezone;
663
- if (meeting) {
664
- timezone = meeting.timezone ?? timezone;
665
- if (meeting.status === "scheduled") {
666
- meetingAt = meeting.startAt ?? null;
667
- meetUrl = meeting.meetUrl ?? null;
668
- } else {
669
- meetingAt = null;
670
- }
671
- }
672
- return { ...summary, meetingAt, meetUrl, timezone };
673
- }
674
-
675
- // src/clerk.ts
676
- var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
677
- function clerkInviteRequest(input) {
678
- return {
679
- path: "/v1/invitations",
680
- body: {
681
- email_address: input.email,
682
- notify: true,
683
- ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
684
- ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
685
- }
686
- };
687
- }
688
- async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
689
- const { path, body } = clerkInviteRequest(input);
690
- const res = await fetchImpl(`https://api.clerk.com${path}`, {
691
- method: "POST",
692
- headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
693
- body: JSON.stringify(body)
694
- });
695
- return res.ok ? { ok: true, status: res.status } : heal(res.status);
696
- }
697
- function clerkUserRequest(input) {
698
- return {
699
- path: "/v1/users",
700
- body: {
701
- email_address: [input.email],
702
- skip_password_requirement: true,
703
- ...input.firstName ? { first_name: input.firstName } : {},
704
- ...input.lastName ? { last_name: input.lastName } : {},
705
- ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
706
- ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
707
- }
708
- };
709
- }
710
- async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
711
- const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
712
- method: "PATCH",
713
- headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
714
- body: JSON.stringify({
715
- ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
716
- ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
717
- })
718
- });
719
- return res.ok;
720
- }
721
- async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
722
- const auth = { authorization: `Bearer ${secretKey}` };
723
- const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
724
- if (!found.ok) return false;
725
- const users = await found.json().catch(() => null);
726
- const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
727
- if (!id) return false;
728
- return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
729
- }
730
- async function createClerkUser(secretKey, input, fetchImpl = fetch) {
731
- const { path, body } = clerkUserRequest(input);
732
- const res = await fetchImpl(`https://api.clerk.com${path}`, {
733
- method: "POST",
734
- headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
735
- body: JSON.stringify(body)
736
- });
737
- if (res.ok) return { ok: true, status: res.status };
738
- const healed = heal(res.status);
739
- const repair = input.repairMetadata ?? {
740
- ...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
741
- ...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
742
- };
743
- if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
744
- const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
745
- ...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
746
- ...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
747
- }, fetchImpl).catch(() => false);
748
- return { ...healed, refreshed };
749
- }
750
-
751
- // src/network.ts
752
- var import_crm2 = require("@odla-ai/crm");
753
-
754
- // src/network-contract.ts
755
- var DEFAULT_SHARE_FIELDS = {
756
- person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
757
- company: ["name", "domain", "industry", "location", "linkedin", "notes"]
758
- };
759
-
760
- // src/network.ts
761
- function sharedPersonInput(person) {
762
- const email = person.email.toLowerCase();
763
- const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
764
- const input = { name: person.name ?? fullName ?? email, email };
765
- if (input.name === "") input.name = email;
766
- if (person.firstName) input.firstName = person.firstName;
767
- if (person.lastName) input.lastName = person.lastName;
768
- if (person.phone) input.phone = person.phone;
769
- if (person.linkedin) input.linkedin = person.linkedin;
770
- return input;
771
- }
772
- function shortHash(value) {
773
- let a = 2166136261;
774
- let b = 2654435769;
775
- for (let i = 0; i < value.length; i += 1) {
776
- const n = value.charCodeAt(i);
777
- a = Math.imul(a ^ n, 16777619);
778
- b = Math.imul(b ^ n, 2246822507);
779
- }
780
- return `${(a >>> 0).toString(36)}${(b >>> 0).toString(36)}`;
781
- }
782
- function networkSourceTag(type, hubRecordId, sourceId) {
783
- const typeKey = type.toLowerCase();
784
- const readable = /^[a-z0-9_-]+$/.test(hubRecordId);
785
- const prefix = sourceId ? `network:${sourceId.toLowerCase()}:${typeKey}` : `network:${typeKey}`;
786
- const raw = `${prefix}:${hubRecordId}`;
787
- if (readable && raw.length <= 64) return raw;
788
- return `network:${typeKey.slice(0, 16)}:${shortHash(`${sourceId ?? ""}\0${type}\0${hubRecordId}`)}`;
789
- }
790
- function normalizeSharedRecord(record, fallbackSourceId = "legacy-source") {
791
- if ("input" in record && record.version === 2) {
792
- return {
793
- version: 2,
794
- sourceId: record.source.siteId,
795
- sourceRecordId: record.source.recordId,
796
- type: record.type,
797
- input: record.input
798
- };
799
- }
800
- if ("input" in record) {
801
- return {
802
- version: 1,
803
- sourceId: fallbackSourceId,
804
- sourceRecordId: record.hubRecordId,
805
- type: record.type,
806
- input: record.input
807
- };
808
- }
809
- if ("type" in record && record.type === "company") {
810
- const input = { name: record.name };
811
- for (const key of ["domain", "industry", "location", "linkedin", "notes"]) {
812
- if (record[key]) input[key] = record[key];
813
- }
814
- return {
815
- version: 1,
816
- sourceId: fallbackSourceId,
817
- sourceRecordId: record.hubRecordId,
818
- type: "company",
819
- input
820
- };
821
- }
822
- return {
823
- version: 1,
824
- sourceId: fallbackSourceId,
825
- sourceRecordId: record.hubRecordId,
826
- type: "person",
827
- input: sharedPersonInput(record)
828
- };
829
- }
830
- function sharedRecordFromCrm(crm, record, target, sourceId) {
831
- if (target.fields && !target.fields[record.type]) {
832
- throw new Error(`${target.name} does not accept "${record.type}" records`);
961
+ function sharedRecordFromCrm(crm, record2, target, sourceId) {
962
+ if (target.fields && !target.fields[record2.type]) {
963
+ throw new Error(`${target.name} does not accept "${record2.type}" records`);
833
964
  }
834
- const def = crm.type(record.type);
835
- const fields = target.fields?.[record.type] ?? DEFAULT_SHARE_FIELDS[record.type];
965
+ const def = crm.type(record2.type);
966
+ const fields = target.fields?.[record2.type] ?? DEFAULT_SHARE_FIELDS[record2.type];
836
967
  if (!fields) {
837
- throw new Error(`${target.name} requires an explicit field allowlist for "${record.type}" records`);
968
+ throw new Error(`${target.name} requires an explicit field allowlist for "${record2.type}" records`);
838
969
  }
839
970
  const nameField = def.nameField ?? "name";
840
971
  const input = {};
841
972
  for (const field of /* @__PURE__ */ new Set([nameField, ...fields])) {
842
- const value = record.fields?.[field];
973
+ const value = record2.fields?.[field];
843
974
  if (value !== void 0) input[field] = value;
844
975
  }
845
- if (input[nameField] === void 0) input[nameField] = record.name;
846
- return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record.id }, type: record.type, input } : { version: 1, type: record.type, hubRecordId: record.id, input };
976
+ if (input[nameField] === void 0) input[nameField] = record2.name;
977
+ return sourceId ? { version: 2, source: { siteId: sourceId, recordId: record2.id }, type: record2.type, input } : { version: 1, type: record2.type, hubRecordId: record2.id, input };
847
978
  }
848
979
  async function upsertPerson(deps, opts) {
849
980
  const email = opts.email.toLowerCase();
@@ -857,11 +988,11 @@ async function upsertPerson(deps, opts) {
857
988
  const created = await (0, import_crm2.createRecord)(crmDeps3, { type: "person", input: opts.input, mutationId: opts.mutationId });
858
989
  return { recordId: created.id };
859
990
  }
860
- async function findSharedRecord(deps, record, tag) {
991
+ async function findSharedRecord(deps, record2, tag) {
861
992
  const structured = await (0, import_crm2.getRecordByOrigin)(
862
993
  { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId },
863
- record.sourceId,
864
- record.sourceRecordId
994
+ record2.sourceId,
995
+ record2.sourceRecordId
865
996
  );
866
997
  if (structured) return structured;
867
998
  const mapped = await deps.db.query({ crm_tag: { $: { where: { tag }, limit: 1 } } });
@@ -870,53 +1001,53 @@ async function findSharedRecord(deps, record, tag) {
870
1001
  const found = await deps.db.query({ crm_record: { $: { where: { id: mappedId }, limit: 1 } } });
871
1002
  if (found.crm_record?.[0]) return found.crm_record[0];
872
1003
  }
873
- const def = deps.crm.type(record.type);
1004
+ const def = deps.crm.type(record2.type);
874
1005
  const emailField = def.emailField;
875
- if (emailField && typeof record.input[emailField] === "string") {
876
- const primaryEmail = record.input[emailField].toLowerCase();
1006
+ if (emailField && typeof record2.input[emailField] === "string") {
1007
+ const primaryEmail = record2.input[emailField].toLowerCase();
877
1008
  const found = await deps.db.query({
878
- crm_record: { $: { where: { type: record.type, primaryEmail }, limit: 1 } }
1009
+ crm_record: { $: { where: { type: record2.type, primaryEmail }, limit: 1 } }
879
1010
  });
880
1011
  if (found.crm_record?.[0]) return found.crm_record[0];
881
1012
  }
882
- const domain = record.input.domain;
1013
+ const domain = record2.input.domain;
883
1014
  const domainSlot = def.fields.domain?.slot;
884
1015
  if (typeof domain === "string" && domainSlot) {
885
1016
  const found = await deps.db.query({
886
- crm_record: { $: { where: { type: record.type, [domainSlot]: domain }, limit: 1 } }
1017
+ crm_record: { $: { where: { type: record2.type, [domainSlot]: domain }, limit: 1 } }
887
1018
  });
888
1019
  if (found.crm_record?.[0]) return found.crm_record[0];
889
1020
  }
890
1021
  const nameField = def.nameField ?? "name";
891
- const name = record.input[nameField];
892
- if (record.type === "company" && typeof name === "string" && name.trim()) {
1022
+ const name = record2.input[nameField];
1023
+ if (record2.type === "company" && typeof name === "string" && name.trim()) {
893
1024
  const found = await deps.db.query({
894
- crm_record: { $: { where: { type: record.type, name: name.trim() }, limit: 1 } }
1025
+ crm_record: { $: { where: { type: record2.type, name: name.trim() }, limit: 1 } }
895
1026
  });
896
1027
  if (found.crm_record?.[0]) return found.crm_record[0];
897
1028
  }
898
1029
  return void 0;
899
1030
  }
900
1031
  async function projectSharedRecord(deps, shared, options = {}) {
901
- const record = normalizeSharedRecord(shared, options.sourceId);
902
- if (!record.type.trim() || !record.sourceId.trim() || !record.sourceRecordId.trim()) {
1032
+ const record2 = normalizeSharedRecord(shared, options.sourceId);
1033
+ if (!record2.type.trim() || !record2.sourceId.trim() || !record2.sourceRecordId.trim()) {
903
1034
  throw new Error("type, source site id, and source record id must be non-empty");
904
1035
  }
905
- if (options.sourceId && options.sourceId !== record.sourceId) {
1036
+ if (options.sourceId && options.sourceId !== record2.sourceId) {
906
1037
  throw new Error("signed sender does not match payload source");
907
1038
  }
908
- const tag = networkSourceTag(record.type, record.sourceRecordId, record.version === 2 ? record.sourceId : void 0);
1039
+ const tag = networkSourceTag(record2.type, record2.sourceRecordId, record2.version === 2 ? record2.sourceId : void 0);
909
1040
  const crmDeps3 = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
910
- const existing = await findSharedRecord(deps, record, tag);
1041
+ const existing = await findSharedRecord(deps, record2, tag);
911
1042
  let recordId;
912
1043
  if (existing && typeof existing.id === "string") {
913
- await (0, import_crm2.updateRecord)(crmDeps3, { id: existing.id, input: record.input });
1044
+ await (0, import_crm2.updateRecord)(crmDeps3, { id: existing.id, input: record2.input });
914
1045
  recordId = existing.id;
915
1046
  } else {
916
- recordId = `network_${shortHash(`${record.sourceId}\0${record.type}\0${record.sourceRecordId}`)}`;
1047
+ recordId = `network_${shortHash(`${record2.sourceId}\0${record2.type}\0${record2.sourceRecordId}`)}`;
917
1048
  await (0, import_crm2.createRecord)({ ...crmDeps3, newId: () => recordId }, {
918
- type: record.type,
919
- input: record.input,
1049
+ type: record2.type,
1050
+ input: record2.input,
920
1051
  mutationId: `share-create:${tag}`
921
1052
  });
922
1053
  }
@@ -926,10 +1057,10 @@ async function projectSharedRecord(deps, shared, options = {}) {
926
1057
  );
927
1058
  await (0, import_crm2.upsertRecordOrigin)(crmDeps3, {
928
1059
  recordId,
929
- sourceId: record.sourceId,
930
- sourceRecordId: record.sourceRecordId,
1060
+ sourceId: record2.sourceId,
1061
+ sourceRecordId: record2.sourceRecordId,
931
1062
  ...options.sourceUrl ? { sourceUrl: options.sourceUrl } : {},
932
- payloadVersion: record.version
1063
+ payloadVersion: record2.version
933
1064
  });
934
1065
  return { recordId };
935
1066
  }
@@ -952,14 +1083,100 @@ async function projectApplicant(deps, applicant) {
952
1083
  }
953
1084
  }
954
1085
 
955
- // src/email.ts
956
- function render(template, vars) {
957
- return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
1086
+ // src/crm-sync.ts
1087
+ var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
1088
+ var crmDeps = (db, ctx) => ({
1089
+ crm: ctx.chapter.crm,
1090
+ db,
1091
+ now: () => Date.now(),
1092
+ newId: () => crypto.randomUUID(),
1093
+ chapter: ctx.chapter
1094
+ });
1095
+ function personInputFromApp(chapter, app) {
1096
+ const input = sharedPersonInput({
1097
+ email: str(app.email),
1098
+ firstName: str(app.firstName) || void 0,
1099
+ lastName: str(app.lastName) || void 0,
1100
+ phone: str(app.phone) || void 0,
1101
+ linkedin: str(app.linkedin) || void 0,
1102
+ hubRecordId: str(app.id)
1103
+ });
1104
+ for (const f of chapter.application.crmFields) {
1105
+ if (app[f] !== void 0) input[f] = app[f];
1106
+ }
1107
+ if (app.id !== void 0) input.applicationId = str(app.id);
1108
+ return input;
958
1109
  }
959
- function groupVars(group, vars) {
960
- return {
961
- ...vars,
962
- refundPolicyText: group.refundPolicyText ?? "",
1110
+ function billingColumns(app) {
1111
+ const status = str(app.status);
1112
+ const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
1113
+ const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
1114
+ const cols = { billingStatus };
1115
+ if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
1116
+ if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
1117
+ if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
1118
+ return cols;
1119
+ }
1120
+ async function syncApplicationToCrm(deps, opts) {
1121
+ const emailKey = str(opts.app.email).toLowerCase();
1122
+ if (!emailKey) return null;
1123
+ const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1124
+ const input = personInputFromApp(deps.chapter, opts.app);
1125
+ const { crm_record } = await deps.db.query({
1126
+ crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
1127
+ });
1128
+ const existing = crm_record?.[0] ?? null;
1129
+ const stage = opts.stage || void 0;
1130
+ let recordId;
1131
+ if (existing && typeof existing.id === "string") {
1132
+ recordId = existing.id;
1133
+ await (0, import_crm3.updateRecord)(recordDeps, { id: recordId, input });
1134
+ } else {
1135
+ const created = await (0, import_crm3.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
1136
+ recordId = created.id;
1137
+ }
1138
+ if (existing && stage && existing.stage !== stage) {
1139
+ await (0, import_crm3.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
1140
+ }
1141
+ await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1142
+ await (0, import_crm3.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1143
+ return recordId;
1144
+ }
1145
+ async function backfillCrm(deps) {
1146
+ const [appsRes, usersRes] = await Promise.all([
1147
+ deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
1148
+ deps.db.query({ $users: { $: { limit: 1e3 } } })
1149
+ ]);
1150
+ const seen = /* @__PURE__ */ new Set();
1151
+ let synced = 0;
1152
+ const errors = [];
1153
+ const run = async (app, stage) => {
1154
+ const key = str(app.email).toLowerCase();
1155
+ if (!key || seen.has(key)) return;
1156
+ seen.add(key);
1157
+ try {
1158
+ await syncApplicationToCrm(deps, { app, stage });
1159
+ synced += 1;
1160
+ } catch (err) {
1161
+ errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
1162
+ }
1163
+ };
1164
+ for (const a of appsRes.applications ?? []) await run(a, str(a.status));
1165
+ for (const u of usersRes.$users ?? []) {
1166
+ if (u.deleted === true) continue;
1167
+ await run({ email: u.email, firstName: str(u.name) });
1168
+ }
1169
+ return { synced, errors };
1170
+ }
1171
+
1172
+ // src/email.ts
1173
+ function render(template, vars) {
1174
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? "");
1175
+ }
1176
+ function groupVars(group, vars) {
1177
+ return {
1178
+ ...vars,
1179
+ refundPolicyText: group.refundPolicyText ?? "",
963
1180
  commitmentText: group.commitmentText ?? "",
964
1181
  normsText: group.normsText ?? ""
965
1182
  };
@@ -982,10 +1199,10 @@ function planDelivery(input) {
982
1199
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
983
1200
  const to = redirect ? input.group.debugEmail : input.to;
984
1201
  const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
985
- const text2 = redirect ? `(dev redirect; original recipient: ${input.to})
1202
+ const text3 = redirect ? `(dev redirect; original recipient: ${input.to})
986
1203
 
987
1204
  ` + render(tpl.text, vars) : render(tpl.text, vars);
988
- return { deliver: true, transport, to, subject, text: text2, redirected: redirect };
1205
+ return { deliver: true, transport, to, subject, text: text3, redirected: redirect };
989
1206
  }
990
1207
 
991
1208
  // src/notify.ts
@@ -1020,39 +1237,372 @@ async function sendTemplated(deps, input) {
1020
1237
  } catch (e) {
1021
1238
  error = e instanceof Error ? e.message : String(e);
1022
1239
  }
1023
- }
1024
- const id = deps.newId();
1025
- const row = {
1026
- id,
1027
- groupId: input.group.id,
1028
- to: decision.to,
1029
- template: input.template,
1030
- subject: decision.subject,
1031
- body: decision.text,
1032
- transport: decision.transport,
1033
- redirected: decision.redirected,
1034
- dedupeKey: input.dedupeKey,
1035
- sentAt: deps.now(),
1036
- ...input.applicationId ? { applicationId: input.applicationId } : {},
1037
- ...messageId ? { messageId } : {},
1038
- ...error ? { error } : {}
1240
+ }
1241
+ const id = deps.newId();
1242
+ const row = {
1243
+ id,
1244
+ groupId: input.group.id,
1245
+ to: decision.to,
1246
+ template: input.template,
1247
+ subject: decision.subject,
1248
+ body: decision.text,
1249
+ transport: decision.transport,
1250
+ redirected: decision.redirected,
1251
+ dedupeKey: input.dedupeKey,
1252
+ sentAt: deps.now(),
1253
+ ...input.applicationId ? { applicationId: input.applicationId } : {},
1254
+ ...messageId ? { messageId } : {},
1255
+ ...error ? { error } : {}
1256
+ };
1257
+ await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
1258
+ return error ? { sent: false, reason: error } : { sent: true };
1259
+ }
1260
+ function emailGroupFrom(row) {
1261
+ const str3 = (v) => typeof v === "string" ? v : void 0;
1262
+ const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
1263
+ return {
1264
+ id: String(row.id),
1265
+ name: String(row.name ?? ""),
1266
+ replyTo: str3(row.replyTo) ?? "",
1267
+ debugEmail: str3(row.debugEmail),
1268
+ refundPolicyText: str3(row.refundPolicyText),
1269
+ commitmentText: str3(row.commitmentText),
1270
+ normsText: str3(row.normsText),
1271
+ emailTemplates: templates
1272
+ };
1273
+ }
1274
+
1275
+ // src/pipeline.ts
1276
+ function canTransition(from, to, p) {
1277
+ const fi = p.stages.indexOf(from);
1278
+ const ti = p.stages.indexOf(to);
1279
+ return fi >= 0 && ti >= 0 && ti >= fi;
1280
+ }
1281
+ function canBook(status, p) {
1282
+ return p.bookableFrom.includes(status);
1283
+ }
1284
+ function canApprove(status, p) {
1285
+ return p.approvableFrom.includes(status);
1286
+ }
1287
+
1288
+ // src/approve-application.ts
1289
+ async function approveApplication(input) {
1290
+ const { db, env, ctx, app } = input;
1291
+ const id = String(app.id ?? "");
1292
+ const status = String(app.status ?? "");
1293
+ if (!id) return { ok: false, status: 404, error: "not found" };
1294
+ if (input.reason === "manual" && status !== "approved" && !canApprove(status, ctx.chapter.pipeline)) {
1295
+ return { ok: false, status: 409, error: `cannot approve from status "${status}"` };
1296
+ }
1297
+ if (input.reason === "interview_waiver") {
1298
+ if (typeof app.interviewWaivedAt !== "number") return { ok: false, status: 409, error: "interview waiver required" };
1299
+ if (status !== "approved" && !canTransition(status, "approved", ctx.chapter.pipeline)) {
1300
+ return { ok: false, status: 409, error: `cannot approve from status "${status}"` };
1301
+ }
1302
+ }
1303
+ const write = await db.transact(
1304
+ [{ t: "update", ns: "applications", id, attrs: { status: "approved" } }],
1305
+ { mutationId: `approve:${id}` }
1306
+ );
1307
+ const approved = { ...app, status: "approved" };
1308
+ await syncApplicationToCrm(crmDeps(db, ctx), { app: approved, stage: "approved" }).catch(() => void 0);
1309
+ const { promoteTo, send } = ctx.chapter.operations.onApprove;
1310
+ let rolePromoted = false;
1311
+ const secret = await getVaultSecret(db, "clerk_secret_key");
1312
+ if (promoteTo !== false && secret) {
1313
+ let userId = typeof app.clerkUserId === "string" ? app.clerkUserId : null;
1314
+ if (!userId && typeof app.email === "string") {
1315
+ const found = await clerkGetUserByEmail(secret, app.email);
1316
+ userId = found?.id ?? null;
1317
+ if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
1318
+ }
1319
+ if (userId) rolePromoted = await clerkSetRole(secret, userId, promoteTo);
1320
+ }
1321
+ let emailLogged = false;
1322
+ const groups = (await db.query({ groups: { $: { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 } } })).groups;
1323
+ const group = Array.isArray(groups) ? groups[0] : void 0;
1324
+ if (send !== false && group && typeof app.email === "string") {
1325
+ const result = await sendTemplated(
1326
+ { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
1327
+ {
1328
+ group: emailGroupFrom(group),
1329
+ template: send,
1330
+ to: app.email,
1331
+ vars: { firstName: String(app.firstName ?? ""), membersUrl: `${input.origin}/members/` },
1332
+ applicationId: id,
1333
+ dedupeKey: `approve:${id}`
1334
+ }
1335
+ );
1336
+ emailLogged = result.sent;
1337
+ }
1338
+ return { ok: true, status: 200, rolePromoted, emailLogged, duplicate: write.duplicate };
1339
+ }
1340
+
1341
+ // src/join-continuation.ts
1342
+ function resolveJoinContinuation(input) {
1343
+ if (input.status === "approved") return "accepted";
1344
+ if (input.interviewWaived) {
1345
+ if (!input.payable) return "approve";
1346
+ if (input.status === input.initialStatus) {
1347
+ return input.subscriptionStarted ? "paymentPending" : "payment";
1348
+ }
1349
+ return "approve";
1350
+ }
1351
+ if (!input.payable) return "booking";
1352
+ if (input.status === input.initialStatus) {
1353
+ return input.subscriptionStarted ? "paymentPending" : "payment";
1354
+ }
1355
+ if (canBook(input.status, input.pipeline)) return "booking";
1356
+ return null;
1357
+ }
1358
+
1359
+ // src/join-continuation-server.ts
1360
+ async function continueJoin(db, ctx, env, origin, app, group) {
1361
+ if (typeof app.giftOfferId === "string") {
1362
+ if (typeof app.giftClaimId === "string") return "gift";
1363
+ return typeof app.giftCheckoutStartedAt === "number" ? "paymentPending" : "payment";
1364
+ }
1365
+ const stripeKey = await getVaultSecret(db, "stripe_secret_key");
1366
+ const offered = await loadOfferableTiers(db, group, Boolean(stripeKey));
1367
+ const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
1368
+ const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
1369
+ const state = resolveJoinContinuation({
1370
+ status: String(app.status ?? ""),
1371
+ initialStatus: ctx.chapter.pipeline.initial,
1372
+ pipeline: ctx.chapter.pipeline,
1373
+ payable,
1374
+ subscriptionStarted: typeof app.stripeSubscriptionId === "string" && app.stripeSubscriptionId !== "",
1375
+ interviewWaived: typeof app.interviewWaivedAt === "number"
1376
+ });
1377
+ if (state !== "approve") return state;
1378
+ const approved = await approveApplication({ db, env, ctx, app, origin, reason: "interview_waiver" });
1379
+ return approved.ok ? "accepted" : null;
1380
+ }
1381
+
1382
+ // src/signup-control-store.ts
1383
+ function asStored(row) {
1384
+ if (!row || typeof row.document !== "object" || row.document === null) return null;
1385
+ return row;
1386
+ }
1387
+ async function hasSignupControl(db, targetId, environment, active) {
1388
+ const where = { targetId, environment, ...active ? { active: true } : {} };
1389
+ if (db.aggregate) {
1390
+ return Number((await db.aggregate("signupControls", { count: true }, { where })).count ?? 0) > 0;
1391
+ }
1392
+ const rows4 = (await db.query({ signupControls: {} })).signupControls;
1393
+ return Array.isArray(rows4) && rows4.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
1394
+ }
1395
+ async function loadLatestSignupControl(db, targetId, environment) {
1396
+ if (!await hasSignupControl(db, targetId, environment)) return null;
1397
+ const rows4 = (await db.query({ signupControls: { $: {
1398
+ where: { targetId, environment },
1399
+ order: { revision: "desc" },
1400
+ limit: 1
1401
+ } } })).signupControls;
1402
+ const latest = Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
1403
+ return asStored(latest);
1404
+ }
1405
+ async function loadActiveSignupControl(db, targetId, environment) {
1406
+ if (!await hasSignupControl(db, targetId, environment, true)) return null;
1407
+ const rows4 = (await db.query({ signupControls: { $: {
1408
+ where: { targetId, environment, active: true },
1409
+ order: { revision: "desc" },
1410
+ limit: 1
1411
+ } } })).signupControls;
1412
+ const active = Array.isArray(rows4) ? [...rows4].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
1413
+ return asStored(active);
1414
+ }
1415
+ function controlledApplication(base, document) {
1416
+ const delegated = document?.application;
1417
+ return delegated ? { ...base, required: delegated.required, optional: delegated.optional, conditions: { ...delegated.conditions ?? {} } } : base;
1418
+ }
1419
+ function validateControlledApplication(chapter, document) {
1420
+ if (!document.application) return [];
1421
+ const declared = /* @__PURE__ */ new Set([...chapter.application.required, ...chapter.application.optional]);
1422
+ 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`);
1423
+ }
1424
+ function groupAttrs(document) {
1425
+ const first4 = [...document.tiers].filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder)[0];
1426
+ const firstPaid = [...document.tiers].find((tier) => tier.available && tier.priceCents > 0);
1427
+ return {
1428
+ ...first4 ? { standardPriceCents: first4.priceCents } : {},
1429
+ foundingDiscountCents: 0,
1430
+ ...firstPaid?.stripePriceId ? { stripePriceId: firstPaid.stripePriceId } : {},
1431
+ ...document.policy ?? {},
1432
+ ...document.scheduling ? { schedulingJson: document.scheduling } : {}
1433
+ };
1434
+ }
1435
+ var byPublicId = (ns, value) => ({
1436
+ ns,
1437
+ attr: "id",
1438
+ value
1439
+ });
1440
+ async function materializeSignupControl(db, envelope, previous, receivedAt) {
1441
+ const { document, digest } = envelope;
1442
+ const currentRows = (await db.query({ tiers: { $: {
1443
+ where: { groupId: document.target.chapterId },
1444
+ limit: 100
1445
+ } } })).tiers ?? [];
1446
+ const incoming = new Set(document.tiers.map((tier) => tier.id));
1447
+ const ops = [];
1448
+ if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
1449
+ for (const row of currentRows) {
1450
+ const id2 = String(row.id ?? "");
1451
+ if (id2 && !incoming.has(id2)) {
1452
+ ops.push({ t: "update", ns: "tiers", id: byPublicId("tiers", id2), attrs: { active: false } });
1453
+ }
1454
+ }
1455
+ for (const tier of document.tiers) {
1456
+ const tierRef = byPublicId("tiers", tier.id);
1457
+ ops.push({ t: "update", ns: "tiers", id: tierRef, attrs: {
1458
+ id: tier.id,
1459
+ groupId: document.target.chapterId,
1460
+ name: tier.name,
1461
+ priceCents: tier.priceCents,
1462
+ ...tier.stripePriceId ? { stripePriceId: tier.stripePriceId } : {},
1463
+ ...tier.blurb ? { blurb: tier.blurb } : {},
1464
+ sortOrder: tier.sortOrder,
1465
+ active: tier.available,
1466
+ createdAt: receivedAt
1467
+ } });
1468
+ const retract = [
1469
+ ...!tier.stripePriceId ? ["stripePriceId"] : [],
1470
+ ...!tier.blurb ? ["blurb"] : []
1471
+ ];
1472
+ if (retract.length) ops.push({ t: "retract", ns: "tiers", id: tierRef, attrs: retract });
1473
+ }
1474
+ const groupRef = byPublicId("groups", document.target.chapterId);
1475
+ const attrs = groupAttrs(document);
1476
+ ops.push({
1477
+ t: "update",
1478
+ ns: "groups",
1479
+ id: groupRef,
1480
+ attrs
1481
+ });
1482
+ if (!("stripePriceId" in attrs)) {
1483
+ ops.push({ t: "retract", ns: "groups", id: groupRef, attrs: ["stripePriceId"] });
1484
+ }
1485
+ const id = `signup-control:${document.target.environment}:${document.revision}`;
1486
+ ops.push({ t: "update", ns: "signupControls", id, attrs: {
1487
+ id,
1488
+ sourceId: document.source.appId,
1489
+ targetId: document.target.chapterId,
1490
+ environment: document.target.environment,
1491
+ revision: document.revision,
1492
+ digest,
1493
+ document,
1494
+ active: true,
1495
+ receivedAt
1496
+ } });
1497
+ await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
1498
+ }
1499
+
1500
+ // src/session.ts
1501
+ function applicationSummary(app) {
1502
+ return {
1503
+ id: app.id,
1504
+ firstName: app.firstName ?? null,
1505
+ lastName: app.lastName ?? null,
1506
+ email: app.email ?? null,
1507
+ status: app.status,
1508
+ createdAt: app.createdAt ?? null,
1509
+ meetingLink: app.meetingLink ?? null,
1510
+ paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
1511
+ renewalAt: app.renewalAt ?? null,
1512
+ canceled: app.canceled === true
1513
+ };
1514
+ }
1515
+ function memberApplication(app, meeting, defaultTimezone) {
1516
+ const summary = applicationSummary(app);
1517
+ let meetingAt = app.meetingAt ?? null;
1518
+ let meetUrl = null;
1519
+ let timezone = defaultTimezone;
1520
+ if (meeting) {
1521
+ timezone = meeting.timezone ?? timezone;
1522
+ if (meeting.status === "scheduled") {
1523
+ meetingAt = meeting.startAt ?? null;
1524
+ meetUrl = meeting.meetUrl ?? null;
1525
+ } else {
1526
+ meetingAt = null;
1527
+ }
1528
+ }
1529
+ return { ...summary, meetingAt, meetUrl, timezone };
1530
+ }
1531
+
1532
+ // src/clerk.ts
1533
+ var heal = (status) => status === 422 ? { ok: true, status, existed: true } : { ok: false, status };
1534
+ function clerkInviteRequest(input) {
1535
+ return {
1536
+ path: "/v1/invitations",
1537
+ body: {
1538
+ email_address: input.email,
1539
+ notify: true,
1540
+ ...input.redirectUrl ? { redirect_url: input.redirectUrl } : {},
1541
+ ...input.publicMetadata ? { public_metadata: input.publicMetadata } : {}
1542
+ }
1039
1543
  };
1040
- await deps.db.transact([{ t: "update", ns: "emailLog", id, attrs: row }], error ? void 0 : { mutationId: `email:${input.dedupeKey}` });
1041
- return error ? { sent: false, reason: error } : { sent: true };
1042
1544
  }
1043
- function emailGroupFrom(row) {
1044
- const str3 = (v) => typeof v === "string" ? v : void 0;
1045
- const templates = row.emailTemplates && typeof row.emailTemplates === "object" ? row.emailTemplates : {};
1545
+ async function createClerkInvitation(secretKey, input, fetchImpl = fetch) {
1546
+ const { path, body } = clerkInviteRequest(input);
1547
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
1548
+ method: "POST",
1549
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1550
+ body: JSON.stringify(body)
1551
+ });
1552
+ return res.ok ? { ok: true, status: res.status } : heal(res.status);
1553
+ }
1554
+ function clerkUserRequest(input) {
1046
1555
  return {
1047
- id: String(row.id),
1048
- name: String(row.name ?? ""),
1049
- replyTo: str3(row.replyTo) ?? "",
1050
- debugEmail: str3(row.debugEmail),
1051
- refundPolicyText: str3(row.refundPolicyText),
1052
- commitmentText: str3(row.commitmentText),
1053
- normsText: str3(row.normsText),
1054
- emailTemplates: templates
1556
+ path: "/v1/users",
1557
+ body: {
1558
+ email_address: [input.email],
1559
+ skip_password_requirement: true,
1560
+ ...input.firstName ? { first_name: input.firstName } : {},
1561
+ ...input.lastName ? { last_name: input.lastName } : {},
1562
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
1563
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
1564
+ }
1565
+ };
1566
+ }
1567
+ async function updateClerkUserMetadata(secretKey, userId, input, fetchImpl = fetch) {
1568
+ const res = await fetchImpl(`https://api.clerk.com/v1/users/${encodeURIComponent(userId)}/metadata`, {
1569
+ method: "PATCH",
1570
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1571
+ body: JSON.stringify({
1572
+ ...input.publicMetadata !== void 0 ? { public_metadata: input.publicMetadata } : {},
1573
+ ...input.privateMetadata !== void 0 ? { private_metadata: input.privateMetadata } : {}
1574
+ })
1575
+ });
1576
+ return res.ok;
1577
+ }
1578
+ async function updateClerkUserMetadataByEmail(secretKey, email, input, fetchImpl = fetch) {
1579
+ const auth = { authorization: `Bearer ${secretKey}` };
1580
+ const found = await fetchImpl(`https://api.clerk.com/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, { headers: auth });
1581
+ if (!found.ok) return false;
1582
+ const users = await found.json().catch(() => null);
1583
+ const id = Array.isArray(users) && typeof users[0]?.id === "string" ? users[0].id : void 0;
1584
+ if (!id) return false;
1585
+ return updateClerkUserMetadata(secretKey, id, input, fetchImpl);
1586
+ }
1587
+ async function createClerkUser(secretKey, input, fetchImpl = fetch) {
1588
+ const { path, body } = clerkUserRequest(input);
1589
+ const res = await fetchImpl(`https://api.clerk.com${path}`, {
1590
+ method: "POST",
1591
+ headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
1592
+ body: JSON.stringify(body)
1593
+ });
1594
+ if (res.ok) return { ok: true, status: res.status };
1595
+ const healed = heal(res.status);
1596
+ const repair = input.repairMetadata ?? {
1597
+ ...input.publicMetadata !== void 0 ? { publicMetadata: input.publicMetadata } : {},
1598
+ ...input.privateMetadata !== void 0 ? { privateMetadata: input.privateMetadata } : {}
1055
1599
  };
1600
+ if (!healed.existed || repair.publicMetadata === void 0 && repair.privateMetadata === void 0) return healed;
1601
+ const refreshed = await updateClerkUserMetadataByEmail(secretKey, input.email, {
1602
+ ...repair.publicMetadata !== void 0 ? { publicMetadata: repair.publicMetadata } : {},
1603
+ ...repair.privateMetadata !== void 0 ? { privateMetadata: repair.privateMetadata } : {}
1604
+ }, fetchImpl).catch(() => false);
1605
+ return { ...healed, refreshed };
1056
1606
  }
1057
1607
 
1058
1608
  // src/worker-provisioning.ts
@@ -1182,8 +1732,12 @@ async function notifyAdminOfApplication(db, env, chapterId, applicationId, field
1182
1732
 
1183
1733
  // src/worker-routes.ts
1184
1734
  async function memberSessionApplication(db, chapterId, email) {
1185
- const apps = (await db.query({ applications: { $: { where: { email }, order: { createdAt: "desc" }, limit: 1 } } })).applications;
1186
- const app = Array.isArray(apps) ? apps[0] : void 0;
1735
+ let apps = (await db.query({ applications: { $: {
1736
+ where: { emailNormalized: normalizeAdmissionEmail(email) },
1737
+ limit: 20
1738
+ } } })).applications;
1739
+ if (!apps?.length) apps = (await db.query({ applications: { $: { where: { email }, limit: 20 } } })).applications;
1740
+ const app = Array.isArray(apps) ? [...apps].sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0] : void 0;
1187
1741
  if (!app) return null;
1188
1742
  const meetings = (await db.query({ meetings: { $: { where: { applicationId: app.id, status: "scheduled" }, order: { createdAt: "desc" }, limit: 1 } } })).meetings;
1189
1743
  const meeting = Array.isArray(meetings) ? meetings[0] : void 0;
@@ -1239,7 +1793,7 @@ var handleCrm = async (req, url, env, ctx) => {
1239
1793
  const crmBase = ctx.crmBase;
1240
1794
  if (url.pathname !== crmBase && !url.pathname.startsWith(crmBase + "/")) return null;
1241
1795
  const db = ctx.makeDb(env);
1242
- const routes = (0, import_crm3.createCrmRoutes)({
1796
+ const routes = (0, import_crm4.createCrmRoutes)({
1243
1797
  crm: ctx.chapter.crm,
1244
1798
  db,
1245
1799
  authorize: async (r) => {
@@ -1285,7 +1839,8 @@ var handleMember = async (req, url, env, ctx) => {
1285
1839
  membershipPage: activeControl.document.membershipPage,
1286
1840
  application: activeControl.document.application,
1287
1841
  theme: activeControl.document.theme
1288
- } } : {}
1842
+ } } : {},
1843
+ giftOffers: publicGiftOffers(activeControl?.document.giftOffers, Date.now())
1289
1844
  });
1290
1845
  }
1291
1846
  if (req.method === "GET" && url.pathname === "/api/join/resume") {
@@ -1324,18 +1879,9 @@ var handleMember = async (req, url, env, ctx) => {
1324
1879
  })).groups;
1325
1880
  const group = Array.isArray(groups) ? groups[0] : void 0;
1326
1881
  if (!group) return json({ error: "not found" }, 404);
1327
- const stripeKey = await getVaultSecret(db, "stripe_secret_key");
1328
- const status = String(app.status ?? "");
1329
- const offered = await loadOfferableTiers(db, group, Boolean(stripeKey));
1330
- const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
1331
- const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
1332
- if (!payable) return json({ state: "booking", applicationId });
1333
- if (status === chapter.pipeline.initial && app.stripeSubscriptionId) {
1334
- return json({ state: "paymentPending", applicationId });
1335
- }
1336
- if (status === chapter.pipeline.initial) return json({ state: "payment", applicationId });
1337
- if (canBook(status, chapter.pipeline)) return json({ state: "booking", applicationId });
1338
- return json({ error: `application cannot resume from status "${status}"` }, 409);
1882
+ const state = await continueJoin(db, ctx, env, url.origin, app, group);
1883
+ if (state) return json({ state, applicationId, ...state === "gift" ? { claimId: app.giftClaimId } : {} });
1884
+ return json({ error: `application cannot resume from status "${String(app.status ?? "")}"` }, 409);
1339
1885
  }
1340
1886
  if (req.method === "POST" && url.pathname === "/api/applications") {
1341
1887
  const raw = await req.text();
@@ -1351,12 +1897,19 @@ var handleMember = async (req, url, env, ctx) => {
1351
1897
  const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
1352
1898
  const effectiveChapter = activeControl ? { ...chapter, application: controlledApplication(chapter.application, activeControl.document) } : chapter;
1353
1899
  const offeredTierIds = typeof parsed.tierId === "string" && parsed.tierId ? (await loadTiers(db, { id: chapter.id })).map((tier) => tier.id) : void 0;
1900
+ const now = Date.now();
1901
+ const requestedGiftId = typeof parsed.giftOfferId === "string" ? parsed.giftOfferId : "";
1902
+ const giftOffer = requestedGiftId ? findGiftOffer(activeControl?.document.giftOffers, requestedGiftId, now) : null;
1903
+ if (requestedGiftId && !giftOffer) return json({ error: "gift offer is unavailable" }, 400);
1904
+ const effectiveGrant = !giftOffer && typeof parsed.email === "string" ? await findAdmissionGrant(db, chapter.id, parsed.email, now) : null;
1354
1905
  const result = await submitApplication(db, effectiveChapter, parsed, {
1355
1906
  submissionId,
1356
1907
  groupId: chapter.id,
1357
- now: Date.now(),
1908
+ now,
1358
1909
  newId: () => crypto.randomUUID(),
1359
- ...offeredTierIds ? { tierIds: offeredTierIds } : {}
1910
+ ...offeredTierIds ? { tierIds: offeredTierIds } : {},
1911
+ ...effectiveGrant?.decision === "waive_interview" ? { admissionGrant: effectiveGrant } : {},
1912
+ ...giftOffer ? { giftOfferId: giftOffer.id } : {}
1360
1913
  });
1361
1914
  if (!result.ok) return json({ error: result.error }, 400);
1362
1915
  if (!result.duplicate) {
@@ -1365,10 +1918,16 @@ var handleMember = async (req, url, env, ctx) => {
1365
1918
  }
1366
1919
  await provisionApplicant(db, effectiveChapter, result.id, parsed);
1367
1920
  }
1921
+ const apps = (await db.query({ applications: { $: { where: { id: result.id }, limit: 1 } } })).applications;
1922
+ const canonical2 = Array.isArray(apps) ? apps[0] : void 0;
1923
+ const groups = (await db.query({ groups: { $: { where: { id: chapter.id }, limit: 1 } } })).groups;
1924
+ const group = Array.isArray(groups) ? groups[0] : void 0;
1925
+ const state = canonical2 && group ? await continueJoin(db, ctx, env, url.origin, canonical2, group) : null;
1368
1926
  return json({
1369
1927
  id: result.id,
1370
1928
  duplicate: result.duplicate,
1371
1929
  status: result.status,
1930
+ ...state ? { state } : {},
1372
1931
  // Echoed so a site can see (and assert in an integration test) whether its
1373
1932
  // join page actually posted the ack. Absent consent is otherwise invisible.
1374
1933
  disclaimerAckAt: result.disclaimerAckAt
@@ -1424,8 +1983,8 @@ var handleNetworkShared = async (req, url, env, ctx) => {
1424
1983
  payload,
1425
1984
  sourceId ? { sourceId } : {}
1426
1985
  );
1427
- const record = normalizeSharedRecord(payload, sourceId);
1428
- return json({ recordId, type: record.type });
1986
+ const record2 = normalizeSharedRecord(payload, sourceId);
1987
+ return json({ recordId, type: record2.type });
1429
1988
  } catch (error) {
1430
1989
  return json({
1431
1990
  error: error instanceof Error ? error.message : "invalid shared record"
@@ -1436,29 +1995,6 @@ var handleNetworkShared = async (req, url, env, ctx) => {
1436
1995
  // src/worker-routes-signup-control.ts
1437
1996
  var import_db3 = require("@odla-ai/db");
1438
1997
 
1439
- // src/signup-control-canonical.ts
1440
- var encoder = new TextEncoder();
1441
- function canonicalValue(value) {
1442
- if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
1443
- return JSON.stringify(value);
1444
- }
1445
- if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
1446
- if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
1447
- const record = value;
1448
- const entries = Object.keys(record).sort().flatMap((key) => {
1449
- const item = record[key];
1450
- return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
1451
- });
1452
- return `{${entries.join(",")}}`;
1453
- }
1454
- function canonicalSignupControl(value) {
1455
- return canonicalValue(value);
1456
- }
1457
- async function signupControlDigest(document) {
1458
- const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
1459
- return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
1460
- }
1461
-
1462
1998
  // src/signup-control-contract.ts
1463
1999
  var SIGNUP_CONTROL_PATH = "/api/signup-control";
1464
2000
 
@@ -1502,10 +2038,10 @@ function boundedString(value, path, errors, max, required = false) {
1502
2038
  errors.push(`${path} must be a string`);
1503
2039
  return void 0;
1504
2040
  }
1505
- const text2 = value.trim();
1506
- if (required && !text2) errors.push(`${path} is required`);
1507
- if (text2.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
1508
- return text2;
2041
+ const text3 = value.trim();
2042
+ if (required && !text3) errors.push(`${path} is required`);
2043
+ if (text3.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
2044
+ return text3;
1509
2045
  }
1510
2046
  function validateMembershipPageDocument(input, options = {}) {
1511
2047
  const errors = [];
@@ -1554,8 +2090,8 @@ function validateMembershipPageDocument(input, options = {}) {
1554
2090
  else {
1555
2091
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
1556
2092
  benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
1557
- const text2 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
1558
- return text2 ? [text2] : [];
2093
+ const text3 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
2094
+ return text3 ? [text3] : [];
1559
2095
  });
1560
2096
  }
1561
2097
  }
@@ -1629,7 +2165,7 @@ function validateMembershipPageDocument(input, options = {}) {
1629
2165
  var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
1630
2166
  var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
1631
2167
  var TIER = /^[a-z][a-z0-9-]{0,79}$/;
1632
- var PRICE = /^price_[A-Za-z0-9]{12,}$/;
2168
+ var PRICE2 = /^price_[A-Za-z0-9]{12,}$/;
1633
2169
  var ROOT_KEYS2 = /* @__PURE__ */ new Set([
1634
2170
  "schemaVersion",
1635
2171
  "revision",
@@ -1641,14 +2177,15 @@ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
1641
2177
  "application",
1642
2178
  "policy",
1643
2179
  "scheduling",
1644
- "theme"
2180
+ "theme",
2181
+ "giftOffers"
1645
2182
  ]);
1646
2183
  var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
1647
2184
  var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1648
2185
  function unknownKeys(value, allowed, path, errors) {
1649
2186
  for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
1650
2187
  }
1651
- function text(value, path, errors, max = 65536) {
2188
+ function text2(value, path, errors, max = 65536) {
1652
2189
  if (typeof value !== "string" || !value.trim()) {
1653
2190
  errors.push(`${path} is required`);
1654
2191
  return "";
@@ -1671,23 +2208,23 @@ function tiersOf(value, errors) {
1671
2208
  return [];
1672
2209
  }
1673
2210
  unknownKeys(item, TIER_KEYS2, path, errors);
1674
- const id = text(item.id, `${path}.id`, errors, 80);
2211
+ const id = text2(item.id, `${path}.id`, errors, 80);
1675
2212
  if (id && !TIER.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
1676
2213
  if (seen.has(id)) errors.push(`${path}.id duplicates "${id}"`);
1677
2214
  seen.add(id);
1678
- const name = text(item.name, `${path}.name`, errors, 160);
2215
+ const name = text2(item.name, `${path}.name`, errors, 160);
1679
2216
  if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
1680
2217
  if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
1681
2218
  if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
1682
2219
  errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
1683
2220
  }
1684
- if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE.test(item.stripePriceId))) {
2221
+ if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE2.test(item.stripePriceId))) {
1685
2222
  errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
1686
2223
  }
1687
2224
  if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
1688
2225
  errors.push(`${path}.stripePriceId is required for an available paid tier`);
1689
2226
  }
1690
- const blurb = item.blurb === void 0 ? void 0 : text(item.blurb, `${path}.blurb`, errors, 4e3);
2227
+ const blurb = item.blurb === void 0 ? void 0 : text2(item.blurb, `${path}.blurb`, errors, 4e3);
1691
2228
  return [{
1692
2229
  id,
1693
2230
  name,
@@ -1736,7 +2273,7 @@ function policyOf(value, errors) {
1736
2273
  const result = {};
1737
2274
  for (const key of allowed) {
1738
2275
  if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
1739
- result[key] = text(input[key], `document.policy.${key}`, errors);
2276
+ result[key] = text2(input[key], `document.policy.${key}`, errors);
1740
2277
  }
1741
2278
  return result;
1742
2279
  }
@@ -1750,12 +2287,18 @@ function themeOf(value, errors) {
1750
2287
  }
1751
2288
  unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
1752
2289
  if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
2290
+ let serializedLength = 0;
1753
2291
  for (const [key, token] of Object.entries(tokens)) {
1754
- if (!/^--[a-z0-9-]{1,100}$/.test(key)) errors.push(`document.theme.tokens.${key} must be a CSS custom-property name`);
1755
- if (typeof token !== "string" && typeof token !== "number" || String(token).length > 1e3) {
1756
- errors.push(`document.theme.tokens.${key} must be a bounded string or number`);
2292
+ if (!/^--ui-[a-z0-9][a-z0-9-]{0,94}$/.test(key)) {
2293
+ errors.push(`document.theme.tokens.${key} must be an allowlisted --ui-* custom property`);
2294
+ }
2295
+ const rendered = String(token).trim();
2296
+ serializedLength += key.length + rendered.length + 4;
2297
+ 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)) {
2298
+ errors.push(`document.theme.tokens.${key} must be a bounded breakout-safe CSS value`);
1757
2299
  }
1758
2300
  }
2301
+ if (serializedLength > 65536) errors.push("document.theme.tokens exceeds the serialized size bound");
1759
2302
  return { tokens };
1760
2303
  }
1761
2304
  function validateSignupControlDocument(input) {
@@ -1768,9 +2311,9 @@ function validateSignupControlDocument(input) {
1768
2311
  if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
1769
2312
  const source = recordOf(root.source);
1770
2313
  const target = recordOf(root.target);
1771
- const appId = text(source?.appId, "document.source.appId", errors, 63);
1772
- const chapterId = text(target?.chapterId, "document.target.chapterId", errors, 63);
1773
- const environment = text(target?.environment, "document.target.environment", errors, 63);
2314
+ const appId = text2(source?.appId, "document.source.appId", errors, 63);
2315
+ const chapterId = text2(target?.chapterId, "document.target.chapterId", errors, 63);
2316
+ const environment = text2(target?.environment, "document.target.environment", errors, 63);
1774
2317
  if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
1775
2318
  if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
1776
2319
  if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
@@ -1791,6 +2334,8 @@ function validateSignupControlDocument(input) {
1791
2334
  else scheduling = root.scheduling;
1792
2335
  }
1793
2336
  const theme = themeOf(root.theme, errors);
2337
+ const giftOffers = root.giftOffers === void 0 ? void 0 : validateGiftOfferCatalog(root.giftOffers, new Set(tiers.map((tier) => tier.id)));
2338
+ if (giftOffers && !giftOffers.ok) errors.push(...giftOffers.errors);
1794
2339
  if (errors.length || !page.ok) return { ok: false, errors };
1795
2340
  return { ok: true, value: {
1796
2341
  schemaVersion: 1,
@@ -1803,7 +2348,8 @@ function validateSignupControlDocument(input) {
1803
2348
  ...application ? { application } : {},
1804
2349
  ...policy ? { policy } : {},
1805
2350
  ...scheduling ? { scheduling } : {},
1806
- ...theme ? { theme } : {}
2351
+ ...theme ? { theme } : {},
2352
+ ...giftOffers?.ok ? { giftOffers: giftOffers.value } : {}
1807
2353
  } };
1808
2354
  }
1809
2355
 
@@ -1864,92 +2410,156 @@ var handleSignupControl = async (req, url, env, ctx) => {
1864
2410
  });
1865
2411
  };
1866
2412
 
1867
- // src/crm-sync.ts
1868
- var import_crm4 = require("@odla-ai/crm");
1869
- var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
1870
- var crmDeps = (db, ctx) => ({
1871
- crm: ctx.chapter.crm,
1872
- db,
1873
- now: () => Date.now(),
1874
- newId: () => crypto.randomUUID(),
1875
- chapter: ctx.chapter
1876
- });
1877
- function personInputFromApp(chapter, app) {
1878
- const input = sharedPersonInput({
1879
- email: str(app.email),
1880
- firstName: str(app.firstName) || void 0,
1881
- lastName: str(app.lastName) || void 0,
1882
- phone: str(app.phone) || void 0,
1883
- linkedin: str(app.linkedin) || void 0,
1884
- hubRecordId: str(app.id)
1885
- });
1886
- for (const f of chapter.application.crmFields) {
1887
- if (app[f] !== void 0) input[f] = app[f];
2413
+ // src/worker-routes-admission-grants.ts
2414
+ var import_db4 = require("@odla-ai/db");
2415
+ var reject2 = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
2416
+ var handleAdmissionGrants = async (req, url, env, ctx) => {
2417
+ if (req.method !== "POST" || url.pathname !== ADMISSION_GRANTS_PATH) return null;
2418
+ const authority = ctx.chapter.signupControl;
2419
+ if (!authority) return json({ error: "not found" }, 404);
2420
+ const db = ctx.makeDb(env);
2421
+ const secret = await getVaultSecret(db, authority.secretName);
2422
+ if (!secret) return json({ error: "unauthorized" }, 401);
2423
+ const verified = await (0, import_db4.verifyFederatedRequest)(req, { secret, senders: [authority.sourceId] });
2424
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
2425
+ let input;
2426
+ try {
2427
+ input = JSON.parse(verified.body);
2428
+ } catch {
2429
+ return reject2("invalid_document", 400, ["request body must be JSON"]);
1888
2430
  }
1889
- if (app.id !== void 0) input.applicationId = str(app.id);
1890
- return input;
1891
- }
1892
- function billingColumns(app) {
1893
- const status = str(app.status);
1894
- const paid = Boolean(app.stripeSubscriptionId) && status !== "refunded";
1895
- const billingStatus = status === "refunded" ? "refunded" : app.canceled === true ? "canceled" : paid ? "active" : "none";
1896
- const cols = { billingStatus };
1897
- if (app.stripeCustomerId) cols.stripeCustomerId = str(app.stripeCustomerId);
1898
- if (app.stripeSubscriptionId) cols.subscriptionId = str(app.stripeSubscriptionId);
1899
- if (typeof app.renewalAt === "number") cols.renewalAt = app.renewalAt;
1900
- return cols;
1901
- }
1902
- async function syncApplicationToCrm(deps, opts) {
1903
- const emailKey = str(opts.app.email).toLowerCase();
1904
- if (!emailKey) return null;
1905
- const recordDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
1906
- const input = personInputFromApp(deps.chapter, opts.app);
1907
- const { crm_record } = await deps.db.query({
1908
- crm_record: { $: { where: { type: "person", primaryEmail: emailKey }, limit: 1 } }
1909
- });
1910
- const existing = crm_record?.[0] ?? null;
1911
- const stage = opts.stage || void 0;
1912
- let recordId;
1913
- if (existing && typeof existing.id === "string") {
1914
- recordId = existing.id;
1915
- await (0, import_crm4.updateRecord)(recordDeps, { id: recordId, input });
1916
- } else {
1917
- const created = await (0, import_crm4.createRecord)(recordDeps, { type: "person", input, ...stage ? { stage } : {} });
1918
- recordId = created.id;
2431
+ const digest = typeof input.digest === "string" ? input.digest : "";
2432
+ const checked = validateAdmissionGrantDocument(input.document);
2433
+ if (!checked.ok) return reject2("invalid_document", 400, checked.errors);
2434
+ const document = checked.value;
2435
+ if (document.source.appId !== authority.sourceId) return reject2("wrong_source", 403);
2436
+ if (document.target.chapterId !== ctx.chapter.id) return reject2("wrong_target", 409);
2437
+ if (document.target.environment !== env.ODLA_ENV) return reject2("wrong_environment", 409);
2438
+ if (!digest || await signupControlDigest(document) !== digest) return reject2("invalid_digest", 400);
2439
+ const latest = await loadLatestAdmissionRevision(db, authority.sourceId, ctx.chapter.id, env.ODLA_ENV);
2440
+ if (latest && document.revision < latest.revision) return reject2("stale_revision", 409);
2441
+ if (latest && document.revision === latest.revision) {
2442
+ if (latest.digest !== digest) return reject2("conflicting_revision", 409);
2443
+ return json({
2444
+ ok: true,
2445
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
2446
+ revision: latest.revision,
2447
+ digest: latest.digest,
2448
+ status: "replayed",
2449
+ acknowledgedAt: latest.receivedAt
2450
+ });
1919
2451
  }
1920
- if (existing && stage && existing.stage !== stage) {
1921
- await (0, import_crm4.setStage)(recordDeps, { id: recordId, to: stage, authorId: "system", mutationId: `crm:stage:${recordId}:${stage}` }).catch(() => void 0);
2452
+ const receivedAt = Date.now();
2453
+ try {
2454
+ await materializeAdmissionGrants(db, { digest, document }, latest, receivedAt);
2455
+ } catch {
2456
+ return reject2("materialization_failed", 503);
1922
2457
  }
1923
- await deps.db.transact([{ t: "update", ns: "crm_record", id: recordId, attrs: billingColumns(opts.app) }]);
1924
- await (0, import_crm4.linkIdentity)(recordDeps, { recordId, email: emailKey, mutationId: `crm:link:${recordId}:${emailKey}` }).catch(() => void 0);
1925
- return recordId;
1926
- }
1927
- async function backfillCrm(deps) {
1928
- const [appsRes, usersRes] = await Promise.all([
1929
- deps.db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 1e3 } } }),
1930
- deps.db.query({ $users: { $: { limit: 1e3 } } })
1931
- ]);
1932
- const seen = /* @__PURE__ */ new Set();
1933
- let synced = 0;
1934
- const errors = [];
1935
- const run = async (app, stage) => {
1936
- const key = str(app.email).toLowerCase();
1937
- if (!key || seen.has(key)) return;
1938
- seen.add(key);
2458
+ return json({
2459
+ ok: true,
2460
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
2461
+ revision: document.revision,
2462
+ digest,
2463
+ status: "accepted",
2464
+ acknowledgedAt: receivedAt
2465
+ });
2466
+ };
2467
+
2468
+ // src/worker-routes-admin-admission.ts
2469
+ async function gate(req, env, ctx) {
2470
+ const rawDb = ctx.makeDb(env);
2471
+ const user = await ctx.verifyUser(req, env);
2472
+ if (!user) return json({ error: "unauthorized" }, 401);
2473
+ if (!await ctx.isAdmin(rawDb, user)) return json({ error: "forbidden" }, 403);
2474
+ return { db: rawDb, user };
2475
+ }
2476
+ var handleAdminAdmissionGrants = async (req, url, env, ctx) => {
2477
+ if (url.pathname === "/api/admin/admission-grants" && req.method === "GET") {
2478
+ const auth = await gate(req, env, ctx);
2479
+ if (auth instanceof Response) return auth;
2480
+ const result = await auth.db.query({ admissionGrants: { $: {
2481
+ where: { targetId: ctx.chapter.id },
2482
+ order: { createdAt: "desc" },
2483
+ limit: 500
2484
+ } } });
2485
+ const grants = Array.isArray(result.admissionGrants) ? result.admissionGrants.map((row) => ({ ...row, emailNormalized: void 0 })) : [];
2486
+ return json({ grants });
2487
+ }
2488
+ if (url.pathname === "/api/admin/admission-grants" && req.method === "POST") {
2489
+ const auth = await gate(req, env, ctx);
2490
+ if (auth instanceof Response) return auth;
2491
+ let body;
1939
2492
  try {
1940
- await syncApplicationToCrm(deps, { app, stage });
1941
- synced += 1;
1942
- } catch (err) {
1943
- errors.push({ email: key, error: err instanceof Error ? err.message : String(err) });
2493
+ body = JSON.parse(await req.text());
2494
+ } catch {
2495
+ return json({ error: "invalid JSON body" }, 400);
2496
+ }
2497
+ const email = typeof body.email === "string" ? body.email.trim() : "";
2498
+ const emailNormalized = normalizeAdmissionEmail(email);
2499
+ if (!isValidEmail(emailNormalized)) return json({ error: "email must be valid" }, 400);
2500
+ const decision = body.decision ?? "waive_interview";
2501
+ if (decision !== "waive_interview" && decision !== "require_interview") {
2502
+ return json({ error: "decision must be waive_interview or require_interview" }, 400);
2503
+ }
2504
+ const note = typeof body.note === "string" ? body.note.trim() : "";
2505
+ if (note.length > 2e3) return json({ error: "note is too long" }, 400);
2506
+ const expiresAt = body.expiresAt === void 0 || body.expiresAt === null ? void 0 : Number(body.expiresAt);
2507
+ if (expiresAt !== void 0 && (!Number.isFinite(expiresAt) || expiresAt <= Date.now())) {
2508
+ return json({ error: "expiresAt must be a future epoch timestamp" }, 400);
2509
+ }
2510
+ const sourceGrantId = crypto.randomUUID();
2511
+ const id = `admission:local:${ctx.chapter.id}:${sourceGrantId}`;
2512
+ const createdAt = Date.now();
2513
+ const actor = auth.user.email ?? auth.user.userId;
2514
+ await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
2515
+ id,
2516
+ sourceGrantId,
2517
+ sourceScope: "local",
2518
+ sourceId: ctx.chapter.id,
2519
+ targetId: ctx.chapter.id,
2520
+ email,
2521
+ emailNormalized,
2522
+ decision,
2523
+ ...note ? { note } : {},
2524
+ ...expiresAt !== void 0 ? { expiresAt } : {},
2525
+ active: true,
2526
+ createdAt,
2527
+ createdBy: actor,
2528
+ updatedAt: createdAt,
2529
+ updatedBy: actor
2530
+ } }], { mutationId: `admission-local-create:${sourceGrantId}` });
2531
+ return json({ grant: { id, sourceGrantId, sourceScope: "local", email, decision, active: true, createdAt, ...note ? { note } : {}, ...expiresAt ? { expiresAt } : {} } }, 201);
2532
+ }
2533
+ const prefix = "/api/admin/admission-grants/";
2534
+ let revokeId = "";
2535
+ if (url.pathname.startsWith(prefix)) {
2536
+ try {
2537
+ revokeId = decodeURIComponent(url.pathname.slice(prefix.length));
2538
+ } catch {
2539
+ revokeId = "";
1944
2540
  }
1945
- };
1946
- for (const a of appsRes.applications ?? []) await run(a, str(a.status));
1947
- for (const u of usersRes.$users ?? []) {
1948
- if (u.deleted === true) continue;
1949
- await run({ email: u.email, firstName: str(u.name) });
1950
2541
  }
1951
- return { synced, errors };
1952
- }
2542
+ if (/^admission:local:[a-z0-9-]+:[0-9a-f-]+$/.test(revokeId) && req.method === "DELETE") {
2543
+ const auth = await gate(req, env, ctx);
2544
+ if (auth instanceof Response) return auth;
2545
+ const id = revokeId;
2546
+ const found = await auth.db.query({ admissionGrants: { $: { where: { id }, limit: 1 } } });
2547
+ const row = Array.isArray(found.admissionGrants) ? found.admissionGrants[0] : void 0;
2548
+ if (!row || row.targetId !== ctx.chapter.id || row.sourceScope !== "local") return json({ error: "not found" }, 404);
2549
+ if (row.active !== true) return json({ ok: true, replayed: true });
2550
+ const revokedAt = Date.now();
2551
+ const actor = auth.user.email ?? auth.user.userId;
2552
+ await auth.db.transact([{ t: "update", ns: "admissionGrants", id, attrs: {
2553
+ active: false,
2554
+ revokedAt,
2555
+ revokedBy: actor,
2556
+ updatedAt: revokedAt,
2557
+ updatedBy: actor
2558
+ } }], { mutationId: `admission-local-revoke:${id}` });
2559
+ return json({ ok: true, replayed: false });
2560
+ }
2561
+ return null;
2562
+ };
1953
2563
 
1954
2564
  // src/worker-routes-schedule.ts
1955
2565
  var import_calendar = require("@odla-ai/calendar");
@@ -1965,8 +2575,8 @@ function makeCalendar(env) {
1965
2575
  }
1966
2576
  async function firstRow(db, ns, q) {
1967
2577
  const res = await db.query({ [ns]: { $: q } });
1968
- const rows2 = res[ns];
1969
- return Array.isArray(rows2) ? rows2[0] : void 0;
2578
+ const rows4 = res[ns];
2579
+ return Array.isArray(rows4) ? rows4[0] : void 0;
1970
2580
  }
1971
2581
  async function computeSlots(cal, cfg) {
1972
2582
  const { from, to } = slotWindow(Date.now(), cfg.windowDays);
@@ -2103,8 +2713,26 @@ function findApplicationRef(obj) {
2103
2713
  const customerId = typeof obj.customer === "string" ? obj.customer : void 0;
2104
2714
  return { ...applicationId ? { applicationId } : {}, ...customerId ? { customerId } : {} };
2105
2715
  }
2716
+ function findGiftRef(obj) {
2717
+ const metaOf = (value) => value && typeof value === "object" ? value.metadata ?? {} : {};
2718
+ const pick = (metadata) => typeof metadata.giftIntentId === "string" ? metadata.giftIntentId : void 0;
2719
+ const parent = obj.parent;
2720
+ const subscription = obj.subscription_details ?? parent?.subscription_details;
2721
+ const giftIntentId = pick(metaOf(obj)) ?? pick(metaOf(subscription));
2722
+ const subscriptionId = typeof obj.subscription === "string" ? obj.subscription : typeof subscription?.subscription === "string" ? String(subscription.subscription) : void 0;
2723
+ return { ...giftIntentId ? { giftIntentId } : {}, providerObjectId: subscriptionId ?? String(obj.id ?? "") };
2724
+ }
2106
2725
  function normalizeWebhookEvent(event) {
2107
2726
  const obj = event.data?.object ?? {};
2727
+ const gift = findGiftRef(obj);
2728
+ if (gift.giftIntentId) {
2729
+ if (event.type === "payment_intent.succeeded" || event.type === "invoice.paid" && obj.billing_reason === "subscription_create") {
2730
+ return { kind: "gift_paid", giftIntentId: gift.giftIntentId, providerObjectId: gift.providerObjectId };
2731
+ }
2732
+ if (event.type === "charge.refunded") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "refunded" };
2733
+ if (event.type === "customer.subscription.deleted") return { kind: "gift_revoked", giftIntentId: gift.giftIntentId, reason: "canceled" };
2734
+ return { kind: "ignored", type: event.type };
2735
+ }
2108
2736
  const ref = findApplicationRef(obj);
2109
2737
  switch (event.type) {
2110
2738
  case "invoice.paid": {
@@ -2205,9 +2833,9 @@ function createStripeProvider(config) {
2205
2833
  `sub:${input.applicationId}`
2206
2834
  );
2207
2835
  if (!sub.ok) fail("subscription create", sub);
2208
- const clientSecret = clientSecretOf(sub.body);
2209
- if (!clientSecret) fail("subscription confirmation-secret missing", sub);
2210
- return { customerId, subscriptionId: String(sub.body.id), clientSecret };
2836
+ const clientSecret2 = clientSecretOf(sub.body);
2837
+ if (!clientSecret2) fail("subscription confirmation-secret missing", sub);
2838
+ return { customerId, subscriptionId: String(sub.body.id), clientSecret: clientSecret2 };
2211
2839
  },
2212
2840
  async ingestWebhook(rawBody, sigHeader) {
2213
2841
  if (!webhookSecret || !await (0, import_stripe2.verifyStripeSignature)(rawBody, sigHeader, webhookSecret)) {
@@ -2220,8 +2848,8 @@ function createStripeProvider(config) {
2220
2848
  const sk = requireSecret(secretKey);
2221
2849
  const charges = await stripeCall(sk, "GET", "/v1/charges", { customer: input.customerId, limit: 100 });
2222
2850
  if (!charges.ok) fail("charges list", charges);
2223
- const rows2 = charges.body.data ?? [];
2224
- const paid = rows2.filter((c) => c.status === "succeeded" && c.refunded !== true);
2851
+ const rows4 = charges.body.data ?? [];
2852
+ const paid = rows4.filter((c) => c.status === "succeeded" && c.refunded !== true);
2225
2853
  const charge = paid[paid.length - 1];
2226
2854
  if (!charge) {
2227
2855
  const err = new Error("no paid charge to refund");
@@ -2237,12 +2865,337 @@ function createStripeProvider(config) {
2237
2865
  };
2238
2866
  }
2239
2867
 
2868
+ // src/gift-codes.ts
2869
+ var GIFT_CODE_VERSION = 1;
2870
+ var GIFT_CODE_BYTES = 32;
2871
+ var base64url = (bytes) => {
2872
+ let binary = "";
2873
+ for (const byte of bytes) binary += String.fromCharCode(byte);
2874
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
2875
+ };
2876
+ function createGiftCode() {
2877
+ const bytes = new Uint8Array(GIFT_CODE_BYTES);
2878
+ crypto.getRandomValues(bytes);
2879
+ return `gift${GIFT_CODE_VERSION}_${base64url(bytes)}`;
2880
+ }
2881
+ async function hashGiftCode(chapterId, code, version = GIFT_CODE_VERSION) {
2882
+ const bytes = new TextEncoder().encode(`odla-gift:${version}:${chapterId}:${code}`);
2883
+ return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
2884
+ }
2885
+ async function giftRequesterBucket(chapterId, requester) {
2886
+ const bytes = new TextEncoder().encode(`odla-gift-rate:${chapterId}:${requester}`);
2887
+ return base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))).slice(0, 32);
2888
+ }
2889
+
2890
+ // src/gift-store.ts
2891
+ var first = (value) => Array.isArray(value) ? value[0] : void 0;
2892
+ async function prepareGiftCode(chapterId) {
2893
+ const code = createGiftCode();
2894
+ return { code, codeHash: await hashGiftCode(chapterId, code), codeVersion: GIFT_CODE_VERSION };
2895
+ }
2896
+ async function issueGiftClaim(db, intent, provider, now) {
2897
+ const existing = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
2898
+ if (existing) return { claimId: String(existing.id), orderId: String(existing.orderId), duplicate: true };
2899
+ const offer = intent.offerSnapshot;
2900
+ const orderId = `gift-order:${intent.id}`;
2901
+ const claimId = `gift-claim:${intent.id}`;
2902
+ const shared = {
2903
+ chapterId: intent.chapterId,
2904
+ offerId: intent.offerId,
2905
+ ...intent.purchaserApplicationId ? { purchaserApplicationId: intent.purchaserApplicationId } : {},
2906
+ ...intent.purchaserUserId ? { purchaserUserId: intent.purchaserUserId } : {},
2907
+ purchaserEmailNormalized: intent.purchaserEmailNormalized
2908
+ };
2909
+ const ops = [
2910
+ { t: "update", ns: "giftOrders", id: orderId, attrs: {
2911
+ id: orderId,
2912
+ intentId: intent.id,
2913
+ ...shared,
2914
+ environment: intent.environment,
2915
+ offerSnapshot: offer,
2916
+ purchaserType: intent.purchaserType,
2917
+ providerEventId: provider.eventId,
2918
+ providerObjectId: provider.objectId,
2919
+ amountCents: offer.priceCents,
2920
+ currency: offer.currency,
2921
+ billingMode: offer.billingMode,
2922
+ createdAt: now
2923
+ } },
2924
+ { t: "update", ns: "giftClaims", id: claimId, attrs: {
2925
+ id: claimId,
2926
+ orderId,
2927
+ intentId: intent.id,
2928
+ ...shared,
2929
+ target: offer.target,
2930
+ codeHash: intent.codeHash,
2931
+ codeVersion: intent.codeVersion,
2932
+ rotationCount: 0,
2933
+ codeIssuedAt: intent.codeIssuedAt,
2934
+ codeExpiresAt: now + offer.redemptionLifetimeHours * 36e5,
2935
+ attemptCount: 0,
2936
+ attemptWindowStartedAt: now,
2937
+ status: "pending",
2938
+ createdAt: now
2939
+ } },
2940
+ { t: "update", ns: "giftPurchaseIntents", id: intent.id, attrs: { status: "paid", paidAt: now, claimId, updatedAt: now } }
2941
+ ];
2942
+ if (intent.purchaserApplicationId) ops.push({
2943
+ t: "update",
2944
+ ns: "applications",
2945
+ id: intent.purchaserApplicationId,
2946
+ attrs: { giftPaidAt: now, giftClaimId: claimId }
2947
+ });
2948
+ try {
2949
+ const result = await db.transact(ops, {
2950
+ mutationId: `gift-paid:${provider.eventId}`,
2951
+ guards: [{ ns: "giftPurchaseIntents", id: intent.id, exists: true, equals: { status: "checkout_ready" } }]
2952
+ });
2953
+ return { claimId, orderId, duplicate: result.duplicate };
2954
+ } catch {
2955
+ const raced = first((await db.query({ giftClaims: { $: { where: { intentId: intent.id }, limit: 1 } } })).giftClaims);
2956
+ if (raced) return { claimId: String(raced.id), orderId: String(raced.orderId), duplicate: true };
2957
+ throw new Error("gift issuance conflict");
2958
+ }
2959
+ }
2960
+ function buyerGiftClaims(rows4, now) {
2961
+ return rows4.map((row) => {
2962
+ const stored = String(row.status ?? "pending");
2963
+ const status = stored === "pending" && Number(row.codeExpiresAt) <= now ? "expired" : stored;
2964
+ return {
2965
+ id: String(row.id),
2966
+ offerId: String(row.offerId),
2967
+ status,
2968
+ createdAt: Number(row.createdAt),
2969
+ codeExpiresAt: Number(row.codeExpiresAt),
2970
+ ...typeof row.redeemedAt === "number" ? { redeemedAt: row.redeemedAt } : {},
2971
+ ...typeof row.revokedAt === "number" ? { revokedAt: row.revokedAt } : {}
2972
+ };
2973
+ });
2974
+ }
2975
+ async function rotateGiftClaim(db, claim, now) {
2976
+ if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
2977
+ const prepared = await prepareGiftCode(String(claim.chapterId));
2978
+ const currentRotation = Number(claim.rotationCount ?? 0);
2979
+ try {
2980
+ const write = await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
2981
+ codeHash: prepared.codeHash,
2982
+ codeVersion: prepared.codeVersion,
2983
+ rotationCount: currentRotation + 1,
2984
+ codeIssuedAt: now,
2985
+ rotatedAt: now,
2986
+ attemptCount: 0,
2987
+ attemptWindowStartedAt: now
2988
+ } }], {
2989
+ mutationId: `gift-rotate:${String(claim.id)}:${currentRotation + 1}`,
2990
+ guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: "pending", rotationCount: currentRotation } }]
2991
+ });
2992
+ return write.duplicate ? null : { code: prepared.code };
2993
+ } catch {
2994
+ return null;
2995
+ }
2996
+ }
2997
+ async function redeemGiftClaim(db, claim, beneficiary, now) {
2998
+ if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) return null;
2999
+ const chapterId = String(claim.chapterId);
3000
+ const emailNormalized = normalizeAdmissionEmail(beneficiary.email);
3001
+ const target = claim.target;
3002
+ const ops = [];
3003
+ let result;
3004
+ if (target.type === "tier") {
3005
+ const apps = (await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications;
3006
+ const existing = apps?.filter((row) => row.groupId === chapterId).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
3007
+ const id = existing ? String(existing.id) : `gift-application:${String(claim.id)}`;
3008
+ const status = existing && existing.status !== "submitted" ? String(existing.status) : "paid_pending_vetting";
3009
+ ops.push({ t: "update", ns: "applications", id, attrs: {
3010
+ id,
3011
+ groupId: chapterId,
3012
+ email: beneficiary.email,
3013
+ emailNormalized,
3014
+ tierId: target.tierId,
3015
+ status,
3016
+ giftClaimId: String(claim.id),
3017
+ giftOrderId: String(claim.orderId),
3018
+ clerkUserId: beneficiary.userId,
3019
+ ...existing ? {} : { createdAt: now }
3020
+ } });
3021
+ result = { kind: "application", id };
3022
+ } else {
3023
+ const id = `gift-entitlement:${String(claim.id)}`;
3024
+ ops.push({ t: "update", ns: "giftEntitlements", id, attrs: {
3025
+ id,
3026
+ claimId: String(claim.id),
3027
+ orderId: String(claim.orderId),
3028
+ chapterId,
3029
+ entitlementKey: target.entitlementKey,
3030
+ beneficiaryEmailNormalized: emailNormalized,
3031
+ beneficiaryUserId: beneficiary.userId,
3032
+ active: true,
3033
+ createdAt: now
3034
+ } });
3035
+ result = { kind: "entitlement", id };
3036
+ }
3037
+ ops.unshift({ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
3038
+ status: "redeemed",
3039
+ redeemedAt: now,
3040
+ beneficiaryEmailNormalized: emailNormalized,
3041
+ beneficiaryUserId: beneficiary.userId,
3042
+ ...result.kind === "application" ? { applicationId: result.id } : { entitlementId: result.id },
3043
+ attemptCount: Number(claim.attemptCount ?? 0) + 1
3044
+ } });
3045
+ try {
3046
+ await db.transact(ops, {
3047
+ mutationId: `gift-redeem:${String(claim.id)}:${beneficiary.userId}`,
3048
+ guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: {
3049
+ status: "pending",
3050
+ codeVersion: Number(claim.codeVersion),
3051
+ codeHash: String(claim.codeHash)
3052
+ } }]
3053
+ });
3054
+ return result;
3055
+ } catch {
3056
+ return null;
3057
+ }
3058
+ }
3059
+ async function revokeGiftClaim(db, claim, reason, now) {
3060
+ if (claim.status === "revoked") return false;
3061
+ const ops = [{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
3062
+ status: "revoked",
3063
+ revokedAt: now,
3064
+ revokedReason: reason
3065
+ } }];
3066
+ if (claim.entitlementId) ops.push({ t: "update", ns: "giftEntitlements", id: String(claim.entitlementId), attrs: { active: false, revokedAt: now } });
3067
+ if (claim.applicationId) ops.push({ t: "update", ns: "applications", id: String(claim.applicationId), attrs: {
3068
+ giftRevokedAt: now,
3069
+ ...reason === "refunded" ? { status: "refunded" } : { canceled: true }
3070
+ } });
3071
+ try {
3072
+ await db.transact(ops, {
3073
+ mutationId: `gift-revoke:${String(claim.id)}:${reason}`,
3074
+ guards: [{ ns: "giftClaims", id: String(claim.id), exists: true, equals: { status: String(claim.status) } }]
3075
+ });
3076
+ return true;
3077
+ } catch {
3078
+ return false;
3079
+ }
3080
+ }
3081
+
3082
+ // src/gift-checkout.ts
3083
+ var clientSecret = (value) => {
3084
+ const direct = value.client_secret;
3085
+ if (typeof direct === "string" && direct) return direct;
3086
+ const invoice = value.latest_invoice;
3087
+ const confirmation = invoice?.confirmation_secret;
3088
+ const intent = invoice?.payment_intent;
3089
+ const nested = confirmation?.client_secret ?? intent?.client_secret;
3090
+ return typeof nested === "string" && nested ? nested : null;
3091
+ };
3092
+ async function startGiftProviderCheckout(secretKey, intent, email, existingCustomerId) {
3093
+ const offer = intent.offerSnapshot;
3094
+ const metadata = {
3095
+ giftIntentId: intent.id,
3096
+ chapterId: intent.chapterId,
3097
+ environment: intent.environment,
3098
+ offerId: intent.offerId
3099
+ };
3100
+ let customerId = existingCustomerId;
3101
+ if (!customerId) {
3102
+ const customer = await stripeCall(secretKey, "POST", "/v1/customers", { email, metadata }, `gift-customer:${intent.id}`);
3103
+ if (!customer.ok) throw new Error(`gift customer create failed (${customer.status})`);
3104
+ customerId = String(customer.body.id);
3105
+ }
3106
+ if (offer.billingMode === "subscription") {
3107
+ const created2 = await stripeCall(secretKey, "POST", "/v1/subscriptions", {
3108
+ customer: customerId,
3109
+ "items[0][price]": offer.stripePriceId,
3110
+ payment_behavior: "default_incomplete",
3111
+ "payment_settings[save_default_payment_method]": "on_subscription",
3112
+ "payment_settings[payment_method_types][0]": "card",
3113
+ "expand[0]": "latest_invoice.confirmation_secret",
3114
+ metadata
3115
+ }, `gift-subscription:${intent.id}`);
3116
+ const secret2 = created2.ok ? clientSecret(created2.body) : null;
3117
+ if (!created2.ok || !secret2) throw new Error(`gift subscription create failed (${created2.status})`);
3118
+ return { clientSecret: secret2, providerObjectId: String(created2.body.id), customerId };
3119
+ }
3120
+ const price = await stripeCall(secretKey, "GET", `/v1/prices/${encodeURIComponent(offer.stripePriceId)}`);
3121
+ if (!price.ok || price.body.active === false || price.body.unit_amount !== offer.priceCents || price.body.currency !== offer.currency) {
3122
+ throw new Error("gift price does not match the signed offer");
3123
+ }
3124
+ const created = await stripeCall(secretKey, "POST", "/v1/payment_intents", {
3125
+ customer: customerId,
3126
+ amount: offer.priceCents,
3127
+ currency: offer.currency,
3128
+ "automatic_payment_methods[enabled]": true,
3129
+ metadata
3130
+ }, `gift-payment:${intent.id}`);
3131
+ const secret = created.ok ? clientSecret(created.body) : null;
3132
+ if (!created.ok || !secret) throw new Error(`gift payment create failed (${created.status})`);
3133
+ return { clientSecret: secret, providerObjectId: String(created.body.id), customerId };
3134
+ }
3135
+
3136
+ // src/gift-intents.ts
3137
+ var first2 = (value) => Array.isArray(value) ? value[0] : void 0;
3138
+ async function startGiftIntent(db, input) {
3139
+ let stored = first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents);
3140
+ let giftCode;
3141
+ if (!stored) {
3142
+ const prepared = await prepareGiftCode(input.chapterId);
3143
+ const attrs = {
3144
+ id: input.id,
3145
+ chapterId: input.chapterId,
3146
+ environment: input.environment,
3147
+ purchaserType: input.purchaserType,
3148
+ purchaserEmailNormalized: input.purchaserEmailNormalized,
3149
+ ...input.purchaserApplicationId ? { purchaserApplicationId: input.purchaserApplicationId } : {},
3150
+ ...input.purchaserUserId ? { purchaserUserId: input.purchaserUserId } : {},
3151
+ offerId: input.offer.id,
3152
+ offerSnapshot: input.offer,
3153
+ status: "created",
3154
+ codeHash: prepared.codeHash,
3155
+ codeVersion: prepared.codeVersion,
3156
+ codeIssuedAt: input.now,
3157
+ createdAt: input.now,
3158
+ updatedAt: input.now
3159
+ };
3160
+ const write = await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs }], {
3161
+ mutationId: `gift-intent:${input.id}`
3162
+ });
3163
+ stored = write.duplicate ? first2((await db.query({ giftPurchaseIntents: { $: { where: { id: input.id }, limit: 1 } } })).giftPurchaseIntents) : attrs;
3164
+ if (!write.duplicate) giftCode = prepared.code;
3165
+ }
3166
+ if (!stored || stored.chapterId !== input.chapterId || stored.environment !== input.environment || stored.offerId !== input.offer.id || stored.purchaserEmailNormalized !== input.purchaserEmailNormalized) {
3167
+ throw new Error("gift purchase intent does not match this checkout");
3168
+ }
3169
+ if (stored.status === "paid") throw new Error("gift purchase is already paid");
3170
+ const checkout = await startGiftProviderCheckout(
3171
+ input.stripeSecret,
3172
+ stored,
3173
+ input.purchaserEmail,
3174
+ typeof stored.providerCustomerId === "string" ? stored.providerCustomerId : input.existingCustomerId
3175
+ );
3176
+ await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: input.id, attrs: {
3177
+ status: "checkout_ready",
3178
+ providerObjectId: checkout.providerObjectId,
3179
+ ...checkout.customerId ? { providerCustomerId: checkout.customerId } : {},
3180
+ checkoutStartedAt: input.now,
3181
+ updatedAt: input.now
3182
+ } }], { mutationId: `gift-checkout-ready:${input.id}:${checkout.providerObjectId}` });
3183
+ return {
3184
+ gift: true,
3185
+ intentId: input.id,
3186
+ clientSecret: checkout.clientSecret,
3187
+ publishableKey: input.publishableKey,
3188
+ lineItems: { standardCents: input.offer.priceCents, discountCents: 0, dueTodayCents: input.offer.priceCents },
3189
+ ...giftCode ? { giftCode } : {}
3190
+ };
3191
+ }
3192
+
2240
3193
  // src/worker-routes-payments.ts
2241
3194
  var codeOf = (err) => err && typeof err === "object" && typeof err.code === "string" ? err.code : "unknown";
2242
3195
  async function firstRow2(db, ns, q) {
2243
3196
  const res = await db.query({ [ns]: { $: q } });
2244
- const rows2 = res[ns];
2245
- return Array.isArray(rows2) ? rows2[0] : void 0;
3197
+ const rows4 = res[ns];
3198
+ return Array.isArray(rows4) ? rows4[0] : void 0;
2246
3199
  }
2247
3200
  function lineItems(group) {
2248
3201
  const standard = Number(group.standardPriceCents ?? 0);
@@ -2327,6 +3280,36 @@ async function startSubscription(req, env, ctx) {
2327
3280
  const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
2328
3281
  const secretKey = await getVaultSecret(db, "stripe_secret_key");
2329
3282
  if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
3283
+ if (typeof app.giftOfferId === "string") {
3284
+ const control = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
3285
+ const offer = findGiftOffer(control?.document.giftOffers, app.giftOfferId, Date.now());
3286
+ if (!offer || typeof group.stripePublishableKey !== "string" || !group.stripePublishableKey) {
3287
+ return json({ error: "gift offer is unavailable" }, 409);
3288
+ }
3289
+ try {
3290
+ const started = await startGiftIntent(db, {
3291
+ id: typeof app.giftIntentId === "string" ? app.giftIntentId : `gift-intent:${applicationId}`,
3292
+ chapterId: ctx.chapter.id,
3293
+ environment: env.ODLA_ENV,
3294
+ offer,
3295
+ purchaserType: "application",
3296
+ purchaserEmail: String(app.email ?? ""),
3297
+ purchaserEmailNormalized: normalizeAdmissionEmail(String(app.email ?? "")),
3298
+ purchaserApplicationId: applicationId,
3299
+ stripeSecret: secretKey,
3300
+ publishableKey: group.stripePublishableKey,
3301
+ now: Date.now()
3302
+ });
3303
+ await db.transact([{ t: "update", ns: "applications", id: applicationId, attrs: {
3304
+ giftIntentId: started.intentId,
3305
+ giftCheckoutStartedAt: Date.now(),
3306
+ refundPolicyAckAt: Date.now()
3307
+ } }]);
3308
+ return json(started);
3309
+ } catch (error) {
3310
+ return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
3311
+ }
3312
+ }
2330
3313
  const tier = findTier(
2331
3314
  await loadTiers(db, group),
2332
3315
  typeof app.tierId === "string" ? app.tierId : null
@@ -2368,17 +3351,54 @@ async function ingestWebhook(req, env, ctx) {
2368
3351
  if (!ingest.ok) return json({ error: "invalid signature" }, 400);
2369
3352
  const { eventId, event } = ingest;
2370
3353
  if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
3354
+ if (event.kind === "gift_paid" || event.kind === "gift_revoked") {
3355
+ const intent = await firstRow2(db, "giftPurchaseIntents", { where: { id: event.giftIntentId }, limit: 1 });
3356
+ if (!intent || intent.chapterId !== ctx.chapter.id || intent.environment !== env.ODLA_ENV) {
3357
+ return json({ ok: true, matched: false });
3358
+ }
3359
+ if (event.kind === "gift_paid") {
3360
+ if (intent.providerObjectId !== event.providerObjectId) return json({ ok: true, matched: false });
3361
+ if (intent.status === "refunded" || intent.status === "canceled") return json({ ok: true, matched: false });
3362
+ try {
3363
+ const issued = await issueGiftClaim(db, intent, {
3364
+ eventId,
3365
+ objectId: event.providerObjectId
3366
+ }, Date.now());
3367
+ return json({ ok: true, matched: true, claimId: issued.claimId });
3368
+ } catch {
3369
+ return json({ error: "gift issuance failed" }, 503);
3370
+ }
3371
+ }
3372
+ await db.transact([{ t: "update", ns: "giftPurchaseIntents", id: event.giftIntentId, attrs: {
3373
+ status: event.reason,
3374
+ updatedAt: Date.now()
3375
+ } }], { mutationId: `gift-revoked-intent:${eventId}` });
3376
+ const claim = await firstRow2(db, "giftClaims", { where: { intentId: event.giftIntentId }, limit: 1 });
3377
+ if (claim) await revokeGiftClaim(db, claim, event.reason, Date.now());
3378
+ return json({ ok: true, matched: Boolean(claim) });
3379
+ }
2371
3380
  const app = await findApplication(db, event);
2372
3381
  if (!app) return json({ ok: true, matched: false });
2373
3382
  const patch = webhookPatch(event, String(app.status ?? ""));
3383
+ let next = app;
2374
3384
  if (Object.keys(patch).length) {
2375
3385
  await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
2376
- const next = { ...app, ...patch };
3386
+ next = { ...app, ...patch };
2377
3387
  await syncApplicationToCrm(crmDeps(db, ctx), { app: next, stage: String(next.status ?? "") }).catch(() => void 0);
2378
3388
  }
2379
3389
  if (event.kind === "first_payment") {
2380
3390
  await notifyPaymentConfirmed(db, env, eventId, app);
2381
3391
  if (ctx.chapter.sends.adminNotification === "payment") await notifyAdminOfPayment(db, env, eventId, app);
3392
+ if (typeof next.interviewWaivedAt === "number") {
3393
+ await approveApplication({
3394
+ db,
3395
+ env,
3396
+ ctx,
3397
+ app: next,
3398
+ origin: new URL(req.url).origin,
3399
+ reason: "interview_waiver"
3400
+ });
3401
+ }
2382
3402
  }
2383
3403
  return json({ ok: true });
2384
3404
  }
@@ -2389,6 +3409,144 @@ var handlePayments = async (req, url, env, ctx) => {
2389
3409
  return null;
2390
3410
  };
2391
3411
 
3412
+ // src/worker-routes-gifts.ts
3413
+ var first3 = (value) => Array.isArray(value) ? value[0] : void 0;
3414
+ var rows2 = (value) => Array.isArray(value) ? value : [];
3415
+ var invalid = () => json({ error: "gift code is invalid or unavailable" }, 400);
3416
+ var userFor = async (req, env, ctx) => {
3417
+ const user = await ctx.verifyUser(req, env);
3418
+ return user?.email ? user : null;
3419
+ };
3420
+ var loadClaim = async (db, id) => first3((await db.query({ giftClaims: { $: { where: { id }, limit: 1 } } })).giftClaims);
3421
+ async function consumeRateLimit(db, chapterId, requester, now) {
3422
+ const bucket = await giftRequesterBucket(chapterId, requester);
3423
+ const id = `gift-rate:${chapterId}:${bucket}`;
3424
+ const current = first3((await db.query({ giftRedemptionAttempts: { $: { where: { id }, limit: 1 } } })).giftRedemptionAttempts);
3425
+ const within = Boolean(current && now - Number(current.windowStartedAt) < 15 * 6e4);
3426
+ const attemptCount = within ? Number(current?.attemptCount ?? 0) + 1 : 1;
3427
+ if (attemptCount > 10) return false;
3428
+ await db.transact([{ t: "update", ns: "giftRedemptionAttempts", id, attrs: {
3429
+ id,
3430
+ chapterId,
3431
+ bucket,
3432
+ attemptCount,
3433
+ windowStartedAt: within ? Number(current?.windowStartedAt) : now,
3434
+ updatedAt: now
3435
+ } }]);
3436
+ return true;
3437
+ }
3438
+ var handleGifts = async (req, url, env, ctx) => {
3439
+ if (ctx.chapter.mode !== "chapter" || !url.pathname.startsWith("/api/gifts")) return null;
3440
+ const db = ctx.makeDb(env);
3441
+ if (req.method === "GET" && url.pathname === "/api/gifts") {
3442
+ const user = await userFor(req, env, ctx);
3443
+ if (!user) return json({ error: "unauthorized" }, 401);
3444
+ const control = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
3445
+ const found = await db.query({ giftClaims: { $: { where: {
3446
+ purchaserEmailNormalized: normalizeAdmissionEmail(user.email),
3447
+ chapterId: ctx.chapter.id
3448
+ }, limit: 200 } } });
3449
+ const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
3450
+ return json({
3451
+ offers: publicGiftOffers(control?.document.giftOffers, Date.now()),
3452
+ claims: buyerGiftClaims(rows2(found.giftClaims).sort((a, b) => Number(b.createdAt) - Number(a.createdAt)), Date.now()),
3453
+ refundPolicyText: typeof group?.refundPolicyText === "string" ? group.refundPolicyText : ""
3454
+ });
3455
+ }
3456
+ if (req.method === "POST" && url.pathname === "/api/gifts/checkout") {
3457
+ const user = await userFor(req, env, ctx);
3458
+ if (!user) return json({ error: "unauthorized" }, 401);
3459
+ let body;
3460
+ try {
3461
+ body = JSON.parse(await req.text());
3462
+ } catch {
3463
+ return json({ error: "invalid JSON body" }, 400);
3464
+ }
3465
+ const purchaseId = typeof body.purchaseId === "string" ? body.purchaseId : "";
3466
+ if (!/^[A-Za-z0-9_-]{16,128}$/.test(purchaseId)) return json({ error: "purchaseId is invalid" }, 400);
3467
+ const control = await loadActiveSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
3468
+ const offer = findGiftOffer(control?.document.giftOffers, typeof body.offerId === "string" ? body.offerId : "", Date.now());
3469
+ if (!offer) return json({ error: "gift offer is unavailable" }, 409);
3470
+ const group = first3((await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups);
3471
+ const stripeSecret = await getVaultSecret(db, "stripe_secret_key");
3472
+ if (!group || !stripeSecret || typeof group.stripePublishableKey !== "string") return json({ error: "payments not configured" }, 503);
3473
+ const emailNormalized = normalizeAdmissionEmail(user.email);
3474
+ const applications = rows2((await db.query({ applications: { $: { where: { emailNormalized }, limit: 20 } } })).applications);
3475
+ const app = applications.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
3476
+ try {
3477
+ return json(await startGiftIntent(db, {
3478
+ id: `gift-intent:${ctx.chapter.id}:${purchaseId}`,
3479
+ chapterId: ctx.chapter.id,
3480
+ environment: env.ODLA_ENV,
3481
+ offer,
3482
+ purchaserType: "member",
3483
+ purchaserEmail: user.email,
3484
+ purchaserEmailNormalized: emailNormalized,
3485
+ purchaserUserId: user.userId,
3486
+ ...typeof app?.stripeCustomerId === "string" ? { existingCustomerId: app.stripeCustomerId } : {},
3487
+ stripeSecret,
3488
+ publishableKey: group.stripePublishableKey,
3489
+ now: Date.now()
3490
+ }));
3491
+ } catch (error) {
3492
+ return json({ error: error instanceof Error ? error.message : "gift checkout failed" }, 502);
3493
+ }
3494
+ }
3495
+ const rotateMatch = url.pathname.match(/^\/api\/gifts\/([^/]+)\/rotate$/);
3496
+ if (req.method === "POST" && rotateMatch) {
3497
+ let claimId = "";
3498
+ try {
3499
+ claimId = decodeURIComponent(rotateMatch[1]);
3500
+ } catch {
3501
+ return invalid();
3502
+ }
3503
+ if (!claimId.startsWith("gift-claim:")) return invalid();
3504
+ const claim = await loadClaim(db, claimId);
3505
+ if (!claim || claim.chapterId !== ctx.chapter.id || Number(claim.rotationCount ?? 0) >= 10) return invalid();
3506
+ const user = await userFor(req, env, ctx);
3507
+ let applicationId = "";
3508
+ if (!user) {
3509
+ try {
3510
+ applicationId = String(JSON.parse(await req.text()).applicationId ?? "");
3511
+ } catch {
3512
+ return invalid();
3513
+ }
3514
+ }
3515
+ const owns = user ? claim.purchaserEmailNormalized === normalizeAdmissionEmail(user.email) : claim.purchaserApplicationId === applicationId;
3516
+ if (!owns) return invalid();
3517
+ const rotated = await rotateGiftClaim(db, claim, Date.now());
3518
+ return rotated ? json(rotated) : invalid();
3519
+ }
3520
+ if (req.method === "POST" && url.pathname === "/api/gifts/redeem") {
3521
+ const user = await userFor(req, env, ctx);
3522
+ if (!user) return json({ error: "unauthorized" }, 401);
3523
+ const now = Date.now();
3524
+ if (!await consumeRateLimit(db, ctx.chapter.id, `${user.userId}:${req.headers.get("cf-connecting-ip") ?? "no-ip"}`, now)) {
3525
+ return json({ error: "too many attempts" }, 429);
3526
+ }
3527
+ let code = "";
3528
+ try {
3529
+ code = String(JSON.parse(await req.text()).code ?? "").trim();
3530
+ } catch {
3531
+ return invalid();
3532
+ }
3533
+ if (!/^gift1_[A-Za-z0-9_-]{43}$/.test(code)) return invalid();
3534
+ const codeHash = await hashGiftCode(ctx.chapter.id, code);
3535
+ const claim = first3((await db.query({ giftClaims: { $: { where: { codeHash }, limit: 1 } } })).giftClaims);
3536
+ if (!claim || claim.chapterId !== ctx.chapter.id) return invalid();
3537
+ if (claim.status !== "pending" || Number(claim.codeExpiresAt) <= now) {
3538
+ await db.transact([{ t: "update", ns: "giftClaims", id: String(claim.id), attrs: {
3539
+ attemptCount: Number(claim.attemptCount ?? 0) + 1,
3540
+ ...claim.status === "pending" ? { status: "expired" } : {}
3541
+ } }]).catch(() => void 0);
3542
+ return invalid();
3543
+ }
3544
+ const redeemed = await redeemGiftClaim(db, claim, { email: user.email, userId: user.userId }, now);
3545
+ return redeemed ? json({ ok: true, ...redeemed }) : invalid();
3546
+ }
3547
+ return null;
3548
+ };
3549
+
2392
3550
  // src/worker-routes-admin.ts
2393
3551
  var import_calendar2 = require("@odla-ai/calendar");
2394
3552
 
@@ -2460,8 +3618,8 @@ async function upcomingEvents(env) {
2460
3618
  const res = await cal.availability.upcoming();
2461
3619
  return res.events.map((e) => ({ eventId: e.eventId, status: e.status, startAt: e.startAt, endAt: e.endAt }));
2462
3620
  }
2463
- function toReconcile(rows2) {
2464
- return rows2.map((m) => ({
3621
+ function toReconcile(rows4) {
3622
+ return rows4.map((m) => ({
2465
3623
  id: String(m.id),
2466
3624
  applicationId: String(m.applicationId),
2467
3625
  googleEventId: typeof m.googleEventId === "string" ? m.googleEventId : null,
@@ -2483,12 +3641,12 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
2483
3641
  const query = await db.query({
2484
3642
  meetings: { $: { where: all ? {} : { status: "scheduled" }, order: { startAt: "asc" }, limit: 500 } }
2485
3643
  });
2486
- let rows2 = Array.isArray(query.meetings) ? query.meetings : [];
2487
- if (Number.isFinite(from)) rows2 = rows2.filter((m) => Number(m.startAt ?? 0) >= from);
2488
- if (Number.isFinite(to)) rows2 = rows2.filter((m) => Number(m.startAt ?? 0) <= to);
3644
+ let rows4 = Array.isArray(query.meetings) ? query.meetings : [];
3645
+ if (Number.isFinite(from)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) >= from);
3646
+ if (Number.isFinite(to)) rows4 = rows4.filter((m) => Number(m.startAt ?? 0) <= to);
2489
3647
  let decisions = [];
2490
3648
  try {
2491
- decisions = reconcileMeetings(toReconcile(rows2), await upcomingEvents(env), Date.now());
3649
+ decisions = reconcileMeetings(toReconcile(rows4), await upcomingEvents(env), Date.now());
2492
3650
  } catch {
2493
3651
  }
2494
3652
  const patched = /* @__PURE__ */ new Map();
@@ -2515,69 +3673,10 @@ var handleAdminMeetings = async (req, url, env, ctx) => {
2515
3673
  };
2516
3674
  const group = (await db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })).groups?.[0];
2517
3675
  const timezone = resolveScheduling(group?.schedulingJson).timezone;
2518
- const meetings = rows2.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
3676
+ const meetings = rows4.map((m) => ({ ...m, ...patched.get(String(m.id)) ?? {}, applicant: applicantOf(m) })).filter((m) => all || m.status === "scheduled");
2519
3677
  return json({ meetings, adopted: decisions.length, timezone });
2520
3678
  };
2521
3679
 
2522
- // src/clerk-roles.ts
2523
- var CLERK_API = "https://api.clerk.com";
2524
- var DEFAULT_ROLE = "provisional";
2525
- var PAGE = 100;
2526
- function toRecord(u) {
2527
- if (typeof u.id !== "string") return null;
2528
- const pm = u.public_metadata ?? {};
2529
- const privateMetadata = u.private_metadata ?? {};
2530
- const rawProfile = privateMetadata.profile;
2531
- const profile = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {};
2532
- const applicationId = typeof privateMetadata.applicationId === "string" ? privateMetadata.applicationId : void 0;
2533
- const role = typeof pm.role === "string" && pm.role ? pm.role : DEFAULT_ROLE;
2534
- const email = u.email_addresses?.[0]?.email_address;
2535
- return {
2536
- id: u.id,
2537
- email: typeof email === "string" ? email : void 0,
2538
- role,
2539
- publicMetadata: pm,
2540
- privateMetadata,
2541
- profile,
2542
- ...applicationId ? { applicationId } : {}
2543
- };
2544
- }
2545
- async function clerkGet(path, secretKey, fetchImpl) {
2546
- const res = await fetchImpl(`${CLERK_API}${path}`, { headers: { authorization: `Bearer ${secretKey}` } });
2547
- if (!res.ok) throw new Error(`clerk GET ${path} \u2192 ${res.status}`);
2548
- return res.json();
2549
- }
2550
- async function clerkGetUserByEmail(secretKey, email, fetchImpl = fetch) {
2551
- const data = await clerkGet(`/v1/users?email_address=${encodeURIComponent(email)}&limit=1`, secretKey, fetchImpl).catch(() => null);
2552
- const user = Array.isArray(data) ? data[0] : void 0;
2553
- return user ? toRecord(user) : null;
2554
- }
2555
- async function clerkGetUser(secretKey, id, fetchImpl = fetch) {
2556
- const data = await clerkGet(`/v1/users/${encodeURIComponent(id)}`, secretKey, fetchImpl).catch(() => null);
2557
- return data ? toRecord(data) : null;
2558
- }
2559
- async function clerkListUsers(secretKey, fetchImpl = fetch) {
2560
- const out = [];
2561
- for (let offset = 0; ; offset += PAGE) {
2562
- const data = await clerkGet(`/v1/users?limit=${PAGE}&offset=${offset}`, secretKey, fetchImpl);
2563
- const page = Array.isArray(data) ? data : [];
2564
- for (const u of page) {
2565
- const record = toRecord(u);
2566
- if (record) out.push(record);
2567
- }
2568
- if (page.length < PAGE) break;
2569
- }
2570
- return out;
2571
- }
2572
- async function clerkSetRole(secretKey, id, role, fetchImpl = fetch) {
2573
- const res = await fetchImpl(`${CLERK_API}/v1/users/${encodeURIComponent(id)}/metadata`, {
2574
- method: "PATCH",
2575
- headers: { authorization: `Bearer ${secretKey}`, "content-type": "application/json" },
2576
- body: JSON.stringify({ public_metadata: { role } })
2577
- });
2578
- return res.ok;
2579
- }
2580
-
2581
3680
  // src/worker-routes-admin-people.ts
2582
3681
  async function adminGate(req, env, ctx) {
2583
3682
  const rawDb = ctx.makeDb(env);
@@ -2595,16 +3694,16 @@ var crmDeps2 = (db, ctx) => ({
2595
3694
  });
2596
3695
  var handleAdminCrmSync = async (req, url, env, ctx) => {
2597
3696
  if (req.method !== "POST" || url.pathname !== "/api/admin/crm/sync") return null;
2598
- const gate8 = await adminGate(req, env, ctx);
2599
- if (gate8 instanceof Response) return gate8;
2600
- const result = await backfillCrm(crmDeps2(gate8.db, ctx));
3697
+ const gate9 = await adminGate(req, env, ctx);
3698
+ if (gate9 instanceof Response) return gate9;
3699
+ const result = await backfillCrm(crmDeps2(gate9.db, ctx));
2601
3700
  return json({ ok: true, ...result });
2602
3701
  };
2603
3702
  var handleAdminPeople = async (req, url, env, ctx) => {
2604
3703
  if (req.method !== "GET" || url.pathname !== "/api/admin/people") return null;
2605
- const gate8 = await adminGate(req, env, ctx);
2606
- if (gate8 instanceof Response) return gate8;
2607
- const { db } = gate8;
3704
+ const gate9 = await adminGate(req, env, ctx);
3705
+ if (gate9 instanceof Response) return gate9;
3706
+ const { db } = gate9;
2608
3707
  const sk = await getVaultSecret(db, "clerk_secret_key");
2609
3708
  const [appsRes, usersRes, roleList] = await Promise.all([
2610
3709
  db.query({ applications: { $: { order: { createdAt: "desc" }, limit: 200 } } }),
@@ -2648,16 +3747,16 @@ var handleAdminPeople = async (req, url, env, ctx) => {
2648
3747
  });
2649
3748
  }
2650
3749
  }
2651
- const rows2 = [...people.values()].sort(
3750
+ const rows4 = [...people.values()].sort(
2652
3751
  (x, y) => (y.application?.createdAt ?? -1) - (x.application?.createdAt ?? -1)
2653
3752
  );
2654
- return json({ people: rows2 });
3753
+ return json({ people: rows4 });
2655
3754
  };
2656
3755
  var handleAdminPeopleAccess = async (req, url, env, ctx) => {
2657
3756
  if (req.method !== "GET" || url.pathname !== "/api/admin/people/access") return null;
2658
- const gate8 = await adminGate(req, env, ctx);
2659
- if (gate8 instanceof Response) return gate8;
2660
- const { db } = gate8;
3757
+ const gate9 = await adminGate(req, env, ctx);
3758
+ if (gate9 instanceof Response) return gate9;
3759
+ const { db } = gate9;
2661
3760
  const targetId = url.searchParams.get("userId") ?? "";
2662
3761
  if (!targetId.startsWith("user_")) return json({ error: "invalid userId" }, 400);
2663
3762
  const sk = await getVaultSecret(db, "clerk_secret_key");
@@ -2668,9 +3767,9 @@ var handleAdminPeopleAccess = async (req, url, env, ctx) => {
2668
3767
  };
2669
3768
  var handleAdminPeopleRole = async (req, url, env, ctx) => {
2670
3769
  if (req.method !== "POST" || url.pathname !== "/api/admin/people/role") return null;
2671
- const gate8 = await adminGate(req, env, ctx);
2672
- if (gate8 instanceof Response) return gate8;
2673
- const { db, actor } = gate8;
3770
+ const gate9 = await adminGate(req, env, ctx);
3771
+ if (gate9 instanceof Response) return gate9;
3772
+ const { db, actor } = gate9;
2674
3773
  let body;
2675
3774
  try {
2676
3775
  body = await req.json();
@@ -2817,17 +3916,17 @@ function subAnnualCents(sub) {
2817
3916
  }
2818
3917
 
2819
3918
  // src/worker-routes-admin-dashboard.ts
2820
- async function gate(req, env, ctx) {
3919
+ async function gate2(req, env, ctx) {
2821
3920
  const rawDb = ctx.makeDb(env);
2822
3921
  const u = await ctx.verifyUser(req, env);
2823
3922
  if (!u) return json({ error: "unauthorized" }, 401);
2824
3923
  if (!await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
2825
3924
  return rawDb;
2826
3925
  }
2827
- var rows = (v) => Array.isArray(v) ? v : [];
3926
+ var rows3 = (v) => Array.isArray(v) ? v : [];
2828
3927
  var handleAdminDashboard = async (req, url, env, ctx) => {
2829
3928
  if (req.method !== "GET" || url.pathname !== "/api/admin/dashboard") return null;
2830
- const got = await gate(req, env, ctx);
3929
+ const got = await gate2(req, env, ctx);
2831
3930
  if (got instanceof Response) return got;
2832
3931
  const db = got;
2833
3932
  const now = Date.now();
@@ -2839,7 +3938,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2839
3938
  db.query({ crm_record: { $: { where: { type: "person" }, limit: 1e3 } } }),
2840
3939
  db.query({ groups: { $: { where: { id: ctx.chapter.id }, limit: 1 } } })
2841
3940
  ]);
2842
- const apps = rows(appsRes.applications);
3941
+ const apps = rows3(appsRes.applications);
2843
3942
  const applications = {
2844
3943
  total: apps.length,
2845
3944
  last7: apps.filter((a) => a.createdAt >= d7).length,
@@ -2851,7 +3950,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2851
3950
  pipeline[s] = 0;
2852
3951
  pipelineDelta[s] = 0;
2853
3952
  }
2854
- for (const r of rows(recsRes.crm_record)) {
3953
+ for (const r of rows3(recsRes.crm_record)) {
2855
3954
  const s = r.stage;
2856
3955
  if (s in pipeline) {
2857
3956
  pipeline[s] = (pipeline[s] ?? 0) + 1;
@@ -2859,7 +3958,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2859
3958
  if (Number.isFinite(sc) && sc >= d7) pipelineDelta[s] = (pipelineDelta[s] ?? 0) + 1;
2860
3959
  }
2861
3960
  }
2862
- const meetings = rows(meetingsRes.meetings);
3961
+ const meetings = rows3(meetingsRes.meetings);
2863
3962
  const appById = new Map(apps.map((a) => [a.id, a]));
2864
3963
  const upcoming = meetings.filter((m) => m.startAt >= now - 36e5);
2865
3964
  const calls = { upcoming: upcoming.length, needsAttention: meetings.filter((m) => m.drift && m.drift !== "none").length };
@@ -2884,7 +3983,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2884
3983
  if (sk) {
2885
3984
  const subsRes = await stripeCall(sk, "GET", "/v1/subscriptions", { limit: 100, status: "all" });
2886
3985
  if (subsRes.ok) {
2887
- const subs = rows(subsRes.body.data);
3986
+ const subs = rows3(subsRes.body.data);
2888
3987
  const subMs = (s) => (s.created ?? 0) * 1e3;
2889
3988
  membersSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: 1 })), now);
2890
3989
  revenueSeries = dashboardMetricData(subs.map((s) => ({ t: subMs(s), v: subAnnualCents(s) })), now);
@@ -2904,7 +4003,7 @@ var handleAdminDashboard = async (req, url, env, ctx) => {
2904
4003
  };
2905
4004
  var handleAdminBilling = async (req, url, env, ctx) => {
2906
4005
  if (req.method !== "GET" || url.pathname !== "/api/admin/billing") return null;
2907
- const got = await gate(req, env, ctx);
4006
+ const got = await gate2(req, env, ctx);
2908
4007
  if (got instanceof Response) return got;
2909
4008
  const db = got;
2910
4009
  const sk = await getVaultSecret(db, "stripe_secret_key");
@@ -2916,9 +4015,9 @@ var handleAdminBilling = async (req, url, env, ctx) => {
2916
4015
  ]);
2917
4016
  const testMode = String(groupRes.groups?.[0]?.stripePublishableKey ?? "").startsWith("pk_test");
2918
4017
  if (!subsRes.ok) return json({ error: "billing lookup failed upstream" }, 502);
2919
- const subById = new Map(rows(subsRes.body.data).map((s) => [s.id, s]));
4018
+ const subById = new Map(rows3(subsRes.body.data).map((s) => [s.id, s]));
2920
4019
  const billingRows = [];
2921
- for (const a of rows(appsRes.applications)) {
4020
+ for (const a of rows3(appsRes.applications)) {
2922
4021
  if (!a.stripeCustomerId && !a.stripeSubscriptionId) continue;
2923
4022
  const sub = a.stripeSubscriptionId ? subById.get(a.stripeSubscriptionId) : void 0;
2924
4023
  const items = sub?.items?.data ?? [];
@@ -2962,7 +4061,7 @@ var handleAdminBilling = async (req, url, env, ctx) => {
2962
4061
 
2963
4062
  // src/worker-routes-admin-lifecycle.ts
2964
4063
  var import_calendar3 = require("@odla-ai/calendar");
2965
- async function gate2(req, env, ctx) {
4064
+ async function gate3(req, env, ctx) {
2966
4065
  const rawDb = ctx.makeDb(env);
2967
4066
  const u = await ctx.verifyUser(req, env);
2968
4067
  if (!u) return json({ error: "unauthorized" }, 401);
@@ -2988,7 +4087,7 @@ async function loadGroup(db, id) {
2988
4087
  var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
2989
4088
  const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/reschedule$/);
2990
4089
  if (req.method !== "POST" || !m) return null;
2991
- const got = await gate2(req, env, ctx);
4090
+ const got = await gate3(req, env, ctx);
2992
4091
  if (got instanceof Response) return got;
2993
4092
  const db = got;
2994
4093
  const body = await readJson(req);
@@ -3029,7 +4128,7 @@ var handleAdminMeetingReschedule = async (req, url, env, ctx) => {
3029
4128
  var handleAdminMeetingCancel = async (req, url, env, ctx) => {
3030
4129
  const m = url.pathname.match(/^\/api\/admin\/meetings\/([0-9a-f-]+)\/cancel$/);
3031
4130
  if (req.method !== "POST" || !m) return null;
3032
- const got = await gate2(req, env, ctx);
4131
+ const got = await gate3(req, env, ctx);
3033
4132
  if (got instanceof Response) return got;
3034
4133
  const db = got;
3035
4134
  const { meetings } = await db.query({ meetings: { $: { where: { id: m[1] }, limit: 1 } } });
@@ -3052,43 +4151,20 @@ var handleAdminMeetingCancel = async (req, url, env, ctx) => {
3052
4151
  var handleAdminApprove = async (req, url, env, ctx) => {
3053
4152
  const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/approve$/);
3054
4153
  if (req.method !== "POST" || !m) return null;
3055
- const got = await gate2(req, env, ctx);
4154
+ const got = await gate3(req, env, ctx);
3056
4155
  if (got instanceof Response) return got;
3057
4156
  const db = got;
3058
4157
  const id = m[1];
3059
4158
  const app = await loadApp(db, id);
3060
4159
  if (!app) return json({ error: "not found" }, 404);
3061
- if (!canApprove(String(app.status), ctx.chapter.pipeline)) return json({ error: `cannot approve from status "${String(app.status)}"` }, 409);
3062
- const target = "approved";
3063
- await db.transact([{ t: "update", ns: "applications", id, attrs: { status: target } }]);
3064
- await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: target }, stage: target }).catch(() => void 0);
3065
- const { promoteTo, send } = ctx.chapter.operations.onApprove;
3066
- let rolePromoted = false;
3067
- const sk = await getVaultSecret(db, "clerk_secret_key");
3068
- if (promoteTo !== false && sk) {
3069
- let userId = app.clerkUserId || null;
3070
- if (!userId) {
3071
- const found = await clerkGetUserByEmail(sk, String(app.email));
3072
- userId = found?.id ?? null;
3073
- if (userId) await db.transact([{ t: "update", ns: "applications", id, attrs: { clerkUserId: userId } }]);
3074
- }
3075
- if (userId) rolePromoted = await clerkSetRole(sk, userId, promoteTo);
3076
- }
3077
- let emailLogged = false;
3078
- const group = await loadGroup(db, String(app.groupId ?? ctx.chapter.id));
3079
- if (send !== false && group) {
3080
- const res = await sendTemplated(
3081
- { db, envName: env.ODLA_ENV, sender: env.SEND_EMAIL, from: env.EMAIL_FROM, now: () => Date.now(), newId: () => crypto.randomUUID() },
3082
- { group: emailGroupFrom(group), template: send, to: String(app.email), vars: { firstName: String(app.firstName ?? ""), membersUrl: `${url.origin}/members/` }, applicationId: id, dedupeKey: `approve:${id}` }
3083
- );
3084
- emailLogged = res.sent;
3085
- }
3086
- return json({ ok: true, status: target, rolePromoted, emailLogged });
4160
+ const result = await approveApplication({ db, env, ctx, app, origin: url.origin, reason: "manual" });
4161
+ if (!result.ok) return json({ error: result.error }, result.status);
4162
+ return json({ ok: true, status: "approved", rolePromoted: result.rolePromoted, emailLogged: result.emailLogged });
3087
4163
  };
3088
4164
  var handleAdminRefund = async (req, url, env, ctx) => {
3089
4165
  const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/refund$/);
3090
4166
  if (req.method !== "POST" || !m) return null;
3091
- const got = await gate2(req, env, ctx);
4167
+ const got = await gate3(req, env, ctx);
3092
4168
  if (got instanceof Response) return got;
3093
4169
  const db = got;
3094
4170
  const app = await loadApp(db, m[1]);
@@ -3120,7 +4196,7 @@ var handleAdminRefund = async (req, url, env, ctx) => {
3120
4196
  var handleAdminApplicationPatch = async (req, url, env, ctx) => {
3121
4197
  const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)$/);
3122
4198
  if (req.method !== "PATCH" || !m) return null;
3123
- const got = await gate2(req, env, ctx);
4199
+ const got = await gate3(req, env, ctx);
3124
4200
  if (got instanceof Response) return got;
3125
4201
  const db = got;
3126
4202
  const id = m[1];
@@ -3148,7 +4224,7 @@ var handleAdminApplicationPatch = async (req, url, env, ctx) => {
3148
4224
  };
3149
4225
 
3150
4226
  // src/worker-routes-admin-comms.ts
3151
- async function gate3(req, env, ctx) {
4227
+ async function gate4(req, env, ctx) {
3152
4228
  const rawDb = ctx.makeDb(env);
3153
4229
  const u = await ctx.verifyUser(req, env);
3154
4230
  if (!u) return json({ error: "unauthorized" }, 401);
@@ -3170,7 +4246,7 @@ var notifyDeps = (db, env) => ({
3170
4246
  });
3171
4247
  var handleAdminGroupEmail = async (req, url, env, ctx) => {
3172
4248
  if (url.pathname !== "/api/admin/group/email" || req.method !== "GET" && req.method !== "PUT") return null;
3173
- const got = await gate3(req, env, ctx);
4249
+ const got = await gate4(req, env, ctx);
3174
4250
  if (got instanceof Response) return got;
3175
4251
  const db = got;
3176
4252
  const group = await loadGroup2(db, ctx.chapter.id);
@@ -3211,11 +4287,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
3211
4287
  for (const key of EMAIL_TEMPLATE_NAMES) {
3212
4288
  const t = templates[key];
3213
4289
  const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
3214
- const text2 = typeof t?.text === "string" ? t.text : "";
3215
- if (!subject || !text2.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
4290
+ const text3 = typeof t?.text === "string" ? t.text : "";
4291
+ if (!subject || !text3.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
3216
4292
  if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key}" subject must be a single line under 200 characters` }, 400);
3217
- if (text2.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
3218
- clean[key] = { subject, text: text2, enabled: t?.enabled !== false };
4293
+ if (text3.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
4294
+ clean[key] = { subject, text: text3, enabled: t?.enabled !== false };
3219
4295
  }
3220
4296
  const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
3221
4297
  const notificationEmail = str2(body.notificationEmail).trim();
@@ -3232,7 +4308,7 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
3232
4308
  };
3233
4309
  var handleAdminEmailLog = async (req, url, env, ctx) => {
3234
4310
  if (req.method !== "GET" || url.pathname !== "/api/admin/email/log") return null;
3235
- const got = await gate3(req, env, ctx);
4311
+ const got = await gate4(req, env, ctx);
3236
4312
  if (got instanceof Response) return got;
3237
4313
  const { emailLog } = await got.query({ emailLog: { $: { order: { sentAt: "desc" }, limit: 50 } } });
3238
4314
  const sends = (emailLog ?? []).map((r) => ({
@@ -3249,7 +4325,7 @@ var handleAdminEmailLog = async (req, url, env, ctx) => {
3249
4325
  };
3250
4326
  var handleAdminEmailTest = async (req, url, env, ctx) => {
3251
4327
  if (req.method !== "POST" || url.pathname !== "/api/admin/email/test") return null;
3252
- const got = await gate3(req, env, ctx);
4328
+ const got = await gate4(req, env, ctx);
3253
4329
  if (got instanceof Response) return got;
3254
4330
  const db = got;
3255
4331
  let body;
@@ -3277,7 +4353,7 @@ var handleAdminEmailTest = async (req, url, env, ctx) => {
3277
4353
  var handleAdminComms = async (req, url, env, ctx) => {
3278
4354
  const m = url.pathname.match(/^\/api\/admin\/people\/([0-9a-fA-F-]+)\/comms$/);
3279
4355
  if (req.method !== "GET" || !m) return null;
3280
- const got = await gate3(req, env, ctx);
4356
+ const got = await gate4(req, env, ctx);
3281
4357
  if (got instanceof Response) return got;
3282
4358
  const db = got;
3283
4359
  const appId = m[1];
@@ -3310,7 +4386,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
3310
4386
 
3311
4387
  // src/worker-routes-network.ts
3312
4388
  var import_crm6 = require("@odla-ai/crm");
3313
- var import_db5 = require("@odla-ai/db");
4389
+ var import_db6 = require("@odla-ai/db");
3314
4390
 
3315
4391
  // src/worker-network-fetch.ts
3316
4392
  function targetFetcher(env, target) {
@@ -3328,8 +4404,8 @@ function fetchNetworkTarget(env, target, destination, init) {
3328
4404
 
3329
4405
  // src/worker-network-push.ts
3330
4406
  var import_crm5 = require("@odla-ai/crm");
3331
- var import_db4 = require("@odla-ai/db");
3332
- async function pushNetworkRecord(db, env, ctx, target, record) {
4407
+ var import_db5 = require("@odla-ai/db");
4408
+ async function pushNetworkRecord(db, env, ctx, target, record2) {
3333
4409
  const secret = await getVaultSecret(db, target.secretName);
3334
4410
  const crmDeps3 = {
3335
4411
  crm: ctx.chapter.crm,
@@ -3340,7 +4416,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3340
4416
  if (!secret) {
3341
4417
  const error = `vault secret "${target.secretName}" is missing`;
3342
4418
  await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
3343
- recordId: record.id,
4419
+ recordId: record2.id,
3344
4420
  targetId: target.id,
3345
4421
  status: "failed",
3346
4422
  payloadVersion: 2,
@@ -3350,7 +4426,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3350
4426
  }
3351
4427
  let payload;
3352
4428
  try {
3353
- payload = sharedRecordFromCrm(ctx.chapter.crm, record, target, ctx.chapter.id);
4429
+ payload = sharedRecordFromCrm(ctx.chapter.crm, record2, target, ctx.chapter.id);
3354
4430
  } catch (err) {
3355
4431
  return {
3356
4432
  id: target.id,
@@ -3362,7 +4438,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3362
4438
  const payloadBody = JSON.stringify(payload);
3363
4439
  const destination = new URL("/api/network/shared", target.url);
3364
4440
  try {
3365
- const signed = await (0, import_db4.signFederatedRequest)({
4441
+ const signed = await (0, import_db5.signFederatedRequest)({
3366
4442
  secret,
3367
4443
  sender: ctx.chapter.id,
3368
4444
  method: "POST",
@@ -3379,7 +4455,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3379
4455
  if (!res.ok) {
3380
4456
  const error = responseBody.error ?? "follower rejected the record";
3381
4457
  await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
3382
- recordId: record.id,
4458
+ recordId: record2.id,
3383
4459
  targetId: target.id,
3384
4460
  status: "failed",
3385
4461
  payloadVersion: 2,
@@ -3394,7 +4470,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3394
4470
  };
3395
4471
  }
3396
4472
  await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
3397
- recordId: record.id,
4473
+ recordId: record2.id,
3398
4474
  targetId: target.id,
3399
4475
  status: "delivered",
3400
4476
  payloadVersion: 2,
@@ -3410,7 +4486,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3410
4486
  } catch (err) {
3411
4487
  const error = err instanceof Error ? err.message : "delivery failed";
3412
4488
  await (0, import_crm5.recordDeliveryAttempt)(crmDeps3, {
3413
- recordId: record.id,
4489
+ recordId: record2.id,
3414
4490
  targetId: target.id,
3415
4491
  status: "failed",
3416
4492
  payloadVersion: 2,
@@ -3421,7 +4497,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
3421
4497
  }
3422
4498
 
3423
4499
  // src/worker-routes-network.ts
3424
- async function gate4(req, env, ctx) {
4500
+ async function gate5(req, env, ctx) {
3425
4501
  const db = ctx.makeDb(env);
3426
4502
  const user = await ctx.verifyUser(req, env);
3427
4503
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3430,7 +4506,7 @@ async function gate4(req, env, ctx) {
3430
4506
  }
3431
4507
  var handleAdminNetworkTargets = async (req, url, env, ctx) => {
3432
4508
  if (req.method !== "GET" || url.pathname !== "/api/admin/network/targets") return null;
3433
- const got = await gate4(req, env, ctx);
4509
+ const got = await gate5(req, env, ctx);
3434
4510
  if ("response" in got) return got.response;
3435
4511
  return json({
3436
4512
  targets: ctx.chapter.network.targets.map(({ id, name, url: targetUrl, fields, sharedNotes }) => ({
@@ -3447,7 +4523,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
3447
4523
  const db = ctx.makeDb(env);
3448
4524
  const secret = await getVaultSecret(db, "network_share_secret");
3449
4525
  if (!secret) return json({ error: "unauthorized" }, 401);
3450
- const verified = await (0, import_db5.verifyFederatedRequest)(req, { secret });
4526
+ const verified = await (0, import_db6.verifyFederatedRequest)(req, { secret });
3451
4527
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
3452
4528
  const types = await Promise.all(
3453
4529
  Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm6.typeSummary)({ crm: ctx.chapter.crm, db }, type))
@@ -3465,7 +4541,7 @@ async function snapshotOne(db, env, ctx, target) {
3465
4541
  if (!secret) return { id: target.id, name: target.name, url: target.url, available: false, error: "edge secret is missing" };
3466
4542
  const destination = new URL("/api/network/snapshot", target.url);
3467
4543
  try {
3468
- const headers = await (0, import_db5.signFederatedRequest)({
4544
+ const headers = await (0, import_db6.signFederatedRequest)({
3469
4545
  secret,
3470
4546
  sender: ctx.chapter.id,
3471
4547
  method: "GET",
@@ -3506,7 +4582,7 @@ async function snapshotOne(db, env, ctx, target) {
3506
4582
  }
3507
4583
  var handleAdminNetworkRollup = async (req, url, env, ctx) => {
3508
4584
  if (req.method !== "GET" || url.pathname !== "/api/admin/network/rollup") return null;
3509
- const got = await gate4(req, env, ctx);
4585
+ const got = await gate5(req, env, ctx);
3510
4586
  if ("response" in got) return got.response;
3511
4587
  const targets = await Promise.all(
3512
4588
  ctx.chapter.network.targets.map((target) => snapshotOne(got.db, env, ctx, target))
@@ -3533,7 +4609,7 @@ var handleAdminNetworkRollup = async (req, url, env, ctx) => {
3533
4609
  };
3534
4610
  var handleAdminNetworkPush = async (req, url, env, ctx) => {
3535
4611
  if (req.method !== "POST" || url.pathname !== "/api/admin/network/push") return null;
3536
- const got = await gate4(req, env, ctx);
4612
+ const got = await gate5(req, env, ctx);
3537
4613
  if ("response" in got) return got.response;
3538
4614
  let body;
3539
4615
  try {
@@ -3548,22 +4624,22 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
3548
4624
  if (requested.size !== body.targetIds.length) return json({ error: "targetIds must contain unique strings" }, 400);
3549
4625
  const targets = ctx.chapter.network.targets.filter((target) => requested.has(target.id));
3550
4626
  if (targets.length !== requested.size) return json({ error: "one or more targetIds are not configured" }, 400);
3551
- const record = await (0, import_crm6.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
3552
- if (!record) return json({ error: "record not found" }, 404);
4627
+ const record2 = await (0, import_crm6.getRecord)({ crm: ctx.chapter.crm, db: got.db }, body.recordId);
4628
+ if (!record2) return json({ error: "record not found" }, 404);
3553
4629
  const results = await Promise.all(
3554
- targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record))
4630
+ targets.map((target) => pushNetworkRecord(got.db, env, ctx, target, record2))
3555
4631
  );
3556
4632
  return json({ ok: results.every((result) => result.ok), results });
3557
4633
  };
3558
4634
 
3559
4635
  // src/worker-routes-network-records.ts
3560
4636
  var import_crm7 = require("@odla-ai/crm");
3561
- var import_db6 = require("@odla-ai/db");
4637
+ var import_db7 = require("@odla-ai/db");
3562
4638
  var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
3563
4639
  var MAX_PAGE = 50;
3564
4640
  var MAX_OFFSET = 1e4;
3565
4641
  var MAX_SEARCH = 200;
3566
- async function gate5(req, env, ctx) {
4642
+ async function gate6(req, env, ctx) {
3567
4643
  const db = ctx.makeDb(env);
3568
4644
  const user = await ctx.verifyUser(req, env);
3569
4645
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3576,18 +4652,18 @@ function integer(value, fallback, min, max) {
3576
4652
  const parsed = Number(value);
3577
4653
  return Number.isSafeInteger(parsed) && parsed >= min && parsed <= max ? parsed : null;
3578
4654
  }
3579
- function projectRecord(record, allowed) {
4655
+ function projectRecord(record2, allowed) {
3580
4656
  const fields = Object.fromEntries(
3581
- allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(record.fields, name) ? [[name, record.fields[name]]] : [])
4657
+ allowed.flatMap((name) => Object.prototype.hasOwnProperty.call(record2.fields, name) ? [[name, record2.fields[name]]] : [])
3582
4658
  );
3583
4659
  return {
3584
- id: record.id,
3585
- type: record.type,
3586
- name: record.name,
3587
- ...record.stage ? { stage: record.stage } : {},
4660
+ id: record2.id,
4661
+ type: record2.type,
4662
+ name: record2.name,
4663
+ ...record2.stage ? { stage: record2.stage } : {},
3588
4664
  fields,
3589
- createdAt: record.createdAt,
3590
- updatedAt: record.updatedAt
4665
+ createdAt: record2.createdAt,
4666
+ updatedAt: record2.updatedAt
3591
4667
  };
3592
4668
  }
3593
4669
  var handleNetworkRecords = async (req, url, env, ctx) => {
@@ -3596,7 +4672,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
3596
4672
  const secret = await getVaultSecret(db, "network_share_secret");
3597
4673
  const readers = ctx.chapter.network.readers;
3598
4674
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
3599
- const verified = await (0, import_db6.verifyFederatedRequest)(req, {
4675
+ const verified = await (0, import_db7.verifyFederatedRequest)(req, {
3600
4676
  secret,
3601
4677
  senders: readers.map((reader2) => reader2.id)
3602
4678
  });
@@ -3625,7 +4701,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
3625
4701
  version: 1,
3626
4702
  site: { id: ctx.chapter.id, name: ctx.chapter.name },
3627
4703
  type,
3628
- records: page.records.map((record) => projectRecord(record, allowed)),
4704
+ records: page.records.map((record2) => projectRecord(record2, allowed)),
3629
4705
  total: page.total,
3630
4706
  limit: page.limit,
3631
4707
  offset: page.offset
@@ -3641,7 +4717,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
3641
4717
  const destination = new URL("/api/network/records", target.url);
3642
4718
  destination.search = query.toString();
3643
4719
  try {
3644
- const headers = await (0, import_db6.signFederatedRequest)({
4720
+ const headers = await (0, import_db7.signFederatedRequest)({
3645
4721
  secret,
3646
4722
  sender: ctx.chapter.id,
3647
4723
  method: "GET",
@@ -3666,7 +4742,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
3666
4742
  }
3667
4743
  var handleAdminNetworkRecords = async (req, url, env, ctx) => {
3668
4744
  if (req.method !== "GET" || url.pathname !== "/api/admin/network/records") return null;
3669
- const got = await gate5(req, env, ctx);
4745
+ const got = await gate6(req, env, ctx);
3670
4746
  if ("response" in got) return got.response;
3671
4747
  const targetId = url.searchParams.get("targetId") ?? "";
3672
4748
  const type = url.searchParams.get("type") ?? "";
@@ -3693,8 +4769,8 @@ var handleAdminNetworkRecords = async (req, url, env, ctx) => {
3693
4769
  // src/worker-routes-network-notes.ts
3694
4770
  var IDENTIFIER2 = /^[a-z][a-zA-Z0-9_]*$/;
3695
4771
  var RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3696
- var MAX_NOTE = 4e3;
3697
- async function gate6(req, env, ctx) {
4772
+ var MAX_NOTE2 = 4e3;
4773
+ async function gate7(req, env, ctx) {
3698
4774
  const db = ctx.makeDb(env);
3699
4775
  const user = await ctx.verifyUser(req, env);
3700
4776
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3728,7 +4804,7 @@ function asNote(row) {
3728
4804
  }
3729
4805
  var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3730
4806
  if (url.pathname !== "/api/admin/network/notes" || req.method !== "GET" && req.method !== "POST") return null;
3731
- const got = await gate6(req, env, ctx);
4807
+ const got = await gate7(req, env, ctx);
3732
4808
  if ("response" in got) return got.response;
3733
4809
  const identity = noteIdentity(url, ctx);
3734
4810
  if (!identity) return json({ error: "valid targetId, type, and recordId are required" }, 400);
@@ -3745,7 +4821,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3745
4821
  return json({ notes: (result.networkNotes ?? []).map((row) => asNote(row)) });
3746
4822
  }
3747
4823
  const contentLength = Number(req.headers.get("content-length") ?? 0);
3748
- if (contentLength > MAX_NOTE * 2) return json({ error: "note is too large" }, 413);
4824
+ if (contentLength > MAX_NOTE2 * 2) return json({ error: "note is too large" }, 413);
3749
4825
  let input;
3750
4826
  try {
3751
4827
  input = await req.json();
@@ -3753,7 +4829,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3753
4829
  return json({ error: "invalid JSON body" }, 400);
3754
4830
  }
3755
4831
  const body = typeof input.body === "string" ? input.body.trim() : "";
3756
- if (!body || body.length > MAX_NOTE) return json({ error: `note must be 1\u2013${MAX_NOTE} characters` }, 400);
4832
+ if (!body || body.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
3757
4833
  const id = crypto.randomUUID();
3758
4834
  const note = {
3759
4835
  id,
@@ -3776,13 +4852,13 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3776
4852
 
3777
4853
  // src/worker-routes-network-shared-notes.ts
3778
4854
  var import_crm8 = require("@odla-ai/crm");
3779
- var import_db7 = require("@odla-ai/db");
4855
+ var import_db8 = require("@odla-ai/db");
3780
4856
  var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
3781
4857
  var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3782
4858
  var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
3783
- var MAX_NOTE2 = 4e3;
4859
+ var MAX_NOTE3 = 4e3;
3784
4860
  var NETWORK_META = "chapterNetwork";
3785
- async function gate7(req, env, ctx) {
4861
+ async function gate8(req, env, ctx) {
3786
4862
  const db = ctx.makeDb(env);
3787
4863
  const user = await ctx.verifyUser(req, env);
3788
4864
  if (!user) return { response: json({ error: "unauthorized" }, 401) };
@@ -3828,14 +4904,14 @@ function sourceLabel(sourceId) {
3828
4904
  }
3829
4905
  var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3830
4906
  if (url.pathname !== "/api/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
3831
- if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_NOTE2 * 2) {
4907
+ if (req.method === "POST" && Number(req.headers.get("content-length") ?? 0) > MAX_NOTE3 * 2) {
3832
4908
  return json({ error: "note is too large" }, 413);
3833
4909
  }
3834
4910
  const db = ctx.makeDb(env);
3835
4911
  const secret = await getVaultSecret(db, "network_share_secret");
3836
4912
  const readers = ctx.chapter.network.readers;
3837
4913
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
3838
- const verified = await (0, import_db7.verifyFederatedRequest)(req, {
4914
+ const verified = await (0, import_db8.verifyFederatedRequest)(req, {
3839
4915
  secret,
3840
4916
  senders: readers.map((reader2) => reader2.id)
3841
4917
  });
@@ -3846,8 +4922,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3846
4922
  if (!IDENTIFIER3.test(recordType) || !RECORD_ID2.test(recordId) || !reader?.sharedNotes.includes(recordType)) {
3847
4923
  return json({ error: "shared notes are not enabled for this reader and record type" }, 403);
3848
4924
  }
3849
- const record = await (0, import_crm8.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
3850
- if (!record || record.type !== recordType) return json({ error: "record not found" }, 404);
4925
+ const record2 = await (0, import_crm8.getRecord)({ crm: ctx.chapter.crm, db }, recordId);
4926
+ if (!record2 || record2.type !== recordType) return json({ error: "record not found" }, 404);
3851
4927
  if (req.method === "GET") {
3852
4928
  return json({
3853
4929
  version: 1,
@@ -3863,8 +4939,8 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3863
4939
  }
3864
4940
  const body = typeof input.body === "string" ? input.body.trim() : "";
3865
4941
  const mutationId = typeof input.mutationId === "string" ? input.mutationId : "";
3866
- if (!body || body.length > MAX_NOTE2 || !MUTATION_ID.test(mutationId)) {
3867
- return json({ error: `body must be 1\u2013${MAX_NOTE2} characters and mutationId must be valid` }, 400);
4942
+ if (!body || body.length > MAX_NOTE3 || !MUTATION_ID.test(mutationId)) {
4943
+ return json({ error: `body must be 1\u2013${MAX_NOTE3} characters and mutationId must be valid` }, 400);
3868
4944
  }
3869
4945
  const createdAt = Date.now();
3870
4946
  const id = `network:${verified.sender}:${mutationId}`;
@@ -3906,7 +4982,7 @@ function targetFor2(ctx, id) {
3906
4982
  }
3907
4983
  var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
3908
4984
  if (url.pathname !== "/api/admin/network/shared-notes" || req.method !== "GET" && req.method !== "POST") return null;
3909
- const got = await gate7(req, env, ctx);
4985
+ const got = await gate8(req, env, ctx);
3910
4986
  if ("response" in got) return got.response;
3911
4987
  const targetId = url.searchParams.get("targetId") ?? "";
3912
4988
  const recordType = url.searchParams.get("type") ?? "";
@@ -3924,7 +5000,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
3924
5000
  return json({ error: "invalid JSON body" }, 400);
3925
5001
  }
3926
5002
  const note = typeof input.body === "string" ? input.body.trim() : "";
3927
- if (!note || note.length > MAX_NOTE2) return json({ error: `note must be 1\u2013${MAX_NOTE2} characters` }, 400);
5003
+ if (!note || note.length > MAX_NOTE3) return json({ error: `note must be 1\u2013${MAX_NOTE3} characters` }, 400);
3928
5004
  body = JSON.stringify({ body: note, mutationId: crypto.randomUUID() });
3929
5005
  }
3930
5006
  const secret = await getVaultSecret(got.db, target.secretName);
@@ -3932,7 +5008,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
3932
5008
  const destination = new URL("/api/network/shared-notes", target.url);
3933
5009
  destination.search = new URLSearchParams({ type: recordType, recordId }).toString();
3934
5010
  try {
3935
- const headers = await (0, import_db7.signFederatedRequest)({
5011
+ const headers = await (0, import_db8.signFederatedRequest)({
3936
5012
  secret,
3937
5013
  sender: ctx.chapter.id,
3938
5014
  method: req.method,
@@ -4053,11 +5129,11 @@ var handleFormation = async (req, url, env, ctx) => {
4053
5129
  };
4054
5130
 
4055
5131
  // src/signup-control-delivery.ts
4056
- var import_db8 = require("@odla-ai/db");
5132
+ var import_db9 = require("@odla-ai/db");
4057
5133
  async function deliverSignupControl(options) {
4058
5134
  const destination = new URL(SIGNUP_CONTROL_PATH, options.targetUrl);
4059
5135
  const body = JSON.stringify(options.envelope);
4060
- const signed = await (0, import_db8.signFederatedRequest)({
5136
+ const signed = await (0, import_db9.signFederatedRequest)({
4061
5137
  secret: options.secret,
4062
5138
  sender: options.sender,
4063
5139
  method: "POST",
@@ -4077,6 +5153,162 @@ async function deliverSignupControl(options) {
4077
5153
  return result;
4078
5154
  }
4079
5155
 
5156
+ // src/admission-grants-delivery.ts
5157
+ var import_db10 = require("@odla-ai/db");
5158
+ async function deliverAdmissionGrants(options) {
5159
+ const destination = new URL(ADMISSION_GRANTS_PATH, options.targetUrl);
5160
+ const body = JSON.stringify(options.envelope);
5161
+ const signed = await (0, import_db10.signFederatedRequest)({
5162
+ secret: options.secret,
5163
+ sender: options.sender,
5164
+ method: "POST",
5165
+ url: destination,
5166
+ body
5167
+ });
5168
+ const response = await (options.fetch ?? globalThis.fetch)(destination, {
5169
+ method: "POST",
5170
+ headers: { ...signed, "content-type": "application/json" },
5171
+ body
5172
+ });
5173
+ const result = await response.json();
5174
+ if (!response.ok || !("ok" in result) || result.ok !== true) {
5175
+ const failure = result;
5176
+ throw new Error(`admission grants rejected (${response.status}): ${failure.reason ?? failure.error ?? "unknown"}`);
5177
+ }
5178
+ return result;
5179
+ }
5180
+
5181
+ // src/signup-control-theme.ts
5182
+ var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
5183
+ var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
5184
+ var TOKEN_NAME = /^--ui-[a-z0-9][a-z0-9-]{0,94}$/;
5185
+ var DIGEST = /^sha256:[0-9a-f]{64}$/;
5186
+ var UNSAFE_VALUE = /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i;
5187
+ var MAX_TOKENS = 256;
5188
+ var MAX_VALUE_LENGTH = 1e3;
5189
+ var MAX_SERIALIZED_LENGTH = 65536;
5190
+ function validatedTokens(input) {
5191
+ const entries = Object.entries(input ?? {});
5192
+ if (entries.length > MAX_TOKENS) {
5193
+ throw new TypeError(`signup-control theme supports at most ${MAX_TOKENS} tokens`);
5194
+ }
5195
+ const tokens = {};
5196
+ let serializedLength = 0;
5197
+ for (const [name, raw] of entries.sort(([left], [right]) => left.localeCompare(right))) {
5198
+ if (!TOKEN_NAME.test(name)) {
5199
+ throw new TypeError(`signup-control theme token "${name}" must be an allowlisted --ui-* property`);
5200
+ }
5201
+ if (typeof raw !== "string" && typeof raw !== "number" || typeof raw === "number" && !Number.isFinite(raw)) {
5202
+ throw new TypeError(`signup-control theme token "${name}" must be a finite string or number`);
5203
+ }
5204
+ const value = String(raw).trim();
5205
+ if (!value || value.length > MAX_VALUE_LENGTH || UNSAFE_VALUE.test(value)) {
5206
+ throw new TypeError(`signup-control theme token "${name}" has an unsafe or oversized value`);
5207
+ }
5208
+ serializedLength += name.length + value.length + 4;
5209
+ if (serializedLength > MAX_SERIALIZED_LENGTH) {
5210
+ throw new TypeError("signup-control theme exceeds the serialized size bound");
5211
+ }
5212
+ tokens[name] = value;
5213
+ }
5214
+ return tokens;
5215
+ }
5216
+ function cssFor(tokens) {
5217
+ const declarations = Object.entries(tokens).map(([name, value]) => ` ${name}: ${value};`);
5218
+ return declarations.length ? `:root {
5219
+ ${declarations.join("\n")}
5220
+ }` : "";
5221
+ }
5222
+ function stateFor(revision, options) {
5223
+ if (!revision) {
5224
+ return {
5225
+ css: cssFor(validatedTokens(options.bootstrapTokens)),
5226
+ metadata: { source: "bootstrap", revision: null, digest: null, cacheKey: "signup-control:bootstrap" }
5227
+ };
5228
+ }
5229
+ if (!Number.isSafeInteger(revision.revision) || revision.revision < 1 || !DIGEST.test(revision.digest)) {
5230
+ throw new TypeError("signup-control theme revision metadata is invalid");
5231
+ }
5232
+ return {
5233
+ css: cssFor(validatedTokens(revision.theme?.tokens)),
5234
+ metadata: {
5235
+ source: "active",
5236
+ revision: revision.revision,
5237
+ digest: revision.digest,
5238
+ cacheKey: `signup-control:${revision.digest}`
5239
+ }
5240
+ };
5241
+ }
5242
+ function attributes(metadata) {
5243
+ return [
5244
+ `id="${SIGNUP_CONTROL_THEME_STYLE_ID}"`,
5245
+ `data-signup-control-source="${metadata.source}"`,
5246
+ `data-signup-control-cache-key="${metadata.cacheKey}"`,
5247
+ ...metadata.revision === null ? [] : [`data-signup-control-revision="${metadata.revision}"`],
5248
+ ...metadata.digest === null ? [] : [`data-signup-control-digest="${metadata.digest}"`]
5249
+ ].join(" ");
5250
+ }
5251
+ function renderSignupControlThemeHead(revision, options = {}) {
5252
+ const { css, metadata } = stateFor(revision, options);
5253
+ return { css, metadata, head: `<style ${attributes(metadata)}>${css}</style>` };
5254
+ }
5255
+ function injectSignupControlThemeHead(html, rendered, marker = SIGNUP_CONTROL_THEME_MARKER) {
5256
+ const existing = /<style\b(?=[^>]*\bid\s*=\s*["']odla-signup-control-theme["'])[^>]*>[\s\S]*?<\/style>/gi;
5257
+ const existingBlocks = html.match(existing) ?? [];
5258
+ if (existingBlocks.length > 1) {
5259
+ throw new TypeError("signup-control theme style block must appear at most once");
5260
+ }
5261
+ if (existingBlocks.length === 1) return html.replace(existing, rendered.head);
5262
+ const markerIndex = html.indexOf(marker);
5263
+ if (markerIndex >= 0) {
5264
+ if (html.indexOf(marker, markerIndex + marker.length) >= 0) {
5265
+ throw new TypeError("signup-control theme marker must appear exactly once");
5266
+ }
5267
+ return `${html.slice(0, markerIndex)}${rendered.head}${html.slice(markerIndex + marker.length)}`;
5268
+ }
5269
+ const closingHead = html.search(/<\/head\s*>/i);
5270
+ if (closingHead < 0) throw new TypeError("signup-control theme requires an HTML head");
5271
+ return `${html.slice(0, closingHead)}${rendered.head}${html.slice(closingHead)}`;
5272
+ }
5273
+
5274
+ // src/signup-control-theme-worker.ts
5275
+ async function activeTheme(options) {
5276
+ const active = await loadActiveSignupControl(options.db, options.chapterId, options.environment);
5277
+ if (!active) return null;
5278
+ const checked = validateSignupControlDocument(active.document);
5279
+ if (!checked.ok) throw new TypeError(`active signup-control document is invalid: ${checked.errors.join("; ")}`);
5280
+ const document = checked.value;
5281
+ if (document.revision !== active.revision || document.target.chapterId !== options.chapterId || document.target.environment !== options.environment || active.digest !== await signupControlDigest(document)) {
5282
+ throw new TypeError("active signup-control revision metadata or digest is invalid");
5283
+ }
5284
+ return { revision: active.revision, digest: active.digest, theme: document.theme };
5285
+ }
5286
+ async function renderActiveSignupControlHtml(html, options) {
5287
+ const theme = renderSignupControlThemeHead(await activeTheme(options), {
5288
+ bootstrapTokens: options.bootstrapTokens
5289
+ });
5290
+ return { html: injectSignupControlThemeHead(html, theme, options.marker), theme };
5291
+ }
5292
+ async function contentEtag(html) {
5293
+ const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(html));
5294
+ const hex = Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
5295
+ return `W/"sha256-${hex}"`;
5296
+ }
5297
+ async function withActiveSignupControlTheme(response, options) {
5298
+ if (!response.ok || !(response.headers.get("content-type") ?? "").toLowerCase().includes("text/html")) {
5299
+ throw new TypeError("signup-control theme can only transform a successful HTML response");
5300
+ }
5301
+ const rendered = await renderActiveSignupControlHtml(await response.text(), options);
5302
+ const headers = new Headers(response.headers);
5303
+ headers.delete("content-length");
5304
+ headers.set("cache-control", "no-store");
5305
+ headers.set("etag", await contentEtag(rendered.html));
5306
+ headers.set("x-odla-signup-control-digest", rendered.theme.metadata.digest ?? "bootstrap");
5307
+ if (rendered.theme.metadata.revision === null) headers.delete("x-odla-signup-control-revision");
5308
+ else headers.set("x-odla-signup-control-revision", String(rendered.theme.metadata.revision));
5309
+ return new Response(rendered.html, { status: response.status, statusText: response.statusText, headers });
5310
+ }
5311
+
4080
5312
  // src/worker.ts
4081
5313
  var BUILTIN_ROUTES = [
4082
5314
  handleHealth,
@@ -4085,11 +5317,13 @@ var BUILTIN_ROUTES = [
4085
5317
  handleCrm,
4086
5318
  handleNetworkShared,
4087
5319
  handleSignupControl,
5320
+ handleAdmissionGrants,
4088
5321
  handleNetworkSnapshot,
4089
5322
  handleNetworkRecords,
4090
5323
  handleNetworkSharedNotes,
4091
5324
  handleFormation,
4092
5325
  handleMember,
5326
+ handleGifts,
4093
5327
  handleSchedule,
4094
5328
  handlePayments,
4095
5329
  handleAdminMeetings,
@@ -4099,6 +5333,7 @@ var BUILTIN_ROUTES = [
4099
5333
  handleAdminPeopleAccess,
4100
5334
  handleAdminPeopleRole,
4101
5335
  handleAdminCrmSync,
5336
+ handleAdminAdmissionGrants,
4102
5337
  handleAdminClerkPrivateProfileSync,
4103
5338
  // Aggregation
4104
5339
  handleAdminDashboard,