@odla-ai/chapter 0.32.2 → 0.33.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.
@@ -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,91 @@ 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 loadLatestSignupControl(db, targetId, environment) {
531
+ const rows2 = (await db.query({ signupControls: { $: {
532
+ where: { targetId, environment },
533
+ order: { revision: "desc" },
534
+ limit: 1
535
+ } } })).signupControls;
536
+ const latest = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
537
+ return asStored(latest);
538
+ }
539
+ async function loadActiveSignupControl(db, targetId, environment) {
540
+ const rows2 = (await db.query({ signupControls: { $: {
541
+ where: { targetId, environment, active: true },
542
+ order: { revision: "desc" },
543
+ limit: 1
544
+ } } })).signupControls;
545
+ const active = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
546
+ return asStored(active);
547
+ }
548
+ function controlledApplication(base, document) {
549
+ const delegated = document?.application;
550
+ return delegated ? { ...base, required: delegated.required, optional: delegated.optional, conditions: { ...delegated.conditions ?? {} } } : base;
551
+ }
552
+ function validateControlledApplication(chapter, document) {
553
+ if (!document.application) return [];
554
+ const declared = /* @__PURE__ */ new Set([...chapter.application.required, ...chapter.application.optional]);
555
+ 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`);
556
+ }
557
+ function groupAttrs(document) {
558
+ const first = [...document.tiers].filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder)[0];
559
+ const firstPaid = [...document.tiers].find((tier) => tier.available && tier.priceCents > 0);
560
+ return {
561
+ ...first ? { standardPriceCents: first.priceCents } : {},
562
+ foundingDiscountCents: 0,
563
+ stripePriceId: firstPaid?.stripePriceId ?? null,
564
+ ...document.policy ?? {},
565
+ ...document.scheduling ? { schedulingJson: document.scheduling } : {}
566
+ };
567
+ }
568
+ async function materializeSignupControl(db, envelope, previous, receivedAt) {
569
+ const { document, digest } = envelope;
570
+ const currentRows = (await db.query({ tiers: { $: {
571
+ where: { groupId: document.target.chapterId },
572
+ limit: 100
573
+ } } })).tiers ?? [];
574
+ const incoming = new Set(document.tiers.map((tier) => tier.id));
575
+ const ops = [];
576
+ if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
577
+ for (const row of currentRows) {
578
+ const id2 = String(row.id ?? "");
579
+ if (id2 && !incoming.has(id2)) ops.push({ t: "update", ns: "tiers", id: id2, attrs: { active: false } });
580
+ }
581
+ for (const tier of document.tiers) {
582
+ ops.push({ t: "update", ns: "tiers", id: tier.id, attrs: {
583
+ id: tier.id,
584
+ groupId: document.target.chapterId,
585
+ name: tier.name,
586
+ priceCents: tier.priceCents,
587
+ stripePriceId: tier.stripePriceId ?? null,
588
+ blurb: tier.blurb ?? null,
589
+ sortOrder: tier.sortOrder,
590
+ active: tier.available,
591
+ createdAt: receivedAt
592
+ } });
593
+ }
594
+ ops.push({ t: "update", ns: "groups", id: document.target.chapterId, attrs: groupAttrs(document) });
595
+ const id = `signup-control:${document.target.environment}:${document.revision}`;
596
+ ops.push({ t: "update", ns: "signupControls", id, attrs: {
597
+ id,
598
+ sourceId: document.source.appId,
599
+ targetId: document.target.chapterId,
600
+ environment: document.target.environment,
601
+ revision: document.revision,
602
+ digest,
603
+ document,
604
+ active: true,
605
+ receivedAt
606
+ } });
607
+ await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
608
+ }
609
+
474
610
  // src/session.ts
475
611
  function applicationSummary(app) {
476
612
  return {
@@ -813,10 +949,10 @@ function planDelivery(input) {
813
949
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
814
950
  const to = redirect ? input.group.debugEmail : input.to;
815
951
  const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
816
- const text = redirect ? `(dev redirect; original recipient: ${input.to})
952
+ const text2 = redirect ? `(dev redirect; original recipient: ${input.to})
817
953
 
818
954
  ` + render(tpl.text, vars) : render(tpl.text, vars);
819
- return { deliver: true, transport, to, subject, text, redirected: redirect };
955
+ return { deliver: true, transport, to, subject, text: text2, redirected: redirect };
820
956
  }
821
957
 
822
958
  // src/notify.ts
@@ -1105,9 +1241,18 @@ var handleMember = async (req, url, env, ctx) => {
1105
1241
  group,
1106
1242
  Boolean(stripeKey)
1107
1243
  );
1244
+ const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
1245
+ const application = controlledApplication(chapter.application, activeControl?.document);
1108
1246
  return json({
1109
- ...joinConfig(group, paymentsReady, joinConfigTiers(offered), chapter.application.conditions),
1110
- copy: chapter.copy.join
1247
+ ...joinConfig(group, paymentsReady, joinConfigTiers(offered), application.conditions),
1248
+ copy: { ...chapter.copy.join, ...activeControl?.document.application?.copy ?? {} },
1249
+ ...activeControl ? { signupControl: {
1250
+ revision: activeControl.revision,
1251
+ digest: activeControl.digest,
1252
+ membershipPage: activeControl.document.membershipPage,
1253
+ application: activeControl.document.application,
1254
+ theme: activeControl.document.theme
1255
+ } } : {}
1111
1256
  });
1112
1257
  }
1113
1258
  if (req.method === "GET" && url.pathname === "/api/join/resume") {
@@ -1170,8 +1315,10 @@ var handleMember = async (req, url, env, ctx) => {
1170
1315
  }
1171
1316
  const submissionId = typeof parsed.submissionId === "string" ? parsed.submissionId : void 0;
1172
1317
  const db = ctx.makeDb(env);
1318
+ const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
1319
+ const effectiveChapter = activeControl ? { ...chapter, application: controlledApplication(chapter.application, activeControl.document) } : chapter;
1173
1320
  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, {
1321
+ const result = await submitApplication(db, effectiveChapter, parsed, {
1175
1322
  submissionId,
1176
1323
  groupId: chapter.id,
1177
1324
  now: Date.now(),
@@ -1180,10 +1327,10 @@ var handleMember = async (req, url, env, ctx) => {
1180
1327
  });
1181
1328
  if (!result.ok) return json({ error: result.error }, 400);
1182
1329
  if (!result.duplicate) {
1183
- if (chapter.sends.adminNotification === "submit") {
1330
+ if (effectiveChapter.sends.adminNotification === "submit") {
1184
1331
  await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
1185
1332
  }
1186
- await provisionApplicant(db, chapter, result.id, parsed);
1333
+ await provisionApplicant(db, effectiveChapter, result.id, parsed);
1187
1334
  }
1188
1335
  return json({
1189
1336
  id: result.id,
@@ -1253,6 +1400,433 @@ var handleNetworkShared = async (req, url, env, ctx) => {
1253
1400
  }
1254
1401
  };
1255
1402
 
1403
+ // src/worker-routes-signup-control.ts
1404
+ var import_db3 = require("@odla-ai/db");
1405
+
1406
+ // src/signup-control-canonical.ts
1407
+ var encoder = new TextEncoder();
1408
+ function canonicalValue(value) {
1409
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
1410
+ return JSON.stringify(value);
1411
+ }
1412
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
1413
+ if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
1414
+ const record = value;
1415
+ const entries = Object.keys(record).sort().flatMap((key) => {
1416
+ const item = record[key];
1417
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
1418
+ });
1419
+ return `{${entries.join(",")}}`;
1420
+ }
1421
+ function canonicalSignupControl(value) {
1422
+ return canonicalValue(value);
1423
+ }
1424
+ async function signupControlDigest(document) {
1425
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
1426
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
1427
+ }
1428
+
1429
+ // src/signup-control-contract.ts
1430
+ var SIGNUP_CONTROL_PATH = "/api/signup-control";
1431
+
1432
+ // src/membership-page.ts
1433
+ var MEMBERSHIP_PAGE_RENDERER = "membership-cards-v1";
1434
+ var TIER_ID = /^[a-z][a-z0-9-]{0,79}$/;
1435
+ var VARIANTS = /* @__PURE__ */ new Set(["standard", "featured", "supporting"]);
1436
+ var ROOT_KEYS = /* @__PURE__ */ new Set(["schemaVersion", "renderer", "tiers", "footnoteMarkdown"]);
1437
+ var TIER_KEYS = /* @__PURE__ */ new Set([
1438
+ "tierId",
1439
+ "variant",
1440
+ "badge",
1441
+ "summaryMarkdown",
1442
+ "priceEyebrow",
1443
+ "compareAtPriceCents",
1444
+ "billingLabel",
1445
+ "priceNoteMarkdown",
1446
+ "capacity",
1447
+ "benefits",
1448
+ "callout",
1449
+ "ctaLabel"
1450
+ ]);
1451
+ var CALLOUT_KEYS = /* @__PURE__ */ new Set(["label", "bodyMarkdown"]);
1452
+ var CAPACITY_KEYS = /* @__PURE__ */ new Set(["total", "label"]);
1453
+ var MAX_TIERS = 20;
1454
+ var MAX_BENEFITS = 24;
1455
+ var MAX_MARKDOWN = 65536;
1456
+ var MAX_LABEL = 160;
1457
+ var objectRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1458
+ function rejectUnknownKeys(value, allowed, path, errors) {
1459
+ for (const key of Object.keys(value)) {
1460
+ if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
1461
+ }
1462
+ }
1463
+ function boundedString(value, path, errors, max, required = false) {
1464
+ if (value === void 0) {
1465
+ if (required) errors.push(`${path} is required`);
1466
+ return void 0;
1467
+ }
1468
+ if (typeof value !== "string") {
1469
+ errors.push(`${path} must be a string`);
1470
+ return void 0;
1471
+ }
1472
+ const text2 = value.trim();
1473
+ if (required && !text2) errors.push(`${path} is required`);
1474
+ if (text2.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
1475
+ return text2;
1476
+ }
1477
+ function validateMembershipPageDocument(input, options = {}) {
1478
+ const errors = [];
1479
+ const root = objectRecord(input);
1480
+ if (!root) return { ok: false, errors: ["document must be an object"] };
1481
+ rejectUnknownKeys(root, ROOT_KEYS, "document", errors);
1482
+ if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
1483
+ if (root.renderer !== MEMBERSHIP_PAGE_RENDERER) {
1484
+ errors.push(`document.renderer must be "${MEMBERSHIP_PAGE_RENDERER}"`);
1485
+ }
1486
+ if (!Array.isArray(root.tiers)) errors.push("document.tiers must be an array");
1487
+ const rawTiers = Array.isArray(root.tiers) ? root.tiers : [];
1488
+ if (Array.isArray(root.tiers) && rawTiers.length === 0) errors.push("document.tiers must include at least one tier");
1489
+ if (rawTiers.length > MAX_TIERS) errors.push(`document.tiers supports at most ${MAX_TIERS} entries`);
1490
+ const authority = new Map((options.tiers ?? []).map((tier) => [tier.id, tier]));
1491
+ const seen = /* @__PURE__ */ new Set();
1492
+ const tiers = [];
1493
+ for (const [index, raw] of rawTiers.slice(0, MAX_TIERS).entries()) {
1494
+ const path = `document.tiers[${index}]`;
1495
+ const item = objectRecord(raw);
1496
+ if (!item) {
1497
+ errors.push(`${path} must be an object`);
1498
+ continue;
1499
+ }
1500
+ rejectUnknownKeys(item, TIER_KEYS, path, errors);
1501
+ const tierId = boundedString(item.tierId, `${path}.tierId`, errors, 80, true) ?? "";
1502
+ if (tierId && !TIER_ID.test(tierId)) errors.push(`${path}.tierId must be a lowercase kebab-case id`);
1503
+ if (seen.has(tierId)) errors.push(`${path}.tierId duplicates "${tierId}"`);
1504
+ seen.add(tierId);
1505
+ if (authority.size && !authority.has(tierId)) errors.push(`${path}.tierId "${tierId}" is not an offered tier`);
1506
+ const variant = item.variant;
1507
+ if (variant !== void 0 && (typeof variant !== "string" || !VARIANTS.has(variant))) {
1508
+ errors.push(`${path}.variant must be standard, featured, or supporting`);
1509
+ }
1510
+ const compareAt = item.compareAtPriceCents;
1511
+ if (compareAt !== void 0 && (!Number.isInteger(compareAt) || Number(compareAt) < 0 || Number(compareAt) > 1e8)) {
1512
+ errors.push(`${path}.compareAtPriceCents must be an integer from 0 to 100,000,000`);
1513
+ }
1514
+ const actual = authority.get(tierId)?.priceCents;
1515
+ if (Number.isInteger(compareAt) && actual !== void 0 && Number(compareAt) <= actual) {
1516
+ errors.push(`${path}.compareAtPriceCents must be greater than the authoritative tier price`);
1517
+ }
1518
+ let benefits;
1519
+ if (item.benefits !== void 0) {
1520
+ if (!Array.isArray(item.benefits)) errors.push(`${path}.benefits must be an array`);
1521
+ else {
1522
+ if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
1523
+ benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
1524
+ const text2 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
1525
+ return text2 ? [text2] : [];
1526
+ });
1527
+ }
1528
+ }
1529
+ let callout;
1530
+ if (item.callout !== void 0) {
1531
+ const rawCallout = objectRecord(item.callout);
1532
+ if (!rawCallout) errors.push(`${path}.callout must be an object`);
1533
+ else {
1534
+ rejectUnknownKeys(rawCallout, CALLOUT_KEYS, `${path}.callout`, errors);
1535
+ const bodyMarkdown = boundedString(rawCallout.bodyMarkdown, `${path}.callout.bodyMarkdown`, errors, MAX_MARKDOWN, true);
1536
+ const label = boundedString(rawCallout.label, `${path}.callout.label`, errors, MAX_LABEL);
1537
+ if (bodyMarkdown) callout = { ...label ? { label } : {}, bodyMarkdown };
1538
+ }
1539
+ }
1540
+ let capacity;
1541
+ if (item.capacity !== void 0) {
1542
+ const rawCapacity = objectRecord(item.capacity);
1543
+ if (!rawCapacity) errors.push(`${path}.capacity must be an object`);
1544
+ else {
1545
+ rejectUnknownKeys(rawCapacity, CAPACITY_KEYS, `${path}.capacity`, errors);
1546
+ const total = rawCapacity.total;
1547
+ if (!Number.isInteger(total) || Number(total) < 1 || Number(total) > 1e6) {
1548
+ errors.push(`${path}.capacity.total must be an integer from 1 to 1,000,000`);
1549
+ }
1550
+ const label = boundedString(rawCapacity.label, `${path}.capacity.label`, errors, MAX_LABEL);
1551
+ if (Number.isInteger(total) && Number(total) >= 1 && Number(total) <= 1e6) {
1552
+ capacity = { total: Number(total), ...label ? { label } : {} };
1553
+ }
1554
+ }
1555
+ }
1556
+ const badge = boundedString(item.badge, `${path}.badge`, errors, MAX_LABEL);
1557
+ const summaryMarkdown = boundedString(item.summaryMarkdown, `${path}.summaryMarkdown`, errors, MAX_MARKDOWN);
1558
+ const priceEyebrow = boundedString(item.priceEyebrow, `${path}.priceEyebrow`, errors, MAX_LABEL);
1559
+ const billingLabel = boundedString(item.billingLabel, `${path}.billingLabel`, errors, MAX_LABEL);
1560
+ const priceNoteMarkdown = boundedString(item.priceNoteMarkdown, `${path}.priceNoteMarkdown`, errors, MAX_MARKDOWN);
1561
+ const ctaLabel = boundedString(item.ctaLabel, `${path}.ctaLabel`, errors, MAX_LABEL);
1562
+ tiers.push({
1563
+ tierId,
1564
+ ...typeof variant === "string" && VARIANTS.has(variant) ? { variant } : {},
1565
+ ...badge ? { badge } : {},
1566
+ ...summaryMarkdown ? { summaryMarkdown } : {},
1567
+ ...priceEyebrow ? { priceEyebrow } : {},
1568
+ ...Number.isInteger(compareAt) ? { compareAtPriceCents: Number(compareAt) } : {},
1569
+ ...billingLabel ? { billingLabel } : {},
1570
+ ...priceNoteMarkdown ? { priceNoteMarkdown } : {},
1571
+ ...capacity ? { capacity } : {},
1572
+ ...benefits ? { benefits } : {},
1573
+ ...callout ? { callout } : {},
1574
+ ...ctaLabel ? { ctaLabel } : {}
1575
+ });
1576
+ }
1577
+ if (authority.size) {
1578
+ for (const tierId of authority.keys()) {
1579
+ if (!seen.has(tierId)) errors.push(`document.tiers is missing offered tier "${tierId}"`);
1580
+ }
1581
+ }
1582
+ const footnoteMarkdown = boundedString(root.footnoteMarkdown, "document.footnoteMarkdown", errors, MAX_MARKDOWN);
1583
+ if (errors.length) return { ok: false, errors };
1584
+ return {
1585
+ ok: true,
1586
+ value: {
1587
+ schemaVersion: 1,
1588
+ renderer: MEMBERSHIP_PAGE_RENDERER,
1589
+ tiers,
1590
+ ...footnoteMarkdown ? { footnoteMarkdown } : {}
1591
+ }
1592
+ };
1593
+ }
1594
+
1595
+ // src/signup-control-validate.ts
1596
+ var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
1597
+ var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
1598
+ var TIER = /^[a-z][a-z0-9-]{0,79}$/;
1599
+ var PRICE = /^price_[A-Za-z0-9]{12,}$/;
1600
+ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
1601
+ "schemaVersion",
1602
+ "revision",
1603
+ "generatedAt",
1604
+ "source",
1605
+ "target",
1606
+ "tiers",
1607
+ "membershipPage",
1608
+ "application",
1609
+ "policy",
1610
+ "scheduling",
1611
+ "theme"
1612
+ ]);
1613
+ var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
1614
+ var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1615
+ function unknownKeys(value, allowed, path, errors) {
1616
+ for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
1617
+ }
1618
+ function text(value, path, errors, max = 65536) {
1619
+ if (typeof value !== "string" || !value.trim()) {
1620
+ errors.push(`${path} is required`);
1621
+ return "";
1622
+ }
1623
+ if (value.length > max) errors.push(`${path} must be at most ${max} characters`);
1624
+ return value.trim();
1625
+ }
1626
+ function tiersOf(value, errors) {
1627
+ if (!Array.isArray(value) || !value.length) {
1628
+ errors.push("document.tiers must be a non-empty array");
1629
+ return [];
1630
+ }
1631
+ if (value.length > 20) errors.push("document.tiers supports at most 20 entries");
1632
+ const seen = /* @__PURE__ */ new Set();
1633
+ return value.slice(0, 20).flatMap((raw, index) => {
1634
+ const path = `document.tiers[${index}]`;
1635
+ const item = recordOf(raw);
1636
+ if (!item) {
1637
+ errors.push(`${path} must be an object`);
1638
+ return [];
1639
+ }
1640
+ unknownKeys(item, TIER_KEYS2, path, errors);
1641
+ const id = text(item.id, `${path}.id`, errors, 80);
1642
+ if (id && !TIER.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
1643
+ if (seen.has(id)) errors.push(`${path}.id duplicates "${id}"`);
1644
+ seen.add(id);
1645
+ const name = text(item.name, `${path}.name`, errors, 160);
1646
+ if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
1647
+ if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
1648
+ if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
1649
+ errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
1650
+ }
1651
+ if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE.test(item.stripePriceId))) {
1652
+ errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
1653
+ }
1654
+ if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
1655
+ errors.push(`${path}.stripePriceId is required for an available paid tier`);
1656
+ }
1657
+ const blurb = item.blurb === void 0 ? void 0 : text(item.blurb, `${path}.blurb`, errors, 4e3);
1658
+ return [{
1659
+ id,
1660
+ name,
1661
+ available: item.available === true,
1662
+ sortOrder: Number(item.sortOrder) || 0,
1663
+ priceCents: Number(item.priceCents) || 0,
1664
+ ...typeof item.stripePriceId === "string" ? { stripePriceId: item.stripePriceId } : {},
1665
+ ...blurb ? { blurb } : {}
1666
+ }];
1667
+ });
1668
+ }
1669
+ function applicationOf(value, errors) {
1670
+ if (value === void 0) return void 0;
1671
+ const input = recordOf(value);
1672
+ if (!input) {
1673
+ errors.push("document.application must be an object");
1674
+ return void 0;
1675
+ }
1676
+ unknownKeys(input, /* @__PURE__ */ new Set(["required", "optional", "conditions", "copy"]), "document.application", errors);
1677
+ try {
1678
+ const resolved = resolveApplication(input);
1679
+ const copy = recordOf(input.copy);
1680
+ if (input.copy !== void 0 && (!copy || Object.values(copy).some((item) => typeof item !== "string"))) {
1681
+ errors.push("document.application.copy must contain only string values");
1682
+ }
1683
+ return {
1684
+ required: resolved.required,
1685
+ optional: resolved.optional,
1686
+ ...Object.keys(resolved.conditions).length ? { conditions: resolved.conditions } : {},
1687
+ ...copy ? { copy } : {}
1688
+ };
1689
+ } catch (error) {
1690
+ errors.push(error instanceof Error ? error.message : "document.application is invalid");
1691
+ return void 0;
1692
+ }
1693
+ }
1694
+ function policyOf(value, errors) {
1695
+ if (value === void 0) return void 0;
1696
+ const input = recordOf(value);
1697
+ if (!input) {
1698
+ errors.push("document.policy must be an object");
1699
+ return void 0;
1700
+ }
1701
+ const allowed = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
1702
+ unknownKeys(input, allowed, "document.policy", errors);
1703
+ const result = {};
1704
+ for (const key of allowed) {
1705
+ if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
1706
+ result[key] = text(input[key], `document.policy.${key}`, errors);
1707
+ }
1708
+ return result;
1709
+ }
1710
+ function themeOf(value, errors) {
1711
+ if (value === void 0) return void 0;
1712
+ const input = recordOf(value);
1713
+ const tokens = recordOf(input?.tokens);
1714
+ if (!input || !tokens) {
1715
+ errors.push("document.theme.tokens must be an object");
1716
+ return void 0;
1717
+ }
1718
+ unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
1719
+ if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
1720
+ for (const [key, token] of Object.entries(tokens)) {
1721
+ if (!/^--[a-z0-9-]{1,100}$/.test(key)) errors.push(`document.theme.tokens.${key} must be a CSS custom-property name`);
1722
+ if (typeof token !== "string" && typeof token !== "number" || String(token).length > 1e3) {
1723
+ errors.push(`document.theme.tokens.${key} must be a bounded string or number`);
1724
+ }
1725
+ }
1726
+ return { tokens };
1727
+ }
1728
+ function validateSignupControlDocument(input) {
1729
+ const errors = [];
1730
+ const root = recordOf(input);
1731
+ if (!root) return { ok: false, errors: ["document must be an object"] };
1732
+ unknownKeys(root, ROOT_KEYS2, "document", errors);
1733
+ if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
1734
+ if (!Number.isSafeInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
1735
+ if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
1736
+ const source = recordOf(root.source);
1737
+ const target = recordOf(root.target);
1738
+ const appId = text(source?.appId, "document.source.appId", errors, 63);
1739
+ const chapterId = text(target?.chapterId, "document.target.chapterId", errors, 63);
1740
+ const environment = text(target?.environment, "document.target.environment", errors, 63);
1741
+ if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
1742
+ if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
1743
+ if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
1744
+ if (target?.stripeMode !== "test" && target?.stripeMode !== "live") errors.push("document.target.stripeMode must be test or live");
1745
+ const tiers = tiersOf(root.tiers, errors);
1746
+ const available = tiers.filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder);
1747
+ const page = validateMembershipPageDocument(root.membershipPage, { tiers: available });
1748
+ if (!page.ok) errors.push(...page.errors);
1749
+ else if (page.value.tiers.some((tier, index) => tier.tierId !== available[index]?.id)) {
1750
+ errors.push("document.membershipPage.tiers must follow the available tier display order");
1751
+ }
1752
+ const application = applicationOf(root.application, errors);
1753
+ const policy = policyOf(root.policy, errors);
1754
+ let scheduling;
1755
+ if (root.scheduling !== void 0) {
1756
+ const checked = validateScheduling(root.scheduling);
1757
+ if (!checked.ok) errors.push(...Object.entries(checked.errors).map(([key, value]) => `document.scheduling.${key}: ${value}`));
1758
+ else scheduling = root.scheduling;
1759
+ }
1760
+ const theme = themeOf(root.theme, errors);
1761
+ if (errors.length || !page.ok) return { ok: false, errors };
1762
+ return { ok: true, value: {
1763
+ schemaVersion: 1,
1764
+ revision: Number(root.revision),
1765
+ generatedAt: Number(root.generatedAt),
1766
+ source: { appId },
1767
+ target: { chapterId, environment, stripeMode: target?.stripeMode },
1768
+ tiers,
1769
+ membershipPage: page.value,
1770
+ ...application ? { application } : {},
1771
+ ...policy ? { policy } : {},
1772
+ ...scheduling ? { scheduling } : {},
1773
+ ...theme ? { theme } : {}
1774
+ } };
1775
+ }
1776
+
1777
+ // src/worker-routes-signup-control.ts
1778
+ var reject = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
1779
+ var handleSignupControl = async (req, url, env, ctx) => {
1780
+ if (req.method !== "POST" || url.pathname !== SIGNUP_CONTROL_PATH) return null;
1781
+ const authority = ctx.chapter.signupControl;
1782
+ if (!authority) return json({ error: "not found" }, 404);
1783
+ const db = ctx.makeDb(env);
1784
+ const secret = await getVaultSecret(db, authority.secretName);
1785
+ if (!secret) return json({ error: "unauthorized" }, 401);
1786
+ const verified = await (0, import_db3.verifyFederatedRequest)(req, { secret, senders: [authority.sourceId] });
1787
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
1788
+ let input;
1789
+ try {
1790
+ input = JSON.parse(verified.body);
1791
+ } catch {
1792
+ return reject("invalid_document", 400, ["request body must be JSON"]);
1793
+ }
1794
+ const digest = typeof input.digest === "string" ? input.digest : "";
1795
+ const checked = validateSignupControlDocument(input.document);
1796
+ if (!checked.ok) return reject("invalid_document", 400, checked.errors);
1797
+ const document = checked.value;
1798
+ if (document.source.appId !== authority.sourceId) return reject("wrong_source", 403);
1799
+ if (document.target.chapterId !== ctx.chapter.id) return reject("wrong_target", 409);
1800
+ if (document.target.environment !== env.ODLA_ENV) return reject("wrong_environment", 409);
1801
+ if (document.target.stripeMode !== authority.stripeMode) return reject("wrong_stripe_mode", 409);
1802
+ const applicationErrors = validateControlledApplication(ctx.chapter, document);
1803
+ if (applicationErrors.length) return reject("invalid_document", 400, applicationErrors);
1804
+ if (!digest || await signupControlDigest(document) !== digest) return reject("invalid_digest", 400);
1805
+ const latest = await loadLatestSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
1806
+ if (latest && document.revision < latest.revision) return reject("stale_revision", 409);
1807
+ if (latest && document.revision === latest.revision) {
1808
+ if (latest.digest !== digest) return reject("conflicting_revision", 409);
1809
+ return json({
1810
+ ok: true,
1811
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
1812
+ revision: latest.revision,
1813
+ digest: latest.digest,
1814
+ status: "replayed",
1815
+ acknowledgedAt: latest.receivedAt
1816
+ });
1817
+ }
1818
+ const receivedAt = Date.now();
1819
+ await materializeSignupControl(db, { digest, document }, latest, receivedAt);
1820
+ return json({
1821
+ ok: true,
1822
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
1823
+ revision: document.revision,
1824
+ digest,
1825
+ status: "accepted",
1826
+ acknowledgedAt: receivedAt
1827
+ });
1828
+ };
1829
+
1256
1830
  // src/crm-sync.ts
1257
1831
  var import_crm4 = require("@odla-ai/crm");
1258
1832
  var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
@@ -2600,11 +3174,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
2600
3174
  for (const key of EMAIL_TEMPLATE_NAMES) {
2601
3175
  const t = templates[key];
2602
3176
  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);
3177
+ const text2 = typeof t?.text === "string" ? t.text : "";
3178
+ if (!subject || !text2.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
2605
3179
  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 };
3180
+ if (text2.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
3181
+ clean[key] = { subject, text: text2, enabled: t?.enabled !== false };
2608
3182
  }
2609
3183
  const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2610
3184
  const notificationEmail = str2(body.notificationEmail).trim();
@@ -2699,7 +3273,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
2699
3273
 
2700
3274
  // src/worker-routes-network.ts
2701
3275
  var import_crm6 = require("@odla-ai/crm");
2702
- var import_db4 = require("@odla-ai/db");
3276
+ var import_db5 = require("@odla-ai/db");
2703
3277
 
2704
3278
  // src/worker-network-fetch.ts
2705
3279
  function targetFetcher(env, target) {
@@ -2717,7 +3291,7 @@ function fetchNetworkTarget(env, target, destination, init) {
2717
3291
 
2718
3292
  // src/worker-network-push.ts
2719
3293
  var import_crm5 = require("@odla-ai/crm");
2720
- var import_db3 = require("@odla-ai/db");
3294
+ var import_db4 = require("@odla-ai/db");
2721
3295
  async function pushNetworkRecord(db, env, ctx, target, record) {
2722
3296
  const secret = await getVaultSecret(db, target.secretName);
2723
3297
  const crmDeps3 = {
@@ -2751,7 +3325,7 @@ async function pushNetworkRecord(db, env, ctx, target, record) {
2751
3325
  const payloadBody = JSON.stringify(payload);
2752
3326
  const destination = new URL("/api/network/shared", target.url);
2753
3327
  try {
2754
- const signed = await (0, import_db3.signFederatedRequest)({
3328
+ const signed = await (0, import_db4.signFederatedRequest)({
2755
3329
  secret,
2756
3330
  sender: ctx.chapter.id,
2757
3331
  method: "POST",
@@ -2836,7 +3410,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
2836
3410
  const db = ctx.makeDb(env);
2837
3411
  const secret = await getVaultSecret(db, "network_share_secret");
2838
3412
  if (!secret) return json({ error: "unauthorized" }, 401);
2839
- const verified = await (0, import_db4.verifyFederatedRequest)(req, { secret });
3413
+ const verified = await (0, import_db5.verifyFederatedRequest)(req, { secret });
2840
3414
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
2841
3415
  const types = await Promise.all(
2842
3416
  Object.keys(ctx.chapter.crm.config.types).map((type) => (0, import_crm6.typeSummary)({ crm: ctx.chapter.crm, db }, type))
@@ -2854,7 +3428,7 @@ async function snapshotOne(db, env, ctx, target) {
2854
3428
  if (!secret) return { id: target.id, name: target.name, url: target.url, available: false, error: "edge secret is missing" };
2855
3429
  const destination = new URL("/api/network/snapshot", target.url);
2856
3430
  try {
2857
- const headers = await (0, import_db4.signFederatedRequest)({
3431
+ const headers = await (0, import_db5.signFederatedRequest)({
2858
3432
  secret,
2859
3433
  sender: ctx.chapter.id,
2860
3434
  method: "GET",
@@ -2947,7 +3521,7 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
2947
3521
 
2948
3522
  // src/worker-routes-network-records.ts
2949
3523
  var import_crm7 = require("@odla-ai/crm");
2950
- var import_db5 = require("@odla-ai/db");
3524
+ var import_db6 = require("@odla-ai/db");
2951
3525
  var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
2952
3526
  var MAX_PAGE = 50;
2953
3527
  var MAX_OFFSET = 1e4;
@@ -2985,7 +3559,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
2985
3559
  const secret = await getVaultSecret(db, "network_share_secret");
2986
3560
  const readers = ctx.chapter.network.readers;
2987
3561
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
2988
- const verified = await (0, import_db5.verifyFederatedRequest)(req, {
3562
+ const verified = await (0, import_db6.verifyFederatedRequest)(req, {
2989
3563
  secret,
2990
3564
  senders: readers.map((reader2) => reader2.id)
2991
3565
  });
@@ -3030,7 +3604,7 @@ async function readTargetRecords(db, env, ctx, target, query) {
3030
3604
  const destination = new URL("/api/network/records", target.url);
3031
3605
  destination.search = query.toString();
3032
3606
  try {
3033
- const headers = await (0, import_db5.signFederatedRequest)({
3607
+ const headers = await (0, import_db6.signFederatedRequest)({
3034
3608
  secret,
3035
3609
  sender: ctx.chapter.id,
3036
3610
  method: "GET",
@@ -3165,7 +3739,7 @@ var handleAdminNetworkNotes = async (req, url, env, ctx) => {
3165
3739
 
3166
3740
  // src/worker-routes-network-shared-notes.ts
3167
3741
  var import_crm8 = require("@odla-ai/crm");
3168
- var import_db6 = require("@odla-ai/db");
3742
+ var import_db7 = require("@odla-ai/db");
3169
3743
  var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
3170
3744
  var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3171
3745
  var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
@@ -3224,7 +3798,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3224
3798
  const secret = await getVaultSecret(db, "network_share_secret");
3225
3799
  const readers = ctx.chapter.network.readers;
3226
3800
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
3227
- const verified = await (0, import_db6.verifyFederatedRequest)(req, {
3801
+ const verified = await (0, import_db7.verifyFederatedRequest)(req, {
3228
3802
  secret,
3229
3803
  senders: readers.map((reader2) => reader2.id)
3230
3804
  });
@@ -3321,7 +3895,7 @@ var handleAdminNetworkSharedNotes = async (req, url, env, ctx) => {
3321
3895
  const destination = new URL("/api/network/shared-notes", target.url);
3322
3896
  destination.search = new URLSearchParams({ type: recordType, recordId }).toString();
3323
3897
  try {
3324
- const headers = await (0, import_db6.signFederatedRequest)({
3898
+ const headers = await (0, import_db7.signFederatedRequest)({
3325
3899
  secret,
3326
3900
  sender: ctx.chapter.id,
3327
3901
  method: req.method,
@@ -3441,6 +4015,31 @@ var handleFormation = async (req, url, env, ctx) => {
3441
4015
  }
3442
4016
  };
3443
4017
 
4018
+ // src/signup-control-delivery.ts
4019
+ var import_db8 = require("@odla-ai/db");
4020
+ async function deliverSignupControl(options) {
4021
+ const destination = new URL(SIGNUP_CONTROL_PATH, options.targetUrl);
4022
+ const body = JSON.stringify(options.envelope);
4023
+ const signed = await (0, import_db8.signFederatedRequest)({
4024
+ secret: options.secret,
4025
+ sender: options.sender,
4026
+ method: "POST",
4027
+ url: destination,
4028
+ body
4029
+ });
4030
+ const response = await (options.fetch ?? globalThis.fetch)(destination, {
4031
+ method: "POST",
4032
+ headers: { ...signed, "content-type": "application/json" },
4033
+ body
4034
+ });
4035
+ const result = await response.json();
4036
+ if (!response.ok || (!("ok" in result) || result.ok !== true)) {
4037
+ const rejection = result;
4038
+ throw new Error(`signup control rejected (${response.status}): ${rejection.reason ?? rejection.error ?? "unknown"}`);
4039
+ }
4040
+ return result;
4041
+ }
4042
+
3444
4043
  // src/worker.ts
3445
4044
  var BUILTIN_ROUTES = [
3446
4045
  handleHealth,
@@ -3448,6 +4047,7 @@ var BUILTIN_ROUTES = [
3448
4047
  handleMe,
3449
4048
  handleCrm,
3450
4049
  handleNetworkShared,
4050
+ handleSignupControl,
3451
4051
  handleNetworkSnapshot,
3452
4052
  handleNetworkRecords,
3453
4053
  handleNetworkSharedNotes,