@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.
@@ -168,6 +168,56 @@ async function applicationIdForSubmission(submissionId) {
168
168
  }
169
169
 
170
170
  // src/member.ts
171
+ var DEFAULT_REQUIRED = ["firstName", "lastName", "email", "referral", "whoYouAre", "message"];
172
+ var DEFAULT_OPTIONAL = ["referralName", "linkedin", "phone", "state"];
173
+ var SCHEMA_FIELD = /^[A-Za-z_][A-Za-z0-9_-]*$/;
174
+ function resolveApplication(a) {
175
+ const required = a?.required ?? DEFAULT_REQUIRED;
176
+ const optional = a?.optional ?? DEFAULT_OPTIONAL;
177
+ for (const [name, arr] of [["required", required], ["optional", optional]]) {
178
+ if (!Array.isArray(arr) || !arr.every((f) => typeof f === "string" && f !== "")) {
179
+ throw new Error(`defineChapter.application.${name}: must be an array of field-name strings`);
180
+ }
181
+ const unsafe = arr.find((field) => !SCHEMA_FIELD.test(field));
182
+ if (unsafe) {
183
+ throw new Error(`defineChapter.application.${name}: field "${unsafe}" must match ${SCHEMA_FIELD.source}`);
184
+ }
185
+ }
186
+ const duplicate = [...required, ...optional].find((field, index, fields) => fields.indexOf(field) !== index);
187
+ if (duplicate) {
188
+ throw new Error(`defineChapter.application: duplicate field "${duplicate}" across required/optional lists`);
189
+ }
190
+ if (a?.profileFields !== void 0 && (!Array.isArray(a.profileFields) || !a.profileFields.every((f) => typeof f === "string" && f !== ""))) {
191
+ throw new Error("defineChapter.application.profileFields: must be an array of field-name strings");
192
+ }
193
+ if (a?.crmFields !== void 0 && (!Array.isArray(a.crmFields) || !a.crmFields.every((f) => typeof f === "string" && f !== ""))) {
194
+ throw new Error("defineChapter.application.crmFields: must be an array of field-name strings");
195
+ }
196
+ const conditions = a?.conditions ?? {};
197
+ for (const [field, declared] of Object.entries(conditions)) {
198
+ for (const key of ["visibleWhen", "requiredWhen"]) {
199
+ const expression = declared?.[key];
200
+ if (expression === void 0) continue;
201
+ if (typeof expression !== "string" || !expression.trim()) {
202
+ throw new Error(`defineChapter.application.conditions.${field}.${key}: must be a non-empty CEL condition`);
203
+ }
204
+ assertFieldCondition(expression, `defineChapter.application.conditions.${field}.${key}`);
205
+ }
206
+ }
207
+ return {
208
+ required,
209
+ optional,
210
+ conditions,
211
+ maxLen: a?.maxLen ?? {},
212
+ defaultMaxLen: a?.defaultMaxLen ?? 2e3,
213
+ bodyCap: a?.bodyCap ?? 32768,
214
+ requireDisclaimerAck: a?.requireDisclaimerAck ?? true,
215
+ profileFields: a?.profileFields ?? [],
216
+ crmFields: a?.crmFields ?? [],
217
+ maxArrayLen: a?.maxArrayLen ?? 100,
218
+ validateEmail: a?.validateEmail ?? true
219
+ };
220
+ }
171
221
  var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
172
222
  function isValidEmail(value) {
173
223
  return typeof value === "string" && EMAIL_RE.test(value);
@@ -444,6 +494,91 @@ function canApprove(status, p) {
444
494
  return p.approvableFrom.includes(status);
445
495
  }
446
496
 
497
+ // src/signup-control-store.ts
498
+ function asStored(row) {
499
+ if (!row || typeof row.document !== "object" || row.document === null) return null;
500
+ return row;
501
+ }
502
+ async function loadLatestSignupControl(db, targetId, environment) {
503
+ const rows2 = (await db.query({ signupControls: { $: {
504
+ where: { targetId, environment },
505
+ order: { revision: "desc" },
506
+ limit: 1
507
+ } } })).signupControls;
508
+ const latest = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
509
+ return asStored(latest);
510
+ }
511
+ async function loadActiveSignupControl(db, targetId, environment) {
512
+ const rows2 = (await db.query({ signupControls: { $: {
513
+ where: { targetId, environment, active: true },
514
+ order: { revision: "desc" },
515
+ limit: 1
516
+ } } })).signupControls;
517
+ const active = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
518
+ return asStored(active);
519
+ }
520
+ function controlledApplication(base, document) {
521
+ const delegated = document?.application;
522
+ return delegated ? { ...base, required: delegated.required, optional: delegated.optional, conditions: { ...delegated.conditions ?? {} } } : base;
523
+ }
524
+ function validateControlledApplication(chapter, document) {
525
+ if (!document.application) return [];
526
+ const declared = /* @__PURE__ */ new Set([...chapter.application.required, ...chapter.application.optional]);
527
+ 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`);
528
+ }
529
+ function groupAttrs(document) {
530
+ const first = [...document.tiers].filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder)[0];
531
+ const firstPaid = [...document.tiers].find((tier) => tier.available && tier.priceCents > 0);
532
+ return {
533
+ ...first ? { standardPriceCents: first.priceCents } : {},
534
+ foundingDiscountCents: 0,
535
+ stripePriceId: firstPaid?.stripePriceId ?? null,
536
+ ...document.policy ?? {},
537
+ ...document.scheduling ? { schedulingJson: document.scheduling } : {}
538
+ };
539
+ }
540
+ async function materializeSignupControl(db, envelope, previous, receivedAt) {
541
+ const { document, digest } = envelope;
542
+ const currentRows = (await db.query({ tiers: { $: {
543
+ where: { groupId: document.target.chapterId },
544
+ limit: 100
545
+ } } })).tiers ?? [];
546
+ const incoming = new Set(document.tiers.map((tier) => tier.id));
547
+ const ops = [];
548
+ if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
549
+ for (const row of currentRows) {
550
+ const id2 = String(row.id ?? "");
551
+ if (id2 && !incoming.has(id2)) ops.push({ t: "update", ns: "tiers", id: id2, attrs: { active: false } });
552
+ }
553
+ for (const tier of document.tiers) {
554
+ ops.push({ t: "update", ns: "tiers", id: tier.id, attrs: {
555
+ id: tier.id,
556
+ groupId: document.target.chapterId,
557
+ name: tier.name,
558
+ priceCents: tier.priceCents,
559
+ stripePriceId: tier.stripePriceId ?? null,
560
+ blurb: tier.blurb ?? null,
561
+ sortOrder: tier.sortOrder,
562
+ active: tier.available,
563
+ createdAt: receivedAt
564
+ } });
565
+ }
566
+ ops.push({ t: "update", ns: "groups", id: document.target.chapterId, attrs: groupAttrs(document) });
567
+ const id = `signup-control:${document.target.environment}:${document.revision}`;
568
+ ops.push({ t: "update", ns: "signupControls", id, attrs: {
569
+ id,
570
+ sourceId: document.source.appId,
571
+ targetId: document.target.chapterId,
572
+ environment: document.target.environment,
573
+ revision: document.revision,
574
+ digest,
575
+ document,
576
+ active: true,
577
+ receivedAt
578
+ } });
579
+ await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
580
+ }
581
+
447
582
  // src/session.ts
448
583
  function applicationSummary(app) {
449
584
  return {
@@ -791,10 +926,10 @@ function planDelivery(input) {
791
926
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
792
927
  const to = redirect ? input.group.debugEmail : input.to;
793
928
  const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
794
- const text = redirect ? `(dev redirect; original recipient: ${input.to})
929
+ const text2 = redirect ? `(dev redirect; original recipient: ${input.to})
795
930
 
796
931
  ` + render(tpl.text, vars) : render(tpl.text, vars);
797
- return { deliver: true, transport, to, subject, text, redirected: redirect };
932
+ return { deliver: true, transport, to, subject, text: text2, redirected: redirect };
798
933
  }
799
934
 
800
935
  // src/notify.ts
@@ -1083,9 +1218,18 @@ var handleMember = async (req, url, env, ctx) => {
1083
1218
  group,
1084
1219
  Boolean(stripeKey)
1085
1220
  );
1221
+ const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
1222
+ const application = controlledApplication(chapter.application, activeControl?.document);
1086
1223
  return json({
1087
- ...joinConfig(group, paymentsReady, joinConfigTiers(offered), chapter.application.conditions),
1088
- copy: chapter.copy.join
1224
+ ...joinConfig(group, paymentsReady, joinConfigTiers(offered), application.conditions),
1225
+ copy: { ...chapter.copy.join, ...activeControl?.document.application?.copy ?? {} },
1226
+ ...activeControl ? { signupControl: {
1227
+ revision: activeControl.revision,
1228
+ digest: activeControl.digest,
1229
+ membershipPage: activeControl.document.membershipPage,
1230
+ application: activeControl.document.application,
1231
+ theme: activeControl.document.theme
1232
+ } } : {}
1089
1233
  });
1090
1234
  }
1091
1235
  if (req.method === "GET" && url.pathname === "/api/join/resume") {
@@ -1148,8 +1292,10 @@ var handleMember = async (req, url, env, ctx) => {
1148
1292
  }
1149
1293
  const submissionId = typeof parsed.submissionId === "string" ? parsed.submissionId : void 0;
1150
1294
  const db = ctx.makeDb(env);
1295
+ const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
1296
+ const effectiveChapter = activeControl ? { ...chapter, application: controlledApplication(chapter.application, activeControl.document) } : chapter;
1151
1297
  const offeredTierIds = typeof parsed.tierId === "string" && parsed.tierId ? (await loadTiers(db, { id: chapter.id })).map((tier) => tier.id) : void 0;
1152
- const result = await submitApplication(db, chapter, parsed, {
1298
+ const result = await submitApplication(db, effectiveChapter, parsed, {
1153
1299
  submissionId,
1154
1300
  groupId: chapter.id,
1155
1301
  now: Date.now(),
@@ -1158,10 +1304,10 @@ var handleMember = async (req, url, env, ctx) => {
1158
1304
  });
1159
1305
  if (!result.ok) return json({ error: result.error }, 400);
1160
1306
  if (!result.duplicate) {
1161
- if (chapter.sends.adminNotification === "submit") {
1307
+ if (effectiveChapter.sends.adminNotification === "submit") {
1162
1308
  await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
1163
1309
  }
1164
- await provisionApplicant(db, chapter, result.id, parsed);
1310
+ await provisionApplicant(db, effectiveChapter, result.id, parsed);
1165
1311
  }
1166
1312
  return json({
1167
1313
  id: result.id,
@@ -1231,6 +1377,433 @@ var handleNetworkShared = async (req, url, env, ctx) => {
1231
1377
  }
1232
1378
  };
1233
1379
 
1380
+ // src/worker-routes-signup-control.ts
1381
+ import { verifyFederatedRequest as verifyFederatedRequest2 } from "@odla-ai/db";
1382
+
1383
+ // src/signup-control-canonical.ts
1384
+ var encoder = new TextEncoder();
1385
+ function canonicalValue(value) {
1386
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
1387
+ return JSON.stringify(value);
1388
+ }
1389
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
1390
+ if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
1391
+ const record = value;
1392
+ const entries = Object.keys(record).sort().flatMap((key) => {
1393
+ const item = record[key];
1394
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
1395
+ });
1396
+ return `{${entries.join(",")}}`;
1397
+ }
1398
+ function canonicalSignupControl(value) {
1399
+ return canonicalValue(value);
1400
+ }
1401
+ async function signupControlDigest(document) {
1402
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
1403
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
1404
+ }
1405
+
1406
+ // src/signup-control-contract.ts
1407
+ var SIGNUP_CONTROL_PATH = "/api/signup-control";
1408
+
1409
+ // src/membership-page.ts
1410
+ var MEMBERSHIP_PAGE_RENDERER = "membership-cards-v1";
1411
+ var TIER_ID = /^[a-z][a-z0-9-]{0,79}$/;
1412
+ var VARIANTS = /* @__PURE__ */ new Set(["standard", "featured", "supporting"]);
1413
+ var ROOT_KEYS = /* @__PURE__ */ new Set(["schemaVersion", "renderer", "tiers", "footnoteMarkdown"]);
1414
+ var TIER_KEYS = /* @__PURE__ */ new Set([
1415
+ "tierId",
1416
+ "variant",
1417
+ "badge",
1418
+ "summaryMarkdown",
1419
+ "priceEyebrow",
1420
+ "compareAtPriceCents",
1421
+ "billingLabel",
1422
+ "priceNoteMarkdown",
1423
+ "capacity",
1424
+ "benefits",
1425
+ "callout",
1426
+ "ctaLabel"
1427
+ ]);
1428
+ var CALLOUT_KEYS = /* @__PURE__ */ new Set(["label", "bodyMarkdown"]);
1429
+ var CAPACITY_KEYS = /* @__PURE__ */ new Set(["total", "label"]);
1430
+ var MAX_TIERS = 20;
1431
+ var MAX_BENEFITS = 24;
1432
+ var MAX_MARKDOWN = 65536;
1433
+ var MAX_LABEL = 160;
1434
+ var objectRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1435
+ function rejectUnknownKeys(value, allowed, path, errors) {
1436
+ for (const key of Object.keys(value)) {
1437
+ if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
1438
+ }
1439
+ }
1440
+ function boundedString(value, path, errors, max, required = false) {
1441
+ if (value === void 0) {
1442
+ if (required) errors.push(`${path} is required`);
1443
+ return void 0;
1444
+ }
1445
+ if (typeof value !== "string") {
1446
+ errors.push(`${path} must be a string`);
1447
+ return void 0;
1448
+ }
1449
+ const text2 = value.trim();
1450
+ if (required && !text2) errors.push(`${path} is required`);
1451
+ if (text2.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
1452
+ return text2;
1453
+ }
1454
+ function validateMembershipPageDocument(input, options = {}) {
1455
+ const errors = [];
1456
+ const root = objectRecord(input);
1457
+ if (!root) return { ok: false, errors: ["document must be an object"] };
1458
+ rejectUnknownKeys(root, ROOT_KEYS, "document", errors);
1459
+ if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
1460
+ if (root.renderer !== MEMBERSHIP_PAGE_RENDERER) {
1461
+ errors.push(`document.renderer must be "${MEMBERSHIP_PAGE_RENDERER}"`);
1462
+ }
1463
+ if (!Array.isArray(root.tiers)) errors.push("document.tiers must be an array");
1464
+ const rawTiers = Array.isArray(root.tiers) ? root.tiers : [];
1465
+ if (Array.isArray(root.tiers) && rawTiers.length === 0) errors.push("document.tiers must include at least one tier");
1466
+ if (rawTiers.length > MAX_TIERS) errors.push(`document.tiers supports at most ${MAX_TIERS} entries`);
1467
+ const authority = new Map((options.tiers ?? []).map((tier) => [tier.id, tier]));
1468
+ const seen = /* @__PURE__ */ new Set();
1469
+ const tiers = [];
1470
+ for (const [index, raw] of rawTiers.slice(0, MAX_TIERS).entries()) {
1471
+ const path = `document.tiers[${index}]`;
1472
+ const item = objectRecord(raw);
1473
+ if (!item) {
1474
+ errors.push(`${path} must be an object`);
1475
+ continue;
1476
+ }
1477
+ rejectUnknownKeys(item, TIER_KEYS, path, errors);
1478
+ const tierId = boundedString(item.tierId, `${path}.tierId`, errors, 80, true) ?? "";
1479
+ if (tierId && !TIER_ID.test(tierId)) errors.push(`${path}.tierId must be a lowercase kebab-case id`);
1480
+ if (seen.has(tierId)) errors.push(`${path}.tierId duplicates "${tierId}"`);
1481
+ seen.add(tierId);
1482
+ if (authority.size && !authority.has(tierId)) errors.push(`${path}.tierId "${tierId}" is not an offered tier`);
1483
+ const variant = item.variant;
1484
+ if (variant !== void 0 && (typeof variant !== "string" || !VARIANTS.has(variant))) {
1485
+ errors.push(`${path}.variant must be standard, featured, or supporting`);
1486
+ }
1487
+ const compareAt = item.compareAtPriceCents;
1488
+ if (compareAt !== void 0 && (!Number.isInteger(compareAt) || Number(compareAt) < 0 || Number(compareAt) > 1e8)) {
1489
+ errors.push(`${path}.compareAtPriceCents must be an integer from 0 to 100,000,000`);
1490
+ }
1491
+ const actual = authority.get(tierId)?.priceCents;
1492
+ if (Number.isInteger(compareAt) && actual !== void 0 && Number(compareAt) <= actual) {
1493
+ errors.push(`${path}.compareAtPriceCents must be greater than the authoritative tier price`);
1494
+ }
1495
+ let benefits;
1496
+ if (item.benefits !== void 0) {
1497
+ if (!Array.isArray(item.benefits)) errors.push(`${path}.benefits must be an array`);
1498
+ else {
1499
+ if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
1500
+ benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
1501
+ const text2 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
1502
+ return text2 ? [text2] : [];
1503
+ });
1504
+ }
1505
+ }
1506
+ let callout;
1507
+ if (item.callout !== void 0) {
1508
+ const rawCallout = objectRecord(item.callout);
1509
+ if (!rawCallout) errors.push(`${path}.callout must be an object`);
1510
+ else {
1511
+ rejectUnknownKeys(rawCallout, CALLOUT_KEYS, `${path}.callout`, errors);
1512
+ const bodyMarkdown = boundedString(rawCallout.bodyMarkdown, `${path}.callout.bodyMarkdown`, errors, MAX_MARKDOWN, true);
1513
+ const label = boundedString(rawCallout.label, `${path}.callout.label`, errors, MAX_LABEL);
1514
+ if (bodyMarkdown) callout = { ...label ? { label } : {}, bodyMarkdown };
1515
+ }
1516
+ }
1517
+ let capacity;
1518
+ if (item.capacity !== void 0) {
1519
+ const rawCapacity = objectRecord(item.capacity);
1520
+ if (!rawCapacity) errors.push(`${path}.capacity must be an object`);
1521
+ else {
1522
+ rejectUnknownKeys(rawCapacity, CAPACITY_KEYS, `${path}.capacity`, errors);
1523
+ const total = rawCapacity.total;
1524
+ if (!Number.isInteger(total) || Number(total) < 1 || Number(total) > 1e6) {
1525
+ errors.push(`${path}.capacity.total must be an integer from 1 to 1,000,000`);
1526
+ }
1527
+ const label = boundedString(rawCapacity.label, `${path}.capacity.label`, errors, MAX_LABEL);
1528
+ if (Number.isInteger(total) && Number(total) >= 1 && Number(total) <= 1e6) {
1529
+ capacity = { total: Number(total), ...label ? { label } : {} };
1530
+ }
1531
+ }
1532
+ }
1533
+ const badge = boundedString(item.badge, `${path}.badge`, errors, MAX_LABEL);
1534
+ const summaryMarkdown = boundedString(item.summaryMarkdown, `${path}.summaryMarkdown`, errors, MAX_MARKDOWN);
1535
+ const priceEyebrow = boundedString(item.priceEyebrow, `${path}.priceEyebrow`, errors, MAX_LABEL);
1536
+ const billingLabel = boundedString(item.billingLabel, `${path}.billingLabel`, errors, MAX_LABEL);
1537
+ const priceNoteMarkdown = boundedString(item.priceNoteMarkdown, `${path}.priceNoteMarkdown`, errors, MAX_MARKDOWN);
1538
+ const ctaLabel = boundedString(item.ctaLabel, `${path}.ctaLabel`, errors, MAX_LABEL);
1539
+ tiers.push({
1540
+ tierId,
1541
+ ...typeof variant === "string" && VARIANTS.has(variant) ? { variant } : {},
1542
+ ...badge ? { badge } : {},
1543
+ ...summaryMarkdown ? { summaryMarkdown } : {},
1544
+ ...priceEyebrow ? { priceEyebrow } : {},
1545
+ ...Number.isInteger(compareAt) ? { compareAtPriceCents: Number(compareAt) } : {},
1546
+ ...billingLabel ? { billingLabel } : {},
1547
+ ...priceNoteMarkdown ? { priceNoteMarkdown } : {},
1548
+ ...capacity ? { capacity } : {},
1549
+ ...benefits ? { benefits } : {},
1550
+ ...callout ? { callout } : {},
1551
+ ...ctaLabel ? { ctaLabel } : {}
1552
+ });
1553
+ }
1554
+ if (authority.size) {
1555
+ for (const tierId of authority.keys()) {
1556
+ if (!seen.has(tierId)) errors.push(`document.tiers is missing offered tier "${tierId}"`);
1557
+ }
1558
+ }
1559
+ const footnoteMarkdown = boundedString(root.footnoteMarkdown, "document.footnoteMarkdown", errors, MAX_MARKDOWN);
1560
+ if (errors.length) return { ok: false, errors };
1561
+ return {
1562
+ ok: true,
1563
+ value: {
1564
+ schemaVersion: 1,
1565
+ renderer: MEMBERSHIP_PAGE_RENDERER,
1566
+ tiers,
1567
+ ...footnoteMarkdown ? { footnoteMarkdown } : {}
1568
+ }
1569
+ };
1570
+ }
1571
+
1572
+ // src/signup-control-validate.ts
1573
+ var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
1574
+ var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
1575
+ var TIER = /^[a-z][a-z0-9-]{0,79}$/;
1576
+ var PRICE = /^price_[A-Za-z0-9]{12,}$/;
1577
+ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
1578
+ "schemaVersion",
1579
+ "revision",
1580
+ "generatedAt",
1581
+ "source",
1582
+ "target",
1583
+ "tiers",
1584
+ "membershipPage",
1585
+ "application",
1586
+ "policy",
1587
+ "scheduling",
1588
+ "theme"
1589
+ ]);
1590
+ var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
1591
+ var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1592
+ function unknownKeys(value, allowed, path, errors) {
1593
+ for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
1594
+ }
1595
+ function text(value, path, errors, max = 65536) {
1596
+ if (typeof value !== "string" || !value.trim()) {
1597
+ errors.push(`${path} is required`);
1598
+ return "";
1599
+ }
1600
+ if (value.length > max) errors.push(`${path} must be at most ${max} characters`);
1601
+ return value.trim();
1602
+ }
1603
+ function tiersOf(value, errors) {
1604
+ if (!Array.isArray(value) || !value.length) {
1605
+ errors.push("document.tiers must be a non-empty array");
1606
+ return [];
1607
+ }
1608
+ if (value.length > 20) errors.push("document.tiers supports at most 20 entries");
1609
+ const seen = /* @__PURE__ */ new Set();
1610
+ return value.slice(0, 20).flatMap((raw, index) => {
1611
+ const path = `document.tiers[${index}]`;
1612
+ const item = recordOf(raw);
1613
+ if (!item) {
1614
+ errors.push(`${path} must be an object`);
1615
+ return [];
1616
+ }
1617
+ unknownKeys(item, TIER_KEYS2, path, errors);
1618
+ const id = text(item.id, `${path}.id`, errors, 80);
1619
+ if (id && !TIER.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
1620
+ if (seen.has(id)) errors.push(`${path}.id duplicates "${id}"`);
1621
+ seen.add(id);
1622
+ const name = text(item.name, `${path}.name`, errors, 160);
1623
+ if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
1624
+ if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
1625
+ if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
1626
+ errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
1627
+ }
1628
+ if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE.test(item.stripePriceId))) {
1629
+ errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
1630
+ }
1631
+ if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
1632
+ errors.push(`${path}.stripePriceId is required for an available paid tier`);
1633
+ }
1634
+ const blurb = item.blurb === void 0 ? void 0 : text(item.blurb, `${path}.blurb`, errors, 4e3);
1635
+ return [{
1636
+ id,
1637
+ name,
1638
+ available: item.available === true,
1639
+ sortOrder: Number(item.sortOrder) || 0,
1640
+ priceCents: Number(item.priceCents) || 0,
1641
+ ...typeof item.stripePriceId === "string" ? { stripePriceId: item.stripePriceId } : {},
1642
+ ...blurb ? { blurb } : {}
1643
+ }];
1644
+ });
1645
+ }
1646
+ function applicationOf(value, errors) {
1647
+ if (value === void 0) return void 0;
1648
+ const input = recordOf(value);
1649
+ if (!input) {
1650
+ errors.push("document.application must be an object");
1651
+ return void 0;
1652
+ }
1653
+ unknownKeys(input, /* @__PURE__ */ new Set(["required", "optional", "conditions", "copy"]), "document.application", errors);
1654
+ try {
1655
+ const resolved = resolveApplication(input);
1656
+ const copy = recordOf(input.copy);
1657
+ if (input.copy !== void 0 && (!copy || Object.values(copy).some((item) => typeof item !== "string"))) {
1658
+ errors.push("document.application.copy must contain only string values");
1659
+ }
1660
+ return {
1661
+ required: resolved.required,
1662
+ optional: resolved.optional,
1663
+ ...Object.keys(resolved.conditions).length ? { conditions: resolved.conditions } : {},
1664
+ ...copy ? { copy } : {}
1665
+ };
1666
+ } catch (error) {
1667
+ errors.push(error instanceof Error ? error.message : "document.application is invalid");
1668
+ return void 0;
1669
+ }
1670
+ }
1671
+ function policyOf(value, errors) {
1672
+ if (value === void 0) return void 0;
1673
+ const input = recordOf(value);
1674
+ if (!input) {
1675
+ errors.push("document.policy must be an object");
1676
+ return void 0;
1677
+ }
1678
+ const allowed = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
1679
+ unknownKeys(input, allowed, "document.policy", errors);
1680
+ const result = {};
1681
+ for (const key of allowed) {
1682
+ if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
1683
+ result[key] = text(input[key], `document.policy.${key}`, errors);
1684
+ }
1685
+ return result;
1686
+ }
1687
+ function themeOf(value, errors) {
1688
+ if (value === void 0) return void 0;
1689
+ const input = recordOf(value);
1690
+ const tokens = recordOf(input?.tokens);
1691
+ if (!input || !tokens) {
1692
+ errors.push("document.theme.tokens must be an object");
1693
+ return void 0;
1694
+ }
1695
+ unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
1696
+ if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
1697
+ for (const [key, token] of Object.entries(tokens)) {
1698
+ if (!/^--[a-z0-9-]{1,100}$/.test(key)) errors.push(`document.theme.tokens.${key} must be a CSS custom-property name`);
1699
+ if (typeof token !== "string" && typeof token !== "number" || String(token).length > 1e3) {
1700
+ errors.push(`document.theme.tokens.${key} must be a bounded string or number`);
1701
+ }
1702
+ }
1703
+ return { tokens };
1704
+ }
1705
+ function validateSignupControlDocument(input) {
1706
+ const errors = [];
1707
+ const root = recordOf(input);
1708
+ if (!root) return { ok: false, errors: ["document must be an object"] };
1709
+ unknownKeys(root, ROOT_KEYS2, "document", errors);
1710
+ if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
1711
+ if (!Number.isSafeInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
1712
+ if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
1713
+ const source = recordOf(root.source);
1714
+ const target = recordOf(root.target);
1715
+ const appId = text(source?.appId, "document.source.appId", errors, 63);
1716
+ const chapterId = text(target?.chapterId, "document.target.chapterId", errors, 63);
1717
+ const environment = text(target?.environment, "document.target.environment", errors, 63);
1718
+ if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
1719
+ if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
1720
+ if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
1721
+ if (target?.stripeMode !== "test" && target?.stripeMode !== "live") errors.push("document.target.stripeMode must be test or live");
1722
+ const tiers = tiersOf(root.tiers, errors);
1723
+ const available = tiers.filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder);
1724
+ const page = validateMembershipPageDocument(root.membershipPage, { tiers: available });
1725
+ if (!page.ok) errors.push(...page.errors);
1726
+ else if (page.value.tiers.some((tier, index) => tier.tierId !== available[index]?.id)) {
1727
+ errors.push("document.membershipPage.tiers must follow the available tier display order");
1728
+ }
1729
+ const application = applicationOf(root.application, errors);
1730
+ const policy = policyOf(root.policy, errors);
1731
+ let scheduling;
1732
+ if (root.scheduling !== void 0) {
1733
+ const checked = validateScheduling(root.scheduling);
1734
+ if (!checked.ok) errors.push(...Object.entries(checked.errors).map(([key, value]) => `document.scheduling.${key}: ${value}`));
1735
+ else scheduling = root.scheduling;
1736
+ }
1737
+ const theme = themeOf(root.theme, errors);
1738
+ if (errors.length || !page.ok) return { ok: false, errors };
1739
+ return { ok: true, value: {
1740
+ schemaVersion: 1,
1741
+ revision: Number(root.revision),
1742
+ generatedAt: Number(root.generatedAt),
1743
+ source: { appId },
1744
+ target: { chapterId, environment, stripeMode: target?.stripeMode },
1745
+ tiers,
1746
+ membershipPage: page.value,
1747
+ ...application ? { application } : {},
1748
+ ...policy ? { policy } : {},
1749
+ ...scheduling ? { scheduling } : {},
1750
+ ...theme ? { theme } : {}
1751
+ } };
1752
+ }
1753
+
1754
+ // src/worker-routes-signup-control.ts
1755
+ var reject = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
1756
+ var handleSignupControl = async (req, url, env, ctx) => {
1757
+ if (req.method !== "POST" || url.pathname !== SIGNUP_CONTROL_PATH) return null;
1758
+ const authority = ctx.chapter.signupControl;
1759
+ if (!authority) return json({ error: "not found" }, 404);
1760
+ const db = ctx.makeDb(env);
1761
+ const secret = await getVaultSecret(db, authority.secretName);
1762
+ if (!secret) return json({ error: "unauthorized" }, 401);
1763
+ const verified = await verifyFederatedRequest2(req, { secret, senders: [authority.sourceId] });
1764
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
1765
+ let input;
1766
+ try {
1767
+ input = JSON.parse(verified.body);
1768
+ } catch {
1769
+ return reject("invalid_document", 400, ["request body must be JSON"]);
1770
+ }
1771
+ const digest = typeof input.digest === "string" ? input.digest : "";
1772
+ const checked = validateSignupControlDocument(input.document);
1773
+ if (!checked.ok) return reject("invalid_document", 400, checked.errors);
1774
+ const document = checked.value;
1775
+ if (document.source.appId !== authority.sourceId) return reject("wrong_source", 403);
1776
+ if (document.target.chapterId !== ctx.chapter.id) return reject("wrong_target", 409);
1777
+ if (document.target.environment !== env.ODLA_ENV) return reject("wrong_environment", 409);
1778
+ if (document.target.stripeMode !== authority.stripeMode) return reject("wrong_stripe_mode", 409);
1779
+ const applicationErrors = validateControlledApplication(ctx.chapter, document);
1780
+ if (applicationErrors.length) return reject("invalid_document", 400, applicationErrors);
1781
+ if (!digest || await signupControlDigest(document) !== digest) return reject("invalid_digest", 400);
1782
+ const latest = await loadLatestSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
1783
+ if (latest && document.revision < latest.revision) return reject("stale_revision", 409);
1784
+ if (latest && document.revision === latest.revision) {
1785
+ if (latest.digest !== digest) return reject("conflicting_revision", 409);
1786
+ return json({
1787
+ ok: true,
1788
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
1789
+ revision: latest.revision,
1790
+ digest: latest.digest,
1791
+ status: "replayed",
1792
+ acknowledgedAt: latest.receivedAt
1793
+ });
1794
+ }
1795
+ const receivedAt = Date.now();
1796
+ await materializeSignupControl(db, { digest, document }, latest, receivedAt);
1797
+ return json({
1798
+ ok: true,
1799
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
1800
+ revision: document.revision,
1801
+ digest,
1802
+ status: "accepted",
1803
+ acknowledgedAt: receivedAt
1804
+ });
1805
+ };
1806
+
1234
1807
  // src/crm-sync.ts
1235
1808
  import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
1236
1809
  var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
@@ -2578,11 +3151,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
2578
3151
  for (const key of EMAIL_TEMPLATE_NAMES) {
2579
3152
  const t = templates[key];
2580
3153
  const subject = typeof t?.subject === "string" ? t.subject.trim() : "";
2581
- const text = typeof t?.text === "string" ? t.text : "";
2582
- if (!subject || !text.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
3154
+ const text2 = typeof t?.text === "string" ? t.text : "";
3155
+ if (!subject || !text2.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
2583
3156
  if (/[\r\n]/.test(subject) || subject.length > 200) return json({ error: `template "${key}" subject must be a single line under 200 characters` }, 400);
2584
- if (text.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
2585
- clean[key] = { subject, text, enabled: t?.enabled !== false };
3157
+ if (text2.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
3158
+ clean[key] = { subject, text: text2, enabled: t?.enabled !== false };
2586
3159
  }
2587
3160
  const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2588
3161
  const notificationEmail = str2(body.notificationEmail).trim();
@@ -2677,7 +3250,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
2677
3250
 
2678
3251
  // src/worker-routes-network.ts
2679
3252
  import { getRecord, typeSummary } from "@odla-ai/crm";
2680
- import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest2 } from "@odla-ai/db";
3253
+ import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest3 } from "@odla-ai/db";
2681
3254
 
2682
3255
  // src/worker-network-fetch.ts
2683
3256
  function targetFetcher(env, target) {
@@ -2814,7 +3387,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
2814
3387
  const db = ctx.makeDb(env);
2815
3388
  const secret = await getVaultSecret(db, "network_share_secret");
2816
3389
  if (!secret) return json({ error: "unauthorized" }, 401);
2817
- const verified = await verifyFederatedRequest2(req, { secret });
3390
+ const verified = await verifyFederatedRequest3(req, { secret });
2818
3391
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
2819
3392
  const types = await Promise.all(
2820
3393
  Object.keys(ctx.chapter.crm.config.types).map((type) => typeSummary({ crm: ctx.chapter.crm, db }, type))
@@ -2925,7 +3498,7 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
2925
3498
 
2926
3499
  // src/worker-routes-network-records.ts
2927
3500
  import { listRecords } from "@odla-ai/crm";
2928
- import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest3 } from "@odla-ai/db";
3501
+ import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest4 } from "@odla-ai/db";
2929
3502
  var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
2930
3503
  var MAX_PAGE = 50;
2931
3504
  var MAX_OFFSET = 1e4;
@@ -2963,7 +3536,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
2963
3536
  const secret = await getVaultSecret(db, "network_share_secret");
2964
3537
  const readers = ctx.chapter.network.readers;
2965
3538
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
2966
- const verified = await verifyFederatedRequest3(req, {
3539
+ const verified = await verifyFederatedRequest4(req, {
2967
3540
  secret,
2968
3541
  senders: readers.map((reader2) => reader2.id)
2969
3542
  });
@@ -3147,7 +3720,7 @@ import {
3147
3720
  CRM_NS,
3148
3721
  getRecord as getRecord2
3149
3722
  } from "@odla-ai/crm";
3150
- import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest4 } from "@odla-ai/db";
3723
+ import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest5 } from "@odla-ai/db";
3151
3724
  var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
3152
3725
  var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3153
3726
  var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
@@ -3206,7 +3779,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3206
3779
  const secret = await getVaultSecret(db, "network_share_secret");
3207
3780
  const readers = ctx.chapter.network.readers;
3208
3781
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
3209
- const verified = await verifyFederatedRequest4(req, {
3782
+ const verified = await verifyFederatedRequest5(req, {
3210
3783
  secret,
3211
3784
  senders: readers.map((reader2) => reader2.id)
3212
3785
  });
@@ -3423,6 +3996,31 @@ var handleFormation = async (req, url, env, ctx) => {
3423
3996
  }
3424
3997
  };
3425
3998
 
3999
+ // src/signup-control-delivery.ts
4000
+ import { signFederatedRequest as signFederatedRequest5 } from "@odla-ai/db";
4001
+ async function deliverSignupControl(options) {
4002
+ const destination = new URL(SIGNUP_CONTROL_PATH, options.targetUrl);
4003
+ const body = JSON.stringify(options.envelope);
4004
+ const signed = await signFederatedRequest5({
4005
+ secret: options.secret,
4006
+ sender: options.sender,
4007
+ method: "POST",
4008
+ url: destination,
4009
+ body
4010
+ });
4011
+ const response = await (options.fetch ?? globalThis.fetch)(destination, {
4012
+ method: "POST",
4013
+ headers: { ...signed, "content-type": "application/json" },
4014
+ body
4015
+ });
4016
+ const result = await response.json();
4017
+ if (!response.ok || (!("ok" in result) || result.ok !== true)) {
4018
+ const rejection = result;
4019
+ throw new Error(`signup control rejected (${response.status}): ${rejection.reason ?? rejection.error ?? "unknown"}`);
4020
+ }
4021
+ return result;
4022
+ }
4023
+
3426
4024
  // src/worker.ts
3427
4025
  var BUILTIN_ROUTES = [
3428
4026
  handleHealth,
@@ -3430,6 +4028,7 @@ var BUILTIN_ROUTES = [
3430
4028
  handleMe,
3431
4029
  handleCrm,
3432
4030
  handleNetworkShared,
4031
+ handleSignupControl,
3433
4032
  handleNetworkSnapshot,
3434
4033
  handleNetworkRecords,
3435
4034
  handleNetworkSharedNotes,
@@ -3487,6 +4086,7 @@ function chapterWorker(options) {
3487
4086
  }
3488
4087
  export {
3489
4088
  chapterWorker,
3490
- createWorkerContext
4089
+ createWorkerContext,
4090
+ deliverSignupControl
3491
4091
  };
3492
4092
  //# sourceMappingURL=index.js.map