@odla-ai/chapter 0.32.3 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,7 +21,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var worker_exports = {};
22
22
  __export(worker_exports, {
23
23
  chapterWorker: () => chapterWorker,
24
- createWorkerContext: () => createWorkerContext
24
+ createWorkerContext: () => createWorkerContext,
25
+ deliverSignupControl: () => deliverSignupControl
25
26
  });
26
27
  module.exports = __toCommonJS(worker_exports);
27
28
 
@@ -195,6 +196,56 @@ async function applicationIdForSubmission(submissionId) {
195
196
  }
196
197
 
197
198
  // src/member.ts
199
+ var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
200
+ var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
201
+ var SCHEMA_FIELD = /^[A-Za-z_][A-Za-z0-9_-]*$/;
202
+ function resolveApplication(a) {
203
+ const required = a?.required ?? DEFAULT_REQUIRED;
204
+ const optional = a?.optional ?? DEFAULT_OPTIONAL;
205
+ for (const [name, arr] of [["required", required], ["optional", optional]]) {
206
+ if (!Array.isArray(arr) || !arr.every((f) => typeof f === "string" && f !== "")) {
207
+ throw new Error(`defineChapter.application.${name}: must be an array of field-name strings`);
208
+ }
209
+ const unsafe = arr.find((field) => !SCHEMA_FIELD.test(field));
210
+ if (unsafe) {
211
+ throw new Error(`defineChapter.application.${name}: field "${unsafe}" must match ${SCHEMA_FIELD.source}`);
212
+ }
213
+ }
214
+ const duplicate = [...required, ...optional].find((field, index, fields) => fields.indexOf(field) !== index);
215
+ if (duplicate) {
216
+ throw new Error(`defineChapter.application: duplicate field "${duplicate}" across required/optional lists`);
217
+ }
218
+ if (a?.profileFields !== void 0 && (!Array.isArray(a.profileFields) || !a.profileFields.every((f) => typeof f === "string" && f !== ""))) {
219
+ throw new Error("defineChapter.application.profileFields: must be an array of field-name strings");
220
+ }
221
+ if (a?.crmFields !== void 0 && (!Array.isArray(a.crmFields) || !a.crmFields.every((f) => typeof f === "string" && f !== ""))) {
222
+ throw new Error("defineChapter.application.crmFields: must be an array of field-name strings");
223
+ }
224
+ const conditions = a?.conditions ?? {};
225
+ for (const [field, declared] of Object.entries(conditions)) {
226
+ for (const key of ["visibleWhen", "requiredWhen"]) {
227
+ const expression = declared?.[key];
228
+ if (expression === void 0) continue;
229
+ if (typeof expression !== "string" || !expression.trim()) {
230
+ throw new Error(`defineChapter.application.conditions.${field}.${key}: must be a non-empty CEL condition`);
231
+ }
232
+ (0, import_crm.assertFieldCondition)(expression, `defineChapter.application.conditions.${field}.${key}`);
233
+ }
234
+ }
235
+ return {
236
+ required,
237
+ optional,
238
+ conditions,
239
+ maxLen: a?.maxLen ?? {},
240
+ defaultMaxLen: a?.defaultMaxLen ?? 2e3,
241
+ bodyCap: a?.bodyCap ?? 32768,
242
+ requireDisclaimerAck: a?.requireDisclaimerAck ?? true,
243
+ profileFields: a?.profileFields ?? [],
244
+ crmFields: a?.crmFields ?? [],
245
+ maxArrayLen: a?.maxArrayLen ?? 100,
246
+ validateEmail: a?.validateEmail ?? true
247
+ };
248
+ }
198
249
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
199
250
  function isValidEmail(value) {
200
251
  return typeof value === "string" && EMAIL_RE.test(value);
@@ -471,6 +522,101 @@ function canApprove(status, p) {
471
522
  return p.approvableFrom.includes(status);
472
523
  }
473
524
 
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;
529
+ }
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;
534
+ }
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));
537
+ }
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);
547
+ }
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);
557
+ }
558
+ function controlledApplication(base, document) {
559
+ const delegated = document?.application;
560
+ return delegated ? { ...base, required: delegated.required, optional: delegated.optional, conditions: { ...delegated.conditions ?? {} } } : base;
561
+ }
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`);
566
+ }
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);
570
+ return {
571
+ ...first ? { standardPriceCents: first.priceCents } : {},
572
+ foundingDiscountCents: 0,
573
+ stripePriceId: firstPaid?.stripePriceId ?? null,
574
+ ...document.policy ?? {},
575
+ ...document.scheduling ? { schedulingJson: document.scheduling } : {}
576
+ };
577
+ }
578
+ async function materializeSignupControl(db, envelope, previous, receivedAt) {
579
+ const { document, digest } = envelope;
580
+ const currentRows = (await db.query({ tiers: { $: {
581
+ where: { groupId: document.target.chapterId },
582
+ limit: 100
583
+ } } })).tiers ?? [];
584
+ const incoming = new Set(document.tiers.map((tier) => tier.id));
585
+ const ops = [];
586
+ if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
587
+ for (const row of currentRows) {
588
+ const id2 = String(row.id ?? "");
589
+ if (id2 && !incoming.has(id2)) ops.push({ t: "update", ns: "tiers", id: id2, attrs: { active: false } });
590
+ }
591
+ for (const tier of document.tiers) {
592
+ ops.push({ t: "update", ns: "tiers", id: tier.id, attrs: {
593
+ id: tier.id,
594
+ groupId: document.target.chapterId,
595
+ name: tier.name,
596
+ priceCents: tier.priceCents,
597
+ stripePriceId: tier.stripePriceId ?? null,
598
+ blurb: tier.blurb ?? null,
599
+ sortOrder: tier.sortOrder,
600
+ active: tier.available,
601
+ createdAt: receivedAt
602
+ } });
603
+ }
604
+ ops.push({ t: "update", ns: "groups", id: document.target.chapterId, attrs: groupAttrs(document) });
605
+ const id = `signup-control:${document.target.environment}:${document.revision}`;
606
+ ops.push({ t: "update", ns: "signupControls", id, attrs: {
607
+ id,
608
+ sourceId: document.source.appId,
609
+ targetId: document.target.chapterId,
610
+ environment: document.target.environment,
611
+ revision: document.revision,
612
+ digest,
613
+ document,
614
+ active: true,
615
+ receivedAt
616
+ } });
617
+ await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
618
+ }
619
+
474
620
  // src/session.ts
475
621
  function applicationSummary(app) {
476
622
  return {
@@ -813,10 +959,10 @@ function planDelivery(input) {
813
959
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
814
960
  const to = redirect ? input.group.debugEmail : input.to;
815
961
  const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
816
- const text = redirect ? `(dev redirect; original recipient: ${input.to})
962
+ const text2 = redirect ? `(dev redirect; original recipient: ${input.to})
817
963
 
818
964
  ` + render(tpl.text, vars) : render(tpl.text, vars);
819
- return { deliver: true, transport, to, subject, text, redirected: redirect };
965
+ return { deliver: true, transport, to, subject, text: text2, redirected: redirect };
820
966
  }
821
967
 
822
968
  // src/notify.ts
@@ -1105,9 +1251,18 @@ var handleMember = async (req, url, env, ctx) => {
1105
1251
  group,
1106
1252
  Boolean(stripeKey)
1107
1253
  );
1254
+ const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
1255
+ const application = controlledApplication(chapter.application, activeControl?.document);
1108
1256
  return json({
1109
- ...joinConfig(group, paymentsReady, joinConfigTiers(offered), chapter.application.conditions),
1110
- copy: chapter.copy.join
1257
+ ...joinConfig(group, paymentsReady, joinConfigTiers(offered), application.conditions),
1258
+ copy: { ...chapter.copy.join, ...activeControl?.document.application?.copy ?? {} },
1259
+ ...activeControl ? { signupControl: {
1260
+ revision: activeControl.revision,
1261
+ digest: activeControl.digest,
1262
+ membershipPage: activeControl.document.membershipPage,
1263
+ application: activeControl.document.application,
1264
+ theme: activeControl.document.theme
1265
+ } } : {}
1111
1266
  });
1112
1267
  }
1113
1268
  if (req.method === "GET" && url.pathname === "/api/join/resume") {
@@ -1170,8 +1325,10 @@ var handleMember = async (req, url, env, ctx) => {
1170
1325
  }
1171
1326
  const submissionId = typeof parsed.submissionId === "string" ? parsed.submissionId : void 0;
1172
1327
  const db = ctx.makeDb(env);
1328
+ const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
1329
+ const effectiveChapter = activeControl ? { ...chapter, application: controlledApplication(chapter.application, activeControl.document) } : chapter;
1173
1330
  const offeredTierIds = typeof parsed.tierId === "string" && parsed.tierId ? (await loadTiers(db, { id: chapter.id })).map((tier) => tier.id) : void 0;
1174
- const result = await submitApplication(db, chapter, parsed, {
1331
+ const result = await submitApplication(db, effectiveChapter, parsed, {
1175
1332
  submissionId,
1176
1333
  groupId: chapter.id,
1177
1334
  now: Date.now(),
@@ -1180,10 +1337,10 @@ var handleMember = async (req, url, env, ctx) => {
1180
1337
  });
1181
1338
  if (!result.ok) return json({ error: result.error }, 400);
1182
1339
  if (!result.duplicate) {
1183
- if (chapter.sends.adminNotification === "submit") {
1340
+ if (effectiveChapter.sends.adminNotification === "submit") {
1184
1341
  await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
1185
1342
  }
1186
- await provisionApplicant(db, chapter, result.id, parsed);
1343
+ await provisionApplicant(db, effectiveChapter, result.id, parsed);
1187
1344
  }
1188
1345
  return json({
1189
1346
  id: result.id,
@@ -1253,6 +1410,433 @@ var handleNetworkShared = async (req, url, env, ctx) => {
1253
1410
  }
1254
1411
  };
1255
1412
 
1413
+ // src/worker-routes-signup-control.ts
1414
+ var import_db3 = require("@odla-ai/db");
1415
+
1416
+ // src/signup-control-canonical.ts
1417
+ var encoder = new TextEncoder();
1418
+ function canonicalValue(value) {
1419
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
1420
+ return JSON.stringify(value);
1421
+ }
1422
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
1423
+ if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
1424
+ const record = value;
1425
+ const entries = Object.keys(record).sort().flatMap((key) => {
1426
+ const item = record[key];
1427
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
1428
+ });
1429
+ return `{${entries.join(",")}}`;
1430
+ }
1431
+ function canonicalSignupControl(value) {
1432
+ return canonicalValue(value);
1433
+ }
1434
+ async function signupControlDigest(document) {
1435
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
1436
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
1437
+ }
1438
+
1439
+ // src/signup-control-contract.ts
1440
+ var SIGNUP_CONTROL_PATH = "/api/signup-control";
1441
+
1442
+ // src/membership-page.ts
1443
+ var MEMBERSHIP_PAGE_RENDERER = "membership-cards-v1";
1444
+ var TIER_ID = /^[a-z][a-z0-9-]{0,79}$/;
1445
+ var VARIANTS = /* @__PURE__ */ new Set(["standard", "featured", "supporting"]);
1446
+ var ROOT_KEYS = /* @__PURE__ */ new Set(["schemaVersion", "renderer", "tiers", "footnoteMarkdown"]);
1447
+ var TIER_KEYS = /* @__PURE__ */ new Set([
1448
+ "tierId",
1449
+ "variant",
1450
+ "badge",
1451
+ "summaryMarkdown",
1452
+ "priceEyebrow",
1453
+ "compareAtPriceCents",
1454
+ "billingLabel",
1455
+ "priceNoteMarkdown",
1456
+ "capacity",
1457
+ "benefits",
1458
+ "callout",
1459
+ "ctaLabel"
1460
+ ]);
1461
+ var CALLOUT_KEYS = /* @__PURE__ */ new Set(["label", "bodyMarkdown"]);
1462
+ var CAPACITY_KEYS = /* @__PURE__ */ new Set(["total", "label"]);
1463
+ var MAX_TIERS = 20;
1464
+ var MAX_BENEFITS = 24;
1465
+ var MAX_MARKDOWN = 65536;
1466
+ var MAX_LABEL = 160;
1467
+ var objectRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1468
+ function rejectUnknownKeys(value, allowed, path, errors) {
1469
+ for (const key of Object.keys(value)) {
1470
+ if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
1471
+ }
1472
+ }
1473
+ function boundedString(value, path, errors, max, required = false) {
1474
+ if (value === void 0) {
1475
+ if (required) errors.push(`${path} is required`);
1476
+ return void 0;
1477
+ }
1478
+ if (typeof value !== "string") {
1479
+ errors.push(`${path} must be a string`);
1480
+ return void 0;
1481
+ }
1482
+ const text2 = value.trim();
1483
+ if (required && !text2) errors.push(`${path} is required`);
1484
+ if (text2.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
1485
+ return text2;
1486
+ }
1487
+ function validateMembershipPageDocument(input, options = {}) {
1488
+ const errors = [];
1489
+ const root = objectRecord(input);
1490
+ if (!root) return { ok: false, errors: ["document must be an object"] };
1491
+ rejectUnknownKeys(root, ROOT_KEYS, "document", errors);
1492
+ if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
1493
+ if (root.renderer !== MEMBERSHIP_PAGE_RENDERER) {
1494
+ errors.push(`document.renderer must be "${MEMBERSHIP_PAGE_RENDERER}"`);
1495
+ }
1496
+ if (!Array.isArray(root.tiers)) errors.push("document.tiers must be an array");
1497
+ const rawTiers = Array.isArray(root.tiers) ? root.tiers : [];
1498
+ if (Array.isArray(root.tiers) && rawTiers.length === 0) errors.push("document.tiers must include at least one tier");
1499
+ if (rawTiers.length > MAX_TIERS) errors.push(`document.tiers supports at most ${MAX_TIERS} entries`);
1500
+ const authority = new Map((options.tiers ?? []).map((tier) => [tier.id, tier]));
1501
+ const seen = /* @__PURE__ */ new Set();
1502
+ const tiers = [];
1503
+ for (const [index, raw] of rawTiers.slice(0, MAX_TIERS).entries()) {
1504
+ const path = `document.tiers[${index}]`;
1505
+ const item = objectRecord(raw);
1506
+ if (!item) {
1507
+ errors.push(`${path} must be an object`);
1508
+ continue;
1509
+ }
1510
+ rejectUnknownKeys(item, TIER_KEYS, path, errors);
1511
+ const tierId = boundedString(item.tierId, `${path}.tierId`, errors, 80, true) ?? "";
1512
+ if (tierId && !TIER_ID.test(tierId)) errors.push(`${path}.tierId must be a lowercase kebab-case id`);
1513
+ if (seen.has(tierId)) errors.push(`${path}.tierId duplicates "${tierId}"`);
1514
+ seen.add(tierId);
1515
+ if (authority.size && !authority.has(tierId)) errors.push(`${path}.tierId "${tierId}" is not an offered tier`);
1516
+ const variant = item.variant;
1517
+ if (variant !== void 0 && (typeof variant !== "string" || !VARIANTS.has(variant))) {
1518
+ errors.push(`${path}.variant must be standard, featured, or supporting`);
1519
+ }
1520
+ const compareAt = item.compareAtPriceCents;
1521
+ if (compareAt !== void 0 && (!Number.isInteger(compareAt) || Number(compareAt) < 0 || Number(compareAt) > 1e8)) {
1522
+ errors.push(`${path}.compareAtPriceCents must be an integer from 0 to 100,000,000`);
1523
+ }
1524
+ const actual = authority.get(tierId)?.priceCents;
1525
+ if (Number.isInteger(compareAt) && actual !== void 0 && Number(compareAt) <= actual) {
1526
+ errors.push(`${path}.compareAtPriceCents must be greater than the authoritative tier price`);
1527
+ }
1528
+ let benefits;
1529
+ if (item.benefits !== void 0) {
1530
+ if (!Array.isArray(item.benefits)) errors.push(`${path}.benefits must be an array`);
1531
+ else {
1532
+ if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
1533
+ benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
1534
+ const text2 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
1535
+ return text2 ? [text2] : [];
1536
+ });
1537
+ }
1538
+ }
1539
+ let callout;
1540
+ if (item.callout !== void 0) {
1541
+ const rawCallout = objectRecord(item.callout);
1542
+ if (!rawCallout) errors.push(`${path}.callout must be an object`);
1543
+ else {
1544
+ rejectUnknownKeys(rawCallout, CALLOUT_KEYS, `${path}.callout`, errors);
1545
+ const bodyMarkdown = boundedString(rawCallout.bodyMarkdown, `${path}.callout.bodyMarkdown`, errors, MAX_MARKDOWN, true);
1546
+ const label = boundedString(rawCallout.label, `${path}.callout.label`, errors, MAX_LABEL);
1547
+ if (bodyMarkdown) callout = { ...label ? { label } : {}, bodyMarkdown };
1548
+ }
1549
+ }
1550
+ let capacity;
1551
+ if (item.capacity !== void 0) {
1552
+ const rawCapacity = objectRecord(item.capacity);
1553
+ if (!rawCapacity) errors.push(`${path}.capacity must be an object`);
1554
+ else {
1555
+ rejectUnknownKeys(rawCapacity, CAPACITY_KEYS, `${path}.capacity`, errors);
1556
+ const total = rawCapacity.total;
1557
+ if (!Number.isInteger(total) || Number(total) < 1 || Number(total) > 1e6) {
1558
+ errors.push(`${path}.capacity.total must be an integer from 1 to 1,000,000`);
1559
+ }
1560
+ const label = boundedString(rawCapacity.label, `${path}.capacity.label`, errors, MAX_LABEL);
1561
+ if (Number.isInteger(total) && Number(total) >= 1 && Number(total) <= 1e6) {
1562
+ capacity = { total: Number(total), ...label ? { label } : {} };
1563
+ }
1564
+ }
1565
+ }
1566
+ const badge = boundedString(item.badge, `${path}.badge`, errors, MAX_LABEL);
1567
+ const summaryMarkdown = boundedString(item.summaryMarkdown, `${path}.summaryMarkdown`, errors, MAX_MARKDOWN);
1568
+ const priceEyebrow = boundedString(item.priceEyebrow, `${path}.priceEyebrow`, errors, MAX_LABEL);
1569
+ const billingLabel = boundedString(item.billingLabel, `${path}.billingLabel`, errors, MAX_LABEL);
1570
+ const priceNoteMarkdown = boundedString(item.priceNoteMarkdown, `${path}.priceNoteMarkdown`, errors, MAX_MARKDOWN);
1571
+ const ctaLabel = boundedString(item.ctaLabel, `${path}.ctaLabel`, errors, MAX_LABEL);
1572
+ tiers.push({
1573
+ tierId,
1574
+ ...typeof variant === "string" && VARIANTS.has(variant) ? { variant } : {},
1575
+ ...badge ? { badge } : {},
1576
+ ...summaryMarkdown ? { summaryMarkdown } : {},
1577
+ ...priceEyebrow ? { priceEyebrow } : {},
1578
+ ...Number.isInteger(compareAt) ? { compareAtPriceCents: Number(compareAt) } : {},
1579
+ ...billingLabel ? { billingLabel } : {},
1580
+ ...priceNoteMarkdown ? { priceNoteMarkdown } : {},
1581
+ ...capacity ? { capacity } : {},
1582
+ ...benefits ? { benefits } : {},
1583
+ ...callout ? { callout } : {},
1584
+ ...ctaLabel ? { ctaLabel } : {}
1585
+ });
1586
+ }
1587
+ if (authority.size) {
1588
+ for (const tierId of authority.keys()) {
1589
+ if (!seen.has(tierId)) errors.push(`document.tiers is missing offered tier "${tierId}"`);
1590
+ }
1591
+ }
1592
+ const footnoteMarkdown = boundedString(root.footnoteMarkdown, "document.footnoteMarkdown", errors, MAX_MARKDOWN);
1593
+ if (errors.length) return { ok: false, errors };
1594
+ return {
1595
+ ok: true,
1596
+ value: {
1597
+ schemaVersion: 1,
1598
+ renderer: MEMBERSHIP_PAGE_RENDERER,
1599
+ tiers,
1600
+ ...footnoteMarkdown ? { footnoteMarkdown } : {}
1601
+ }
1602
+ };
1603
+ }
1604
+
1605
+ // src/signup-control-validate.ts
1606
+ var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
1607
+ var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
1608
+ var TIER = /^[a-z][a-z0-9-]{0,79}$/;
1609
+ var PRICE = /^price_[A-Za-z0-9]{12,}$/;
1610
+ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
1611
+ "schemaVersion",
1612
+ "revision",
1613
+ "generatedAt",
1614
+ "source",
1615
+ "target",
1616
+ "tiers",
1617
+ "membershipPage",
1618
+ "application",
1619
+ "policy",
1620
+ "scheduling",
1621
+ "theme"
1622
+ ]);
1623
+ var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
1624
+ var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1625
+ function unknownKeys(value, allowed, path, errors) {
1626
+ for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
1627
+ }
1628
+ function text(value, path, errors, max = 65536) {
1629
+ if (typeof value !== "string" || !value.trim()) {
1630
+ errors.push(`${path} is required`);
1631
+ return "";
1632
+ }
1633
+ if (value.length > max) errors.push(`${path} must be at most ${max} characters`);
1634
+ return value.trim();
1635
+ }
1636
+ function tiersOf(value, errors) {
1637
+ if (!Array.isArray(value) || !value.length) {
1638
+ errors.push("document.tiers must be a non-empty array");
1639
+ return [];
1640
+ }
1641
+ if (value.length > 20) errors.push("document.tiers supports at most 20 entries");
1642
+ const seen = /* @__PURE__ */ new Set();
1643
+ return value.slice(0, 20).flatMap((raw, index) => {
1644
+ const path = `document.tiers[${index}]`;
1645
+ const item = recordOf(raw);
1646
+ if (!item) {
1647
+ errors.push(`${path} must be an object`);
1648
+ return [];
1649
+ }
1650
+ unknownKeys(item, TIER_KEYS2, path, errors);
1651
+ const id = text(item.id, `${path}.id`, errors, 80);
1652
+ if (id && !TIER.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
1653
+ if (seen.has(id)) errors.push(`${path}.id duplicates "${id}"`);
1654
+ seen.add(id);
1655
+ const name = text(item.name, `${path}.name`, errors, 160);
1656
+ if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
1657
+ if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
1658
+ if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
1659
+ errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
1660
+ }
1661
+ if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE.test(item.stripePriceId))) {
1662
+ errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
1663
+ }
1664
+ if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
1665
+ errors.push(`${path}.stripePriceId is required for an available paid tier`);
1666
+ }
1667
+ const blurb = item.blurb === void 0 ? void 0 : text(item.blurb, `${path}.blurb`, errors, 4e3);
1668
+ return [{
1669
+ id,
1670
+ name,
1671
+ available: item.available === true,
1672
+ sortOrder: Number(item.sortOrder) || 0,
1673
+ priceCents: Number(item.priceCents) || 0,
1674
+ ...typeof item.stripePriceId === "string" ? { stripePriceId: item.stripePriceId } : {},
1675
+ ...blurb ? { blurb } : {}
1676
+ }];
1677
+ });
1678
+ }
1679
+ function applicationOf(value, errors) {
1680
+ if (value === void 0) return void 0;
1681
+ const input = recordOf(value);
1682
+ if (!input) {
1683
+ errors.push("document.application must be an object");
1684
+ return void 0;
1685
+ }
1686
+ unknownKeys(input, /* @__PURE__ */ new Set(["required", "optional", "conditions", "copy"]), "document.application", errors);
1687
+ try {
1688
+ const resolved = resolveApplication(input);
1689
+ const copy = recordOf(input.copy);
1690
+ if (input.copy !== void 0 && (!copy || Object.values(copy).some((item) => typeof item !== "string"))) {
1691
+ errors.push("document.application.copy must contain only string values");
1692
+ }
1693
+ return {
1694
+ required: resolved.required,
1695
+ optional: resolved.optional,
1696
+ ...Object.keys(resolved.conditions).length ? { conditions: resolved.conditions } : {},
1697
+ ...copy ? { copy } : {}
1698
+ };
1699
+ } catch (error) {
1700
+ errors.push(error instanceof Error ? error.message : "document.application is invalid");
1701
+ return void 0;
1702
+ }
1703
+ }
1704
+ function policyOf(value, errors) {
1705
+ if (value === void 0) return void 0;
1706
+ const input = recordOf(value);
1707
+ if (!input) {
1708
+ errors.push("document.policy must be an object");
1709
+ return void 0;
1710
+ }
1711
+ const allowed = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
1712
+ unknownKeys(input, allowed, "document.policy", errors);
1713
+ const result = {};
1714
+ for (const key of allowed) {
1715
+ if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
1716
+ result[key] = text(input[key], `document.policy.${key}`, errors);
1717
+ }
1718
+ return result;
1719
+ }
1720
+ function themeOf(value, errors) {
1721
+ if (value === void 0) return void 0;
1722
+ const input = recordOf(value);
1723
+ const tokens = recordOf(input?.tokens);
1724
+ if (!input || !tokens) {
1725
+ errors.push("document.theme.tokens must be an object");
1726
+ return void 0;
1727
+ }
1728
+ unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
1729
+ if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
1730
+ for (const [key, token] of Object.entries(tokens)) {
1731
+ if (!/^--[a-z0-9-]{1,100}$/.test(key)) errors.push(`document.theme.tokens.${key} must be a CSS custom-property name`);
1732
+ if (typeof token !== "string" && typeof token !== "number" || String(token).length > 1e3) {
1733
+ errors.push(`document.theme.tokens.${key} must be a bounded string or number`);
1734
+ }
1735
+ }
1736
+ return { tokens };
1737
+ }
1738
+ function validateSignupControlDocument(input) {
1739
+ const errors = [];
1740
+ const root = recordOf(input);
1741
+ if (!root) return { ok: false, errors: ["document must be an object"] };
1742
+ unknownKeys(root, ROOT_KEYS2, "document", errors);
1743
+ if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
1744
+ if (!Number.isSafeInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
1745
+ if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
1746
+ const source = recordOf(root.source);
1747
+ const target = recordOf(root.target);
1748
+ const appId = text(source?.appId, "document.source.appId", errors, 63);
1749
+ const chapterId = text(target?.chapterId, "document.target.chapterId", errors, 63);
1750
+ const environment = text(target?.environment, "document.target.environment", errors, 63);
1751
+ if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
1752
+ if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
1753
+ if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
1754
+ if (target?.stripeMode !== "test" && target?.stripeMode !== "live") errors.push("document.target.stripeMode must be test or live");
1755
+ const tiers = tiersOf(root.tiers, errors);
1756
+ const available = tiers.filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder);
1757
+ const page = validateMembershipPageDocument(root.membershipPage, { tiers: available });
1758
+ if (!page.ok) errors.push(...page.errors);
1759
+ else if (page.value.tiers.some((tier, index) => tier.tierId !== available[index]?.id)) {
1760
+ errors.push("document.membershipPage.tiers must follow the available tier display order");
1761
+ }
1762
+ const application = applicationOf(root.application, errors);
1763
+ const policy = policyOf(root.policy, errors);
1764
+ let scheduling;
1765
+ if (root.scheduling !== void 0) {
1766
+ const checked = validateScheduling(root.scheduling);
1767
+ if (!checked.ok) errors.push(...Object.entries(checked.errors).map(([key, value]) => `document.scheduling.${key}: ${value}`));
1768
+ else scheduling = root.scheduling;
1769
+ }
1770
+ const theme = themeOf(root.theme, errors);
1771
+ if (errors.length || !page.ok) return { ok: false, errors };
1772
+ return { ok: true, value: {
1773
+ schemaVersion: 1,
1774
+ revision: Number(root.revision),
1775
+ generatedAt: Number(root.generatedAt),
1776
+ source: { appId },
1777
+ target: { chapterId, environment, stripeMode: target?.stripeMode },
1778
+ tiers,
1779
+ membershipPage: page.value,
1780
+ ...application ? { application } : {},
1781
+ ...policy ? { policy } : {},
1782
+ ...scheduling ? { scheduling } : {},
1783
+ ...theme ? { theme } : {}
1784
+ } };
1785
+ }
1786
+
1787
+ // src/worker-routes-signup-control.ts
1788
+ var reject = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
1789
+ var handleSignupControl = async (req, url, env, ctx) => {
1790
+ if (req.method !== "POST" || url.pathname !== SIGNUP_CONTROL_PATH) return null;
1791
+ const authority = ctx.chapter.signupControl;
1792
+ if (!authority) return json({ error: "not found" }, 404);
1793
+ const db = ctx.makeDb(env);
1794
+ const secret = await getVaultSecret(db, authority.secretName);
1795
+ if (!secret) return json({ error: "unauthorized" }, 401);
1796
+ const verified = await (0, import_db3.verifyFederatedRequest)(req, { secret, senders: [authority.sourceId] });
1797
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
1798
+ let input;
1799
+ try {
1800
+ input = JSON.parse(verified.body);
1801
+ } catch {
1802
+ return reject("invalid_document", 400, ["request body must be JSON"]);
1803
+ }
1804
+ const digest = typeof input.digest === "string" ? input.digest : "";
1805
+ const checked = validateSignupControlDocument(input.document);
1806
+ if (!checked.ok) return reject("invalid_document", 400, checked.errors);
1807
+ const document = checked.value;
1808
+ if (document.source.appId !== authority.sourceId) return reject("wrong_source", 403);
1809
+ if (document.target.chapterId !== ctx.chapter.id) return reject("wrong_target", 409);
1810
+ if (document.target.environment !== env.ODLA_ENV) return reject("wrong_environment", 409);
1811
+ if (document.target.stripeMode !== authority.stripeMode) return reject("wrong_stripe_mode", 409);
1812
+ const applicationErrors = validateControlledApplication(ctx.chapter, document);
1813
+ if (applicationErrors.length) return reject("invalid_document", 400, applicationErrors);
1814
+ if (!digest || await signupControlDigest(document) !== digest) return reject("invalid_digest", 400);
1815
+ const latest = await loadLatestSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
1816
+ if (latest && document.revision < latest.revision) return reject("stale_revision", 409);
1817
+ if (latest && document.revision === latest.revision) {
1818
+ if (latest.digest !== digest) return reject("conflicting_revision", 409);
1819
+ return json({
1820
+ ok: true,
1821
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
1822
+ revision: latest.revision,
1823
+ digest: latest.digest,
1824
+ status: "replayed",
1825
+ acknowledgedAt: latest.receivedAt
1826
+ });
1827
+ }
1828
+ const receivedAt = Date.now();
1829
+ await materializeSignupControl(db, { digest, document }, latest, receivedAt);
1830
+ return json({
1831
+ ok: true,
1832
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
1833
+ revision: document.revision,
1834
+ digest,
1835
+ status: "accepted",
1836
+ acknowledgedAt: receivedAt
1837
+ });
1838
+ };
1839
+
1256
1840
  // src/crm-sync.ts
1257
1841
  var import_crm4 = require("@odla-ai/crm");
1258
1842
  var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
@@ -2600,11 +3184,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
2600
3184
  for (const key of EMAIL_TEMPLATE_NAMES) {
2601
3185
  const t = templates[key];
2602
3186
  const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
2603
- const text = typeof t?.text === "string" ? t.text : "";
2604
- if (!subject || !text.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
3187
+ const text2 = typeof t?.text === "string" ? t.text : "";
3188
+ if (!subject || !text2.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
2605
3189
  if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key}" subject must be a single line under 200 characters` }, 400);
2606
- if (text.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
2607
- clean[key] = { subject, text, enabled: t?.enabled !== false };
3190
+ if (text2.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
3191
+ clean[key] = { subject, text: text2, enabled: t?.enabled !== false };
2608
3192
  }
2609
3193
  const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2610
3194
  const notificationEmail = str2(body.notificationEmail).trim();
@@ -2699,7 +3283,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
2699
3283
 
2700
3284
  // src/worker-routes-network.ts
2701
3285
  var import_crm6 = require("@odla-ai/crm");
2702
- var import_db4 = require("@odla-ai/db");
3286
+ var import_db5 = require("@odla-ai/db");
2703
3287
 
2704
3288
  // src/worker-network-fetch.ts
2705
3289
  function targetFetcher(env, target) {
@@ -2717,7 +3301,7 @@ function fetchNetworkTarget(env, target, destination, init) {
2717
3301
 
2718
3302
  // src/worker-network-push.ts
2719
3303
  var import_crm5 = require("@odla-ai/crm");
2720
- var import_db3 = require("@odla-ai/db");
3304
+ var import_db4 = require("@odla-ai/db");
2721
3305
  async function pushNetworkRecord(db, env, ctx, target, record) {
2722
3306
  const secret = await getVaultSecret(db, target.secretName);
2723
3307
  const crmDeps3 = {
@@ -2751,7 +3335,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
2751
3335
  const payloadBody = JSON.stringify(payload);
2752
3336
  const destination = new URL("/api/network/shared", target.url);
2753
3337
  try {
2754
- const signed = await (0, import_db3.signFederatedRequest)({
3338
+ const signed = await (0, import_db4.signFederatedRequest)({
2755
3339
  secret,
2756
3340
  sender: ctx.chapter.id,
2757
3341
  method: "POST",
@@ -2836,7 +3420,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
2836
3420
  const db = ctx.makeDb(env);
2837
3421
  const secret = await getVaultSecret(db, "network_share_secret");
2838
3422
  if (!secret) return json({ error: "unauthorized" }, 401);
2839
- const verified = await (0, import_db4.verifyFederatedRequest)(req, { secret });
3423
+ const verified = await (0, import_db5.verifyFederatedRequest)(req, { secret });
2840
3424
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
2841
3425
  const types = await Promise.all(
2842
3426
  Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm6.typeSummary)({ crm: ctx.chapter.crm, db }, type))
@@ -2854,7 +3438,7 @@ async function snapshotOne(db, env, ctx, target) {
2854
3438
  if (!secret) return { id: target.id, name: target.name, url: target.url, available: false, error: "edge secret is missing" };
2855
3439
  const destination = new URL("/api/network/snapshot", target.url);
2856
3440
  try {
2857
- const headers = await (0, import_db4.signFederatedRequest)({
3441
+ const headers = await (0, import_db5.signFederatedRequest)({
2858
3442
  secret,
2859
3443
  sender: ctx.chapter.id,
2860
3444
  method: "GET",
@@ -2947,7 +3531,7 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
2947
3531
 
2948
3532
  // src/worker-routes-network-records.ts
2949
3533
  var import_crm7 = require("@odla-ai/crm");
2950
- var import_db5 = require("@odla-ai/db");
3534
+ var import_db6 = require("@odla-ai/db");
2951
3535
  var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
2952
3536
  var MAX_PAGE = 50;
2953
3537
  var MAX_OFFSET = 1e4;
@@ -2985,7 +3569,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
2985
3569
  const secret = await getVaultSecret(db, "network_share_secret");
2986
3570
  const readers = ctx.chapter.network.readers;
2987
3571
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
2988
- const verified = await (0, import_db5.verifyFederatedRequest)(req, {
3572
+ const verified = await (0, import_db6.verifyFederatedRequest)(req, {
2989
3573
  secret,
2990
3574
  senders: readers.map((reader2) => reader2.id)
2991
3575
  });
@@ -3030,7 +3614,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
3030
3614
  const destination = new URL("/api/network/records", target.url);
3031
3615
  destination.search = query.toString();
3032
3616
  try {
3033
- const headers = await (0, import_db5.signFederatedRequest)({
3617
+ const headers = await (0, import_db6.signFederatedRequest)({
3034
3618
  secret,
3035
3619
  sender: ctx.chapter.id,
3036
3620
  method: "GET",
@@ -3165,7 +3749,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3165
3749
 
3166
3750
  // src/worker-routes-network-shared-notes.ts
3167
3751
  var import_crm8 = require("@odla-ai/crm");
3168
- var import_db6 = require("@odla-ai/db");
3752
+ var import_db7 = require("@odla-ai/db");
3169
3753
  var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
3170
3754
  var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3171
3755
  var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
@@ -3224,7 +3808,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3224
3808
  const secret = await getVaultSecret(db, "network_share_secret");
3225
3809
  const readers = ctx.chapter.network.readers;
3226
3810
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
3227
- const verified = await (0, import_db6.verifyFederatedRequest)(req, {
3811
+ const verified = await (0, import_db7.verifyFederatedRequest)(req, {
3228
3812
  secret,
3229
3813
  senders: readers.map((reader2) => reader2.id)
3230
3814
  });
@@ -3321,7 +3905,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
3321
3905
  const destination = new URL("/api/network/shared-notes", target.url);
3322
3906
  destination.search = new URLSearchParams({ type: recordType, recordId }).toString();
3323
3907
  try {
3324
- const headers = await (0, import_db6.signFederatedRequest)({
3908
+ const headers = await (0, import_db7.signFederatedRequest)({
3325
3909
  secret,
3326
3910
  sender: ctx.chapter.id,
3327
3911
  method: req.method,
@@ -3441,6 +4025,31 @@ var handleFormation = async (req, url, env, ctx) => {
3441
4025
  }
3442
4026
  };
3443
4027
 
4028
+ // src/signup-control-delivery.ts
4029
+ var import_db8 = require("@odla-ai/db");
4030
+ async function deliverSignupControl(options) {
4031
+ const destination = new URL(SIGNUP_CONTROL_PATH, options.targetUrl);
4032
+ const body = JSON.stringify(options.envelope);
4033
+ const signed = await (0, import_db8.signFederatedRequest)({
4034
+ secret: options.secret,
4035
+ sender: options.sender,
4036
+ method: "POST",
4037
+ url: destination,
4038
+ body
4039
+ });
4040
+ const response = await (options.fetch ?? globalThis.fetch)(destination, {
4041
+ method: "POST",
4042
+ headers: { ...signed, "content-type": "application/json" },
4043
+ body
4044
+ });
4045
+ const result = await response.json();
4046
+ if (!response.ok || (!("ok" in result) || result.ok !== true)) {
4047
+ const rejection = result;
4048
+ throw new Error(`signup control rejected (${response.status}): ${rejection.reason ?? rejection.error ?? "unknown"}`);
4049
+ }
4050
+ return result;
4051
+ }
4052
+
3444
4053
  // src/worker.ts
3445
4054
  var BUILTIN_ROUTES = [
3446
4055
  handleHealth,
@@ -3448,6 +4057,7 @@ var BUILTIN_ROUTES = [
3448
4057
  handleMe,
3449
4058
  handleCrm,
3450
4059
  handleNetworkShared,
4060
+ handleSignupControl,
3451
4061
  handleNetworkSnapshot,
3452
4062
  handleNetworkRecords,
3453
4063
  handleNetworkSharedNotes,