@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.
@@ -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,101 @@ 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 hasSignupControl(db, targetId, environment, active) {
503
+ const where = { targetId, environment, ...active ? { active: true } : {} };
504
+ if (db.aggregate) {
505
+ return Number((await db.aggregate("signupControls", { count: true }, { where })).count ?? 0) > 0;
506
+ }
507
+ const rows2 = (await db.query({ signupControls: {} })).signupControls;
508
+ return Array.isArray(rows2) && rows2.some((row) => row.targetId === targetId && row.environment === environment && (!active || row.active === true));
509
+ }
510
+ async function loadLatestSignupControl(db, targetId, environment) {
511
+ if (!await hasSignupControl(db, targetId, environment)) return null;
512
+ const rows2 = (await db.query({ signupControls: { $: {
513
+ where: { targetId, environment },
514
+ order: { revision: "desc" },
515
+ limit: 1
516
+ } } })).signupControls;
517
+ const latest = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
518
+ return asStored(latest);
519
+ }
520
+ async function loadActiveSignupControl(db, targetId, environment) {
521
+ if (!await hasSignupControl(db, targetId, environment, true)) return null;
522
+ const rows2 = (await db.query({ signupControls: { $: {
523
+ where: { targetId, environment, active: true },
524
+ order: { revision: "desc" },
525
+ limit: 1
526
+ } } })).signupControls;
527
+ const active = Array.isArray(rows2) ? [...rows2].sort((a, b) => Number(b.revision ?? 0) - Number(a.revision ?? 0))[0] : void 0;
528
+ return asStored(active);
529
+ }
530
+ function controlledApplication(base, document) {
531
+ const delegated = document?.application;
532
+ return delegated ? { ...base, required: delegated.required, optional: delegated.optional, conditions: { ...delegated.conditions ?? {} } } : base;
533
+ }
534
+ function validateControlledApplication(chapter, document) {
535
+ if (!document.application) return [];
536
+ const declared = /* @__PURE__ */ new Set([...chapter.application.required, ...chapter.application.optional]);
537
+ return [...document.application.required, ...document.application.optional].filter((field) => !declared.has(field)).map((field) => `document.application field "${field}" is not declared by this chapter schema`);
538
+ }
539
+ function groupAttrs(document) {
540
+ const first = [...document.tiers].filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder)[0];
541
+ const firstPaid = [...document.tiers].find((tier) => tier.available && tier.priceCents > 0);
542
+ return {
543
+ ...first ? { standardPriceCents: first.priceCents } : {},
544
+ foundingDiscountCents: 0,
545
+ stripePriceId: firstPaid?.stripePriceId ?? null,
546
+ ...document.policy ?? {},
547
+ ...document.scheduling ? { schedulingJson: document.scheduling } : {}
548
+ };
549
+ }
550
+ async function materializeSignupControl(db, envelope, previous, receivedAt) {
551
+ const { document, digest } = envelope;
552
+ const currentRows = (await db.query({ tiers: { $: {
553
+ where: { groupId: document.target.chapterId },
554
+ limit: 100
555
+ } } })).tiers ?? [];
556
+ const incoming = new Set(document.tiers.map((tier) => tier.id));
557
+ const ops = [];
558
+ if (previous?.active) ops.push({ t: "update", ns: "signupControls", id: previous.id, attrs: { active: false } });
559
+ for (const row of currentRows) {
560
+ const id2 = String(row.id ?? "");
561
+ if (id2 && !incoming.has(id2)) ops.push({ t: "update", ns: "tiers", id: id2, attrs: { active: false } });
562
+ }
563
+ for (const tier of document.tiers) {
564
+ ops.push({ t: "update", ns: "tiers", id: tier.id, attrs: {
565
+ id: tier.id,
566
+ groupId: document.target.chapterId,
567
+ name: tier.name,
568
+ priceCents: tier.priceCents,
569
+ stripePriceId: tier.stripePriceId ?? null,
570
+ blurb: tier.blurb ?? null,
571
+ sortOrder: tier.sortOrder,
572
+ active: tier.available,
573
+ createdAt: receivedAt
574
+ } });
575
+ }
576
+ ops.push({ t: "update", ns: "groups", id: document.target.chapterId, attrs: groupAttrs(document) });
577
+ const id = `signup-control:${document.target.environment}:${document.revision}`;
578
+ ops.push({ t: "update", ns: "signupControls", id, attrs: {
579
+ id,
580
+ sourceId: document.source.appId,
581
+ targetId: document.target.chapterId,
582
+ environment: document.target.environment,
583
+ revision: document.revision,
584
+ digest,
585
+ document,
586
+ active: true,
587
+ receivedAt
588
+ } });
589
+ await db.transact(ops, { mutationId: `signup-control:${document.target.environment}:${document.revision}:${digest}` });
590
+ }
591
+
447
592
  // src/session.ts
448
593
  function applicationSummary(app) {
449
594
  return {
@@ -791,10 +936,10 @@ function planDelivery(input) {
791
936
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
792
937
  const to = redirect ? input.group.debugEmail : input.to;
793
938
  const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
794
- const text = redirect ? `(dev redirect; original recipient: ${input.to})
939
+ const text2 = redirect ? `(dev redirect; original recipient: ${input.to})
795
940
 
796
941
  ` + render(tpl.text, vars) : render(tpl.text, vars);
797
- return { deliver: true, transport, to, subject, text, redirected: redirect };
942
+ return { deliver: true, transport, to, subject, text: text2, redirected: redirect };
798
943
  }
799
944
 
800
945
  // src/notify.ts
@@ -1083,9 +1228,18 @@ var handleMember = async (req, url, env, ctx) => {
1083
1228
  group,
1084
1229
  Boolean(stripeKey)
1085
1230
  );
1231
+ const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
1232
+ const application = controlledApplication(chapter.application, activeControl?.document);
1086
1233
  return json({
1087
- ...joinConfig(group, paymentsReady, joinConfigTiers(offered), chapter.application.conditions),
1088
- copy: chapter.copy.join
1234
+ ...joinConfig(group, paymentsReady, joinConfigTiers(offered), application.conditions),
1235
+ copy: { ...chapter.copy.join, ...activeControl?.document.application?.copy ?? {} },
1236
+ ...activeControl ? { signupControl: {
1237
+ revision: activeControl.revision,
1238
+ digest: activeControl.digest,
1239
+ membershipPage: activeControl.document.membershipPage,
1240
+ application: activeControl.document.application,
1241
+ theme: activeControl.document.theme
1242
+ } } : {}
1089
1243
  });
1090
1244
  }
1091
1245
  if (req.method === "GET" && url.pathname === "/api/join/resume") {
@@ -1148,8 +1302,10 @@ var handleMember = async (req, url, env, ctx) => {
1148
1302
  }
1149
1303
  const submissionId = typeof parsed.submissionId === "string" ? parsed.submissionId : void 0;
1150
1304
  const db = ctx.makeDb(env);
1305
+ const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
1306
+ const effectiveChapter = activeControl ? { ...chapter, application: controlledApplication(chapter.application, activeControl.document) } : chapter;
1151
1307
  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, {
1308
+ const result = await submitApplication(db, effectiveChapter, parsed, {
1153
1309
  submissionId,
1154
1310
  groupId: chapter.id,
1155
1311
  now: Date.now(),
@@ -1158,10 +1314,10 @@ var handleMember = async (req, url, env, ctx) => {
1158
1314
  });
1159
1315
  if (!result.ok) return json({ error: result.error }, 400);
1160
1316
  if (!result.duplicate) {
1161
- if (chapter.sends.adminNotification === "submit") {
1317
+ if (effectiveChapter.sends.adminNotification === "submit") {
1162
1318
  await notifyAdminOfApplication(db, env, chapter.id, result.id, parsed);
1163
1319
  }
1164
- await provisionApplicant(db, chapter, result.id, parsed);
1320
+ await provisionApplicant(db, effectiveChapter, result.id, parsed);
1165
1321
  }
1166
1322
  return json({
1167
1323
  id: result.id,
@@ -1231,6 +1387,433 @@ var handleNetworkShared = async (req, url, env, ctx) => {
1231
1387
  }
1232
1388
  };
1233
1389
 
1390
+ // src/worker-routes-signup-control.ts
1391
+ import { verifyFederatedRequest as verifyFederatedRequest2 } from "@odla-ai/db";
1392
+
1393
+ // src/signup-control-canonical.ts
1394
+ var encoder = new TextEncoder();
1395
+ function canonicalValue(value) {
1396
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
1397
+ return JSON.stringify(value);
1398
+ }
1399
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
1400
+ if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
1401
+ const record = value;
1402
+ const entries = Object.keys(record).sort().flatMap((key) => {
1403
+ const item = record[key];
1404
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
1405
+ });
1406
+ return `{${entries.join(",")}}`;
1407
+ }
1408
+ function canonicalSignupControl(value) {
1409
+ return canonicalValue(value);
1410
+ }
1411
+ async function signupControlDigest(document) {
1412
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
1413
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
1414
+ }
1415
+
1416
+ // src/signup-control-contract.ts
1417
+ var SIGNUP_CONTROL_PATH = "/api/signup-control";
1418
+
1419
+ // src/membership-page.ts
1420
+ var MEMBERSHIP_PAGE_RENDERER = "membership-cards-v1";
1421
+ var TIER_ID = /^[a-z][a-z0-9-]{0,79}$/;
1422
+ var VARIANTS = /* @__PURE__ */ new Set(["standard", "featured", "supporting"]);
1423
+ var ROOT_KEYS = /* @__PURE__ */ new Set(["schemaVersion", "renderer", "tiers", "footnoteMarkdown"]);
1424
+ var TIER_KEYS = /* @__PURE__ */ new Set([
1425
+ "tierId",
1426
+ "variant",
1427
+ "badge",
1428
+ "summaryMarkdown",
1429
+ "priceEyebrow",
1430
+ "compareAtPriceCents",
1431
+ "billingLabel",
1432
+ "priceNoteMarkdown",
1433
+ "capacity",
1434
+ "benefits",
1435
+ "callout",
1436
+ "ctaLabel"
1437
+ ]);
1438
+ var CALLOUT_KEYS = /* @__PURE__ */ new Set(["label", "bodyMarkdown"]);
1439
+ var CAPACITY_KEYS = /* @__PURE__ */ new Set(["total", "label"]);
1440
+ var MAX_TIERS = 20;
1441
+ var MAX_BENEFITS = 24;
1442
+ var MAX_MARKDOWN = 65536;
1443
+ var MAX_LABEL = 160;
1444
+ var objectRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1445
+ function rejectUnknownKeys(value, allowed, path, errors) {
1446
+ for (const key of Object.keys(value)) {
1447
+ if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
1448
+ }
1449
+ }
1450
+ function boundedString(value, path, errors, max, required = false) {
1451
+ if (value === void 0) {
1452
+ if (required) errors.push(`${path} is required`);
1453
+ return void 0;
1454
+ }
1455
+ if (typeof value !== "string") {
1456
+ errors.push(`${path} must be a string`);
1457
+ return void 0;
1458
+ }
1459
+ const text2 = value.trim();
1460
+ if (required && !text2) errors.push(`${path} is required`);
1461
+ if (text2.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
1462
+ return text2;
1463
+ }
1464
+ function validateMembershipPageDocument(input, options = {}) {
1465
+ const errors = [];
1466
+ const root = objectRecord(input);
1467
+ if (!root) return { ok: false, errors: ["document must be an object"] };
1468
+ rejectUnknownKeys(root, ROOT_KEYS, "document", errors);
1469
+ if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
1470
+ if (root.renderer !== MEMBERSHIP_PAGE_RENDERER) {
1471
+ errors.push(`document.renderer must be "${MEMBERSHIP_PAGE_RENDERER}"`);
1472
+ }
1473
+ if (!Array.isArray(root.tiers)) errors.push("document.tiers must be an array");
1474
+ const rawTiers = Array.isArray(root.tiers) ? root.tiers : [];
1475
+ if (Array.isArray(root.tiers) && rawTiers.length === 0) errors.push("document.tiers must include at least one tier");
1476
+ if (rawTiers.length > MAX_TIERS) errors.push(`document.tiers supports at most ${MAX_TIERS} entries`);
1477
+ const authority = new Map((options.tiers ?? []).map((tier) => [tier.id, tier]));
1478
+ const seen = /* @__PURE__ */ new Set();
1479
+ const tiers = [];
1480
+ for (const [index, raw] of rawTiers.slice(0, MAX_TIERS).entries()) {
1481
+ const path = `document.tiers[${index}]`;
1482
+ const item = objectRecord(raw);
1483
+ if (!item) {
1484
+ errors.push(`${path} must be an object`);
1485
+ continue;
1486
+ }
1487
+ rejectUnknownKeys(item, TIER_KEYS, path, errors);
1488
+ const tierId = boundedString(item.tierId, `${path}.tierId`, errors, 80, true) ?? "";
1489
+ if (tierId && !TIER_ID.test(tierId)) errors.push(`${path}.tierId must be a lowercase kebab-case id`);
1490
+ if (seen.has(tierId)) errors.push(`${path}.tierId duplicates "${tierId}"`);
1491
+ seen.add(tierId);
1492
+ if (authority.size && !authority.has(tierId)) errors.push(`${path}.tierId "${tierId}" is not an offered tier`);
1493
+ const variant = item.variant;
1494
+ if (variant !== void 0 && (typeof variant !== "string" || !VARIANTS.has(variant))) {
1495
+ errors.push(`${path}.variant must be standard, featured, or supporting`);
1496
+ }
1497
+ const compareAt = item.compareAtPriceCents;
1498
+ if (compareAt !== void 0 && (!Number.isInteger(compareAt) || Number(compareAt) < 0 || Number(compareAt) > 1e8)) {
1499
+ errors.push(`${path}.compareAtPriceCents must be an integer from 0 to 100,000,000`);
1500
+ }
1501
+ const actual = authority.get(tierId)?.priceCents;
1502
+ if (Number.isInteger(compareAt) && actual !== void 0 && Number(compareAt) <= actual) {
1503
+ errors.push(`${path}.compareAtPriceCents must be greater than the authoritative tier price`);
1504
+ }
1505
+ let benefits;
1506
+ if (item.benefits !== void 0) {
1507
+ if (!Array.isArray(item.benefits)) errors.push(`${path}.benefits must be an array`);
1508
+ else {
1509
+ if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
1510
+ benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
1511
+ const text2 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
1512
+ return text2 ? [text2] : [];
1513
+ });
1514
+ }
1515
+ }
1516
+ let callout;
1517
+ if (item.callout !== void 0) {
1518
+ const rawCallout = objectRecord(item.callout);
1519
+ if (!rawCallout) errors.push(`${path}.callout must be an object`);
1520
+ else {
1521
+ rejectUnknownKeys(rawCallout, CALLOUT_KEYS, `${path}.callout`, errors);
1522
+ const bodyMarkdown = boundedString(rawCallout.bodyMarkdown, `${path}.callout.bodyMarkdown`, errors, MAX_MARKDOWN, true);
1523
+ const label = boundedString(rawCallout.label, `${path}.callout.label`, errors, MAX_LABEL);
1524
+ if (bodyMarkdown) callout = { ...label ? { label } : {}, bodyMarkdown };
1525
+ }
1526
+ }
1527
+ let capacity;
1528
+ if (item.capacity !== void 0) {
1529
+ const rawCapacity = objectRecord(item.capacity);
1530
+ if (!rawCapacity) errors.push(`${path}.capacity must be an object`);
1531
+ else {
1532
+ rejectUnknownKeys(rawCapacity, CAPACITY_KEYS, `${path}.capacity`, errors);
1533
+ const total = rawCapacity.total;
1534
+ if (!Number.isInteger(total) || Number(total) < 1 || Number(total) > 1e6) {
1535
+ errors.push(`${path}.capacity.total must be an integer from 1 to 1,000,000`);
1536
+ }
1537
+ const label = boundedString(rawCapacity.label, `${path}.capacity.label`, errors, MAX_LABEL);
1538
+ if (Number.isInteger(total) && Number(total) >= 1 && Number(total) <= 1e6) {
1539
+ capacity = { total: Number(total), ...label ? { label } : {} };
1540
+ }
1541
+ }
1542
+ }
1543
+ const badge = boundedString(item.badge, `${path}.badge`, errors, MAX_LABEL);
1544
+ const summaryMarkdown = boundedString(item.summaryMarkdown, `${path}.summaryMarkdown`, errors, MAX_MARKDOWN);
1545
+ const priceEyebrow = boundedString(item.priceEyebrow, `${path}.priceEyebrow`, errors, MAX_LABEL);
1546
+ const billingLabel = boundedString(item.billingLabel, `${path}.billingLabel`, errors, MAX_LABEL);
1547
+ const priceNoteMarkdown = boundedString(item.priceNoteMarkdown, `${path}.priceNoteMarkdown`, errors, MAX_MARKDOWN);
1548
+ const ctaLabel = boundedString(item.ctaLabel, `${path}.ctaLabel`, errors, MAX_LABEL);
1549
+ tiers.push({
1550
+ tierId,
1551
+ ...typeof variant === "string" && VARIANTS.has(variant) ? { variant } : {},
1552
+ ...badge ? { badge } : {},
1553
+ ...summaryMarkdown ? { summaryMarkdown } : {},
1554
+ ...priceEyebrow ? { priceEyebrow } : {},
1555
+ ...Number.isInteger(compareAt) ? { compareAtPriceCents: Number(compareAt) } : {},
1556
+ ...billingLabel ? { billingLabel } : {},
1557
+ ...priceNoteMarkdown ? { priceNoteMarkdown } : {},
1558
+ ...capacity ? { capacity } : {},
1559
+ ...benefits ? { benefits } : {},
1560
+ ...callout ? { callout } : {},
1561
+ ...ctaLabel ? { ctaLabel } : {}
1562
+ });
1563
+ }
1564
+ if (authority.size) {
1565
+ for (const tierId of authority.keys()) {
1566
+ if (!seen.has(tierId)) errors.push(`document.tiers is missing offered tier "${tierId}"`);
1567
+ }
1568
+ }
1569
+ const footnoteMarkdown = boundedString(root.footnoteMarkdown, "document.footnoteMarkdown", errors, MAX_MARKDOWN);
1570
+ if (errors.length) return { ok: false, errors };
1571
+ return {
1572
+ ok: true,
1573
+ value: {
1574
+ schemaVersion: 1,
1575
+ renderer: MEMBERSHIP_PAGE_RENDERER,
1576
+ tiers,
1577
+ ...footnoteMarkdown ? { footnoteMarkdown } : {}
1578
+ }
1579
+ };
1580
+ }
1581
+
1582
+ // src/signup-control-validate.ts
1583
+ var SLUG = /^[a-z0-9][a-z0-9-]{1,62}$/;
1584
+ var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
1585
+ var TIER = /^[a-z][a-z0-9-]{0,79}$/;
1586
+ var PRICE = /^price_[A-Za-z0-9]{12,}$/;
1587
+ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
1588
+ "schemaVersion",
1589
+ "revision",
1590
+ "generatedAt",
1591
+ "source",
1592
+ "target",
1593
+ "tiers",
1594
+ "membershipPage",
1595
+ "application",
1596
+ "policy",
1597
+ "scheduling",
1598
+ "theme"
1599
+ ]);
1600
+ var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
1601
+ var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1602
+ function unknownKeys(value, allowed, path, errors) {
1603
+ for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
1604
+ }
1605
+ function text(value, path, errors, max = 65536) {
1606
+ if (typeof value !== "string" || !value.trim()) {
1607
+ errors.push(`${path} is required`);
1608
+ return "";
1609
+ }
1610
+ if (value.length > max) errors.push(`${path} must be at most ${max} characters`);
1611
+ return value.trim();
1612
+ }
1613
+ function tiersOf(value, errors) {
1614
+ if (!Array.isArray(value) || !value.length) {
1615
+ errors.push("document.tiers must be a non-empty array");
1616
+ return [];
1617
+ }
1618
+ if (value.length > 20) errors.push("document.tiers supports at most 20 entries");
1619
+ const seen = /* @__PURE__ */ new Set();
1620
+ return value.slice(0, 20).flatMap((raw, index) => {
1621
+ const path = `document.tiers[${index}]`;
1622
+ const item = recordOf(raw);
1623
+ if (!item) {
1624
+ errors.push(`${path} must be an object`);
1625
+ return [];
1626
+ }
1627
+ unknownKeys(item, TIER_KEYS2, path, errors);
1628
+ const id = text(item.id, `${path}.id`, errors, 80);
1629
+ if (id && !TIER.test(id)) errors.push(`${path}.id must be a lowercase kebab-case id`);
1630
+ if (seen.has(id)) errors.push(`${path}.id duplicates "${id}"`);
1631
+ seen.add(id);
1632
+ const name = text(item.name, `${path}.name`, errors, 160);
1633
+ if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
1634
+ if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
1635
+ if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
1636
+ errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
1637
+ }
1638
+ if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE.test(item.stripePriceId))) {
1639
+ errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
1640
+ }
1641
+ if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
1642
+ errors.push(`${path}.stripePriceId is required for an available paid tier`);
1643
+ }
1644
+ const blurb = item.blurb === void 0 ? void 0 : text(item.blurb, `${path}.blurb`, errors, 4e3);
1645
+ return [{
1646
+ id,
1647
+ name,
1648
+ available: item.available === true,
1649
+ sortOrder: Number(item.sortOrder) || 0,
1650
+ priceCents: Number(item.priceCents) || 0,
1651
+ ...typeof item.stripePriceId === "string" ? { stripePriceId: item.stripePriceId } : {},
1652
+ ...blurb ? { blurb } : {}
1653
+ }];
1654
+ });
1655
+ }
1656
+ function applicationOf(value, errors) {
1657
+ if (value === void 0) return void 0;
1658
+ const input = recordOf(value);
1659
+ if (!input) {
1660
+ errors.push("document.application must be an object");
1661
+ return void 0;
1662
+ }
1663
+ unknownKeys(input, /* @__PURE__ */ new Set(["required", "optional", "conditions", "copy"]), "document.application", errors);
1664
+ try {
1665
+ const resolved = resolveApplication(input);
1666
+ const copy = recordOf(input.copy);
1667
+ if (input.copy !== void 0 && (!copy || Object.values(copy).some((item) => typeof item !== "string"))) {
1668
+ errors.push("document.application.copy must contain only string values");
1669
+ }
1670
+ return {
1671
+ required: resolved.required,
1672
+ optional: resolved.optional,
1673
+ ...Object.keys(resolved.conditions).length ? { conditions: resolved.conditions } : {},
1674
+ ...copy ? { copy } : {}
1675
+ };
1676
+ } catch (error) {
1677
+ errors.push(error instanceof Error ? error.message : "document.application is invalid");
1678
+ return void 0;
1679
+ }
1680
+ }
1681
+ function policyOf(value, errors) {
1682
+ if (value === void 0) return void 0;
1683
+ const input = recordOf(value);
1684
+ if (!input) {
1685
+ errors.push("document.policy must be an object");
1686
+ return void 0;
1687
+ }
1688
+ const allowed = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
1689
+ unknownKeys(input, allowed, "document.policy", errors);
1690
+ const result = {};
1691
+ for (const key of allowed) {
1692
+ if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
1693
+ result[key] = text(input[key], `document.policy.${key}`, errors);
1694
+ }
1695
+ return result;
1696
+ }
1697
+ function themeOf(value, errors) {
1698
+ if (value === void 0) return void 0;
1699
+ const input = recordOf(value);
1700
+ const tokens = recordOf(input?.tokens);
1701
+ if (!input || !tokens) {
1702
+ errors.push("document.theme.tokens must be an object");
1703
+ return void 0;
1704
+ }
1705
+ unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
1706
+ if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
1707
+ for (const [key, token] of Object.entries(tokens)) {
1708
+ if (!/^--[a-z0-9-]{1,100}$/.test(key)) errors.push(`document.theme.tokens.${key} must be a CSS custom-property name`);
1709
+ if (typeof token !== "string" && typeof token !== "number" || String(token).length > 1e3) {
1710
+ errors.push(`document.theme.tokens.${key} must be a bounded string or number`);
1711
+ }
1712
+ }
1713
+ return { tokens };
1714
+ }
1715
+ function validateSignupControlDocument(input) {
1716
+ const errors = [];
1717
+ const root = recordOf(input);
1718
+ if (!root) return { ok: false, errors: ["document must be an object"] };
1719
+ unknownKeys(root, ROOT_KEYS2, "document", errors);
1720
+ if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
1721
+ if (!Number.isSafeInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
1722
+ if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
1723
+ const source = recordOf(root.source);
1724
+ const target = recordOf(root.target);
1725
+ const appId = text(source?.appId, "document.source.appId", errors, 63);
1726
+ const chapterId = text(target?.chapterId, "document.target.chapterId", errors, 63);
1727
+ const environment = text(target?.environment, "document.target.environment", errors, 63);
1728
+ if (appId && !SLUG.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
1729
+ if (chapterId && !SLUG.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
1730
+ if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
1731
+ if (target?.stripeMode !== "test" && target?.stripeMode !== "live") errors.push("document.target.stripeMode must be test or live");
1732
+ const tiers = tiersOf(root.tiers, errors);
1733
+ const available = tiers.filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder);
1734
+ const page = validateMembershipPageDocument(root.membershipPage, { tiers: available });
1735
+ if (!page.ok) errors.push(...page.errors);
1736
+ else if (page.value.tiers.some((tier, index) => tier.tierId !== available[index]?.id)) {
1737
+ errors.push("document.membershipPage.tiers must follow the available tier display order");
1738
+ }
1739
+ const application = applicationOf(root.application, errors);
1740
+ const policy = policyOf(root.policy, errors);
1741
+ let scheduling;
1742
+ if (root.scheduling !== void 0) {
1743
+ const checked = validateScheduling(root.scheduling);
1744
+ if (!checked.ok) errors.push(...Object.entries(checked.errors).map(([key, value]) => `document.scheduling.${key}: ${value}`));
1745
+ else scheduling = root.scheduling;
1746
+ }
1747
+ const theme = themeOf(root.theme, errors);
1748
+ if (errors.length || !page.ok) return { ok: false, errors };
1749
+ return { ok: true, value: {
1750
+ schemaVersion: 1,
1751
+ revision: Number(root.revision),
1752
+ generatedAt: Number(root.generatedAt),
1753
+ source: { appId },
1754
+ target: { chapterId, environment, stripeMode: target?.stripeMode },
1755
+ tiers,
1756
+ membershipPage: page.value,
1757
+ ...application ? { application } : {},
1758
+ ...policy ? { policy } : {},
1759
+ ...scheduling ? { scheduling } : {},
1760
+ ...theme ? { theme } : {}
1761
+ } };
1762
+ }
1763
+
1764
+ // src/worker-routes-signup-control.ts
1765
+ var reject = (reason, status, errors) => json({ ok: false, reason, ...errors ? { errors } : {} }, status);
1766
+ var handleSignupControl = async (req, url, env, ctx) => {
1767
+ if (req.method !== "POST" || url.pathname !== SIGNUP_CONTROL_PATH) return null;
1768
+ const authority = ctx.chapter.signupControl;
1769
+ if (!authority) return json({ error: "not found" }, 404);
1770
+ const db = ctx.makeDb(env);
1771
+ const secret = await getVaultSecret(db, authority.secretName);
1772
+ if (!secret) return json({ error: "unauthorized" }, 401);
1773
+ const verified = await verifyFederatedRequest2(req, { secret, senders: [authority.sourceId] });
1774
+ if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
1775
+ let input;
1776
+ try {
1777
+ input = JSON.parse(verified.body);
1778
+ } catch {
1779
+ return reject("invalid_document", 400, ["request body must be JSON"]);
1780
+ }
1781
+ const digest = typeof input.digest === "string" ? input.digest : "";
1782
+ const checked = validateSignupControlDocument(input.document);
1783
+ if (!checked.ok) return reject("invalid_document", 400, checked.errors);
1784
+ const document = checked.value;
1785
+ if (document.source.appId !== authority.sourceId) return reject("wrong_source", 403);
1786
+ if (document.target.chapterId !== ctx.chapter.id) return reject("wrong_target", 409);
1787
+ if (document.target.environment !== env.ODLA_ENV) return reject("wrong_environment", 409);
1788
+ if (document.target.stripeMode !== authority.stripeMode) return reject("wrong_stripe_mode", 409);
1789
+ const applicationErrors = validateControlledApplication(ctx.chapter, document);
1790
+ if (applicationErrors.length) return reject("invalid_document", 400, applicationErrors);
1791
+ if (!digest || await signupControlDigest(document) !== digest) return reject("invalid_digest", 400);
1792
+ const latest = await loadLatestSignupControl(db, ctx.chapter.id, env.ODLA_ENV);
1793
+ if (latest && document.revision < latest.revision) return reject("stale_revision", 409);
1794
+ if (latest && document.revision === latest.revision) {
1795
+ if (latest.digest !== digest) return reject("conflicting_revision", 409);
1796
+ return json({
1797
+ ok: true,
1798
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
1799
+ revision: latest.revision,
1800
+ digest: latest.digest,
1801
+ status: "replayed",
1802
+ acknowledgedAt: latest.receivedAt
1803
+ });
1804
+ }
1805
+ const receivedAt = Date.now();
1806
+ await materializeSignupControl(db, { digest, document }, latest, receivedAt);
1807
+ return json({
1808
+ ok: true,
1809
+ target: { chapterId: ctx.chapter.id, environment: env.ODLA_ENV },
1810
+ revision: document.revision,
1811
+ digest,
1812
+ status: "accepted",
1813
+ acknowledgedAt: receivedAt
1814
+ });
1815
+ };
1816
+
1234
1817
  // src/crm-sync.ts
1235
1818
  import { createRecord as createRecord2, updateRecord as updateRecord2, setStage, linkIdentity } from "@odla-ai/crm";
1236
1819
  var str = (v) => typeof v === "string" ? v : v == null ? "" : String(v);
@@ -2578,11 +3161,11 @@ var handleAdminGroupEmail = async (req, url, env, ctx) => {
2578
3161
  for (const key of EMAIL_TEMPLATE_NAMES) {
2579
3162
  const t = templates[key];
2580
3163
  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);
3164
+ const text2 = typeof t?.text === "string" ? t.text : "";
3165
+ if (!subject || !text2.trim()) return json({ error: `template "${key}" needs a subject and a body` }, 400);
2583
3166
  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 };
3167
+ if (text2.length > 1e4) return json({ error: `template "${key}" body is too long` }, 400);
3168
+ clean[key] = { subject, text: text2, enabled: t?.enabled !== false };
2586
3169
  }
2587
3170
  const emailish = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2588
3171
  const notificationEmail = str2(body.notificationEmail).trim();
@@ -2677,7 +3260,7 @@ var handleAdminComms = async (req, url, env, ctx) => {
2677
3260
 
2678
3261
  // src/worker-routes-network.ts
2679
3262
  import { getRecord, typeSummary } from "@odla-ai/crm";
2680
- import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest2 } from "@odla-ai/db";
3263
+ import { signFederatedRequest as signFederatedRequest2, verifyFederatedRequest as verifyFederatedRequest3 } from "@odla-ai/db";
2681
3264
 
2682
3265
  // src/worker-network-fetch.ts
2683
3266
  function targetFetcher(env, target) {
@@ -2814,7 +3397,7 @@ var handleNetworkSnapshot = async (req, url, env, ctx) => {
2814
3397
  const db = ctx.makeDb(env);
2815
3398
  const secret = await getVaultSecret(db, "network_share_secret");
2816
3399
  if (!secret) return json({ error: "unauthorized" }, 401);
2817
- const verified = await verifyFederatedRequest2(req, { secret });
3400
+ const verified = await verifyFederatedRequest3(req, { secret });
2818
3401
  if (!verified.ok) return json({ error: "unauthorized", reason: verified.reason }, 401);
2819
3402
  const types = await Promise.all(
2820
3403
  Object.keys(ctx.chapter.crm.config.types).map((type) => typeSummary({ crm: ctx.chapter.crm, db }, type))
@@ -2925,7 +3508,7 @@ var handleAdminNetworkPush = async (req, url, env, ctx) => {
2925
3508
 
2926
3509
  // src/worker-routes-network-records.ts
2927
3510
  import { listRecords } from "@odla-ai/crm";
2928
- import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest3 } from "@odla-ai/db";
3511
+ import { signFederatedRequest as signFederatedRequest3, verifyFederatedRequest as verifyFederatedRequest4 } from "@odla-ai/db";
2929
3512
  var IDENTIFIER = /^[a-z][a-zA-Z0-9_]*$/;
2930
3513
  var MAX_PAGE = 50;
2931
3514
  var MAX_OFFSET = 1e4;
@@ -2963,7 +3546,7 @@ var handleNetworkRecords = async (req, url, env, ctx) => {
2963
3546
  const secret = await getVaultSecret(db, "network_share_secret");
2964
3547
  const readers = ctx.chapter.network.readers;
2965
3548
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
2966
- const verified = await verifyFederatedRequest3(req, {
3549
+ const verified = await verifyFederatedRequest4(req, {
2967
3550
  secret,
2968
3551
  senders: readers.map((reader2) => reader2.id)
2969
3552
  });
@@ -3147,7 +3730,7 @@ import {
3147
3730
  CRM_NS,
3148
3731
  getRecord as getRecord2
3149
3732
  } from "@odla-ai/crm";
3150
- import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest4 } from "@odla-ai/db";
3733
+ import { signFederatedRequest as signFederatedRequest4, verifyFederatedRequest as verifyFederatedRequest5 } from "@odla-ai/db";
3151
3734
  var IDENTIFIER3 = /^[a-z][a-zA-Z0-9_]*$/;
3152
3735
  var RECORD_ID2 = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
3153
3736
  var MUTATION_ID = /^[A-Za-z0-9_-]{16,128}$/;
@@ -3206,7 +3789,7 @@ var handleNetworkSharedNotes = async (req, url, env, ctx) => {
3206
3789
  const secret = await getVaultSecret(db, "network_share_secret");
3207
3790
  const readers = ctx.chapter.network.readers;
3208
3791
  if (!secret || readers.length === 0) return json({ error: "unauthorized" }, 401);
3209
- const verified = await verifyFederatedRequest4(req, {
3792
+ const verified = await verifyFederatedRequest5(req, {
3210
3793
  secret,
3211
3794
  senders: readers.map((reader2) => reader2.id)
3212
3795
  });
@@ -3423,6 +4006,31 @@ var handleFormation = async (req, url, env, ctx) => {
3423
4006
  }
3424
4007
  };
3425
4008
 
4009
+ // src/signup-control-delivery.ts
4010
+ import { signFederatedRequest as signFederatedRequest5 } from "@odla-ai/db";
4011
+ async function deliverSignupControl(options) {
4012
+ const destination = new URL(SIGNUP_CONTROL_PATH, options.targetUrl);
4013
+ const body = JSON.stringify(options.envelope);
4014
+ const signed = await signFederatedRequest5({
4015
+ secret: options.secret,
4016
+ sender: options.sender,
4017
+ method: "POST",
4018
+ url: destination,
4019
+ body
4020
+ });
4021
+ const response = await (options.fetch ?? globalThis.fetch)(destination, {
4022
+ method: "POST",
4023
+ headers: { ...signed, "content-type": "application/json" },
4024
+ body
4025
+ });
4026
+ const result = await response.json();
4027
+ if (!response.ok || (!("ok" in result) || result.ok !== true)) {
4028
+ const rejection = result;
4029
+ throw new Error(`signup control rejected (${response.status}): ${rejection.reason ?? rejection.error ?? "unknown"}`);
4030
+ }
4031
+ return result;
4032
+ }
4033
+
3426
4034
  // src/worker.ts
3427
4035
  var BUILTIN_ROUTES = [
3428
4036
  handleHealth,
@@ -3430,6 +4038,7 @@ var BUILTIN_ROUTES = [
3430
4038
  handleMe,
3431
4039
  handleCrm,
3432
4040
  handleNetworkShared,
4041
+ handleSignupControl,
3433
4042
  handleNetworkSnapshot,
3434
4043
  handleNetworkRecords,
3435
4044
  handleNetworkSharedNotes,
@@ -3487,6 +4096,7 @@ function chapterWorker(options) {
3487
4096
  }
3488
4097
  export {
3489
4098
  chapterWorker,
3490
- createWorkerContext
4099
+ createWorkerContext,
4100
+ deliverSignupControl
3491
4101
  };
3492
4102
  //# sourceMappingURL=index.js.map