@odla-ai/chapter 0.59.0 → 0.60.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.
package/dist/index.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
+ ADMINISTRATOR_AUDIENCE: () => ADMINISTRATOR_AUDIENCE,
23
24
  ADMISSION_GRANTS_PATH: () => ADMISSION_GRANTS_PATH,
24
25
  ADMISSION_GRANTS_SCHEMA_VERSION: () => ADMISSION_GRANTS_SCHEMA_VERSION,
25
26
  BUILDER_LIBRARY_ADMIN_PATH: () => BUILDER_LIBRARY_ADMIN_PATH,
@@ -113,9 +114,12 @@ __export(src_exports, {
113
114
  dashboardMetricData: () => dashboardMetricData,
114
115
  defaultCrm: () => defaultCrm,
115
116
  defineChapter: () => defineChapter,
117
+ deliverabilityHeaders: () => deliverabilityHeaders,
116
118
  drainMembershipEffects: () => drainMembershipEffects,
117
119
  edgeUnpaired: () => edgeUnpaired,
118
120
  emailGroupFrom: () => emailGroupFrom,
121
+ emailSenderWarnings: () => emailSenderWarnings,
122
+ emailSpamSignals: () => emailSpamSignals,
119
123
  endForSlot: () => endForSlot,
120
124
  exportChapterPromotion: () => exportChapterPromotion,
121
125
  findAdmissionGrant: () => findAdmissionGrant,
@@ -134,6 +138,7 @@ __export(src_exports, {
134
138
  introIdempotencyKey: () => introIdempotencyKey,
135
139
  isAdminRole: () => isAdminRole,
136
140
  isAlreadySent: () => isAlreadySent,
141
+ isRecipientFacing: () => isRecipientFacing,
137
142
  isReconcilable: () => isReconcilable,
138
143
  isSlotAvailable: () => isSlotAvailable,
139
144
  isValidEmail: () => isValidEmail,
@@ -142,6 +147,7 @@ __export(src_exports, {
142
147
  joinConfigTiers: () => joinConfigTiers,
143
148
  leaderCrmConfig: () => leaderCrmConfig,
144
149
  listStripeObjects: () => listStripeObjects,
150
+ listUnsubscribeHeader: () => listUnsubscribeHeader,
145
151
  loadLatestAdmissionRevision: () => loadLatestAdmissionRevision,
146
152
  loadOfferableTiers: () => loadOfferableTiers,
147
153
  loadTiers: () => loadTiers,
@@ -259,6 +265,7 @@ __export(src_exports, {
259
265
  submitApplication: () => submitApplication,
260
266
  subscriptionIdempotencyKey: () => subscriptionIdempotencyKey,
261
267
  syncApplicationToCrm: () => syncApplicationToCrm,
268
+ textToHtml: () => textToHtml,
262
269
  tierIsFree: () => tierIsFree,
263
270
  tierPayable: () => tierPayable,
264
271
  updateClerkUserMetadata: () => updateClerkUserMetadata,
@@ -1680,6 +1687,7 @@ var DEFAULT_ADMIN_COPY = {
1680
1687
  replyTo: "Reply-to",
1681
1688
  fromName: "From name",
1682
1689
  fromNameHint: "Shown as the sender; the address stays the Worker's configured one.",
1690
+ spamRisk: "Spam-filter risk",
1683
1691
  debugInbox: "Debug inbox",
1684
1692
  debugInboxHint: "Non-production sends redirect here.",
1685
1693
  sendTest: "Send test",
@@ -1997,8 +2005,8 @@ var APPLICATION_LABELS = {
1997
2005
  state: "State"
1998
2006
  };
1999
2007
  function humanize(id11) {
2000
- const text7 = id11.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
2001
- return text7 ? `${text7[0].toUpperCase()}${text7.slice(1)}` : id11;
2008
+ const text8 = id11.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
2009
+ return text8 ? `${text8[0].toUpperCase()}${text8.slice(1)}` : id11;
2002
2010
  }
2003
2011
  function applicationDefs(chapter) {
2004
2012
  try {
@@ -4665,6 +4673,9 @@ function namedSeatRecipientState(seat) {
4665
4673
  return seat.refundReason === "declined" ? "declined" : "refunded";
4666
4674
  case "canceled":
4667
4675
  return "canceled";
4676
+ // A card-network dispute on the seat's own payment pauses the recipient until it is won or re-charged.
4677
+ case "payment_disputed":
4678
+ return "disputed";
4668
4679
  default:
4669
4680
  return "payment_pending";
4670
4681
  }
@@ -4860,12 +4871,87 @@ async function reconcileMembershipRenewal(db, input, now) {
4860
4871
 
4861
4872
  // src/membership-dispute-authority.ts
4862
4873
  var import_stripe4 = require("@odla-ai/stripe");
4874
+
4875
+ // src/membership-dispute-transition.ts
4863
4876
  var MEMBERSHIP_DISPUTED_STATUS = "payment_disputed";
4864
4877
  var DISPUTE_WON_STATUSES = ["won", "warning_closed"];
4865
4878
  var CLOSED_STATUSES = [...DISPUTE_WON_STATUSES, "lost"];
4866
- var TERMINAL = ["refunded", "refund_pending", "canceled", "declined", "restarting", "superseded"];
4879
+ function disputeTransition(row2, fact, eventId, now, terminal) {
4880
+ const status2 = String(fact.status ?? "unknown"), closed = CLOSED_STATUSES.includes(status2);
4881
+ const sameDispute = row2.disputeId === fact.objectId;
4882
+ if (sameDispute && Number(row2.disputeClosedAt ?? 0) > 0 && !closed) return null;
4883
+ const won = DISPUTE_WON_STATUSES.includes(status2);
4884
+ const suspended = row2.status === MEMBERSHIP_DISPUTED_STATUS, done = terminal.includes(String(row2.status));
4885
+ const restored = String(row2.disputePriorStatus ?? (row2.journeyApprovedAt ? "active" : "paid_pending_approval"));
4886
+ const next = done ? String(row2.status) : won ? suspended ? restored : String(row2.status) : MEMBERSHIP_DISPUTED_STATUS;
4887
+ return {
4888
+ status: next,
4889
+ disputeId: fact.objectId,
4890
+ disputeStatus: status2,
4891
+ disputeEventId: eventId,
4892
+ disputeClosedAt: closed ? now : 0,
4893
+ ...fact.disputeReason ? { disputeReason: fact.disputeReason } : {},
4894
+ ...fact.amountCents !== void 0 ? { disputeAmountCents: fact.amountCents } : {},
4895
+ ...sameDispute ? {} : { disputedAt: now },
4896
+ ...next === MEMBERSHIP_DISPUTED_STATUS && !suspended ? { disputePriorStatus: String(row2.status) } : {},
4897
+ revision: Number(row2.revision) + 1,
4898
+ updatedAt: now
4899
+ };
4900
+ }
4901
+
4902
+ // src/membership-seat-dispute.ts
4867
4903
  var text3 = (value) => typeof value === "string" && value ? value : void 0;
4868
- var refOf = (value) => typeof value === "string" ? text3(value) : text3(value?.id);
4904
+ var TERMINAL = ["payment_pending", "payment_processing", "payment_canceling", "refund_pending", "refunded", "canceled"];
4905
+ var namedSeatDisputeStanding = (seat, applied) => ({ seatId: String(seat.id), status: String(seat.status), ...text3(seat.disputeStatus) ? { disputeStatus: text3(seat.disputeStatus) } : {}, applied });
4906
+ function disputedChargeCoversSeat(charge, seat) {
4907
+ return charge.seatId === seat.id && charge.customerId === seat.providerCustomerId && charge.paymentIntentId !== void 0 && (charge.paymentIntentId === seat.providerPaymentIntentId || charge.paymentIntentId === seat.rechargePaymentIntentId);
4908
+ }
4909
+ async function reconcileNamedSeatDispute(db, input, now, charge, evidence) {
4910
+ if (!charge.seatId) membershipFail("membership_dispute_scope_mismatch");
4911
+ const membership = async (applied, seat) => {
4912
+ const id11 = await networkMembershipId(input), member = await membershipLoad(db, "networkMembership", id11);
4913
+ requireMembershipScope(member, input);
4914
+ return {
4915
+ membershipId: id11,
4916
+ status: String(member.status),
4917
+ termEndsAt: Number(member.termEndsAt),
4918
+ graceEndsAt: Number(member.graceEndsAt ?? 0),
4919
+ autoRenew: member.autoRenew === true,
4920
+ applied,
4921
+ ...text3(member.disputeStatus) ? { disputeStatus: text3(member.disputeStatus) } : {},
4922
+ ...seat ? { seat: namedSeatDisputeStanding(seat, applied) } : {}
4923
+ };
4924
+ };
4925
+ for (let attempt = 0; attempt < 8; attempt++) {
4926
+ const state = await loadMembershipAuthorityState(db, input), seat = await membershipLoad(db, "networkSeat", charge.seatId);
4927
+ if (!seat) return membership(false);
4928
+ requireMembershipScope(seat, input);
4929
+ if (charge.customerId !== seat.providerCustomerId) membershipFail("membership_dispute_scope_mismatch");
4930
+ if (!disputedChargeCoversSeat(charge, seat) || seat.disputeEventId === input.eventId) return membership(seat.disputeEventId === input.eventId, seat);
4931
+ const attrs = disputeTransition(seat, evidence.fact, input.eventId, now, TERMINAL);
4932
+ if (!attrs) return membership(false, seat);
4933
+ try {
4934
+ await db.transact([membershipUpdate("networkSeat", String(seat.id), attrs)], { mutationId: `seat-dispute:${seat.id}:${input.eventId}`, guards: [
4935
+ { ns: "networkSeat", id: String(seat.id), exists: true, equals: { revision: seat.revision } },
4936
+ { ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative", revision: state.revision } },
4937
+ { ns: "networkCommercialEvents", id: String(evidence.row.id), exists: true, equals: { digest: evidence.fact.digest } },
4938
+ evidence.guard
4939
+ ] });
4940
+ } catch (error) {
4941
+ if (error.code !== "transact_guard_failed") throw error;
4942
+ continue;
4943
+ }
4944
+ const saved = await membershipLoad(db, "networkSeat", String(seat.id));
4945
+ requireMembershipScope(saved, input);
4946
+ return membership(true, saved);
4947
+ }
4948
+ return membershipFail("membership_dispute_retry_required");
4949
+ }
4950
+
4951
+ // src/membership-dispute-authority.ts
4952
+ var TERMINAL2 = ["refunded", "refund_pending", "canceled", "declined", "restarting", "superseded"];
4953
+ var text4 = (value) => typeof value === "string" && value ? value : void 0;
4954
+ var refOf = (value) => typeof value === "string" ? text4(value) : text4(value?.id);
4869
4955
  async function readDisputedCharge(secretKey, chargeId, fetch2) {
4870
4956
  if (!/^ch_[A-Za-z0-9]+$/.test(chargeId)) membershipFail("invalid_dispute_charge");
4871
4957
  const config = { secretKey, ...fetch2 ? { fetch: fetch2 } : {} };
@@ -4879,7 +4965,7 @@ async function readDisputedCharge(secretKey, chargeId, fetch2) {
4879
4965
  if (!invoice.ok || invoice.body.id !== invoiceId) membershipFail("membership_dispute_provider_unavailable");
4880
4966
  subscriptionId = refOf(invoice.body.subscription) ?? refOf(invoice.body.parent?.subscription_details?.subscription);
4881
4967
  }
4882
- const seatId = text3(charge.body.metadata?.seatId);
4968
+ const seatId = text4(charge.body.metadata?.seatId);
4883
4969
  return {
4884
4970
  chargeId,
4885
4971
  customerId,
@@ -4905,37 +4991,23 @@ async function reconcileMembershipDispute(db, input, now, secretKey, resolved) {
4905
4991
  termEndsAt: Number(value.termEndsAt),
4906
4992
  graceEndsAt: Number(value.graceEndsAt ?? 0),
4907
4993
  autoRenew: value.autoRenew === true,
4908
- ...text3(value.disputeStatus) ? { disputeStatus: text3(value.disputeStatus) } : {},
4994
+ ...text4(value.disputeStatus) ? { disputeStatus: text4(value.disputeStatus) } : {},
4909
4995
  applied
4910
4996
  });
4911
4997
  const known = await membershipLoad(db, "networkMembership", id11);
4912
4998
  requireMembershipScope(known, input);
4913
4999
  if (known.disputeEventId === input.eventId) return receipt(known);
5000
+ const seatKnown = known.namedSeatId ? await membershipLoad(db, "networkSeat", String(known.namedSeatId)) : void 0;
5001
+ if (seatKnown?.disputeEventId === input.eventId) return { ...receipt(known, true), seat: namedSeatDisputeStanding(seatKnown, true) };
4914
5002
  const charge = resolved && resolved.chargeId === fact.chargeId ? resolved : await readDisputedCharge(secretKey, fact.chargeId);
5003
+ if (charge.customerId !== known.providerCustomerId) membershipFail("membership_dispute_scope_mismatch");
5004
+ if (charge.seatId) return reconcileNamedSeatDispute(db, input, now, charge, { row: row2, fact, guard: evidenceGuard });
4915
5005
  for (let attempt = 0; attempt < 8; attempt++) {
4916
5006
  const state = await loadMembershipAuthorityState(db, input), member = await membershipLoad(db, "networkMembership", id11);
4917
5007
  requireMembershipScope(member, input);
4918
- if (charge.customerId !== member.providerCustomerId) membershipFail("membership_dispute_scope_mismatch");
4919
5008
  if (!disputedChargeCoversMembership(charge, member) || member.disputeEventId === input.eventId) return receipt(member);
4920
- const status2 = String(fact.status ?? "unknown"), closed = CLOSED_STATUSES.includes(status2);
4921
- const sameDispute = member.disputeId === fact.objectId;
4922
- if (sameDispute && Number(member.disputeClosedAt ?? 0) > 0 && !closed) return receipt(member, false);
4923
- const won = DISPUTE_WON_STATUSES.includes(status2);
4924
- const suspended = member.status === MEMBERSHIP_DISPUTED_STATUS, terminal = TERMINAL.includes(String(member.status));
4925
- const next = terminal ? String(member.status) : won ? suspended ? String(member.disputePriorStatus ?? (member.journeyApprovedAt ? "active" : "paid_pending_approval")) : String(member.status) : MEMBERSHIP_DISPUTED_STATUS;
4926
- const attrs = {
4927
- status: next,
4928
- disputeId: fact.objectId,
4929
- disputeStatus: status2,
4930
- disputeEventId: input.eventId,
4931
- disputeClosedAt: closed ? now : 0,
4932
- ...fact.disputeReason ? { disputeReason: fact.disputeReason } : {},
4933
- ...fact.amountCents !== void 0 ? { disputeAmountCents: fact.amountCents } : {},
4934
- ...sameDispute ? {} : { disputedAt: now },
4935
- ...next === MEMBERSHIP_DISPUTED_STATUS && !suspended ? { disputePriorStatus: String(member.status) } : {},
4936
- revision: Number(member.revision) + 1,
4937
- updatedAt: now
4938
- };
5009
+ const attrs = disputeTransition(member, fact, input.eventId, now, TERMINAL2);
5010
+ if (!attrs) return receipt(member, false);
4939
5011
  try {
4940
5012
  await db.transact([membershipUpdate("networkMembership", id11, attrs)], { mutationId: `membership-dispute:${id11}:${input.eventId}`, guards: [
4941
5013
  { ns: "networkMembership", id: id11, exists: true, equals: { revision: member.revision } },
@@ -6339,7 +6411,7 @@ async function readBoundedJson(response, signal, maxBytes, label) {
6339
6411
  };
6340
6412
  signal.addEventListener("abort", cancel, { once: true });
6341
6413
  let size = 0;
6342
- let text7 = "";
6414
+ let text8 = "";
6343
6415
  const decoder = new TextDecoder();
6344
6416
  try {
6345
6417
  while (true) {
@@ -6349,10 +6421,10 @@ async function readBoundedJson(response, signal, maxBytes, label) {
6349
6421
  if (done) break;
6350
6422
  size += value.byteLength;
6351
6423
  if (size > maxBytes) throw new TypeError(`${label} exceeds limit`);
6352
- text7 += decoder.decode(value, { stream: true });
6424
+ text8 += decoder.decode(value, { stream: true });
6353
6425
  }
6354
6426
  try {
6355
- return JSON.parse(text7 + decoder.decode());
6427
+ return JSON.parse(text8 + decoder.decode());
6356
6428
  } catch {
6357
6429
  throw new TypeError(`invalid ${label}`);
6358
6430
  }
@@ -6839,6 +6911,19 @@ var id10 = () => attr9("string", false, true, true);
6839
6911
  var key2 = (optional = false) => attr9("string", optional, false, true);
6840
6912
  var scope = () => ({ appId: key2(), chapterId: key2(), environment: key2(), runtime: key2(), applicationId: key2(), skuId: key2() });
6841
6913
  var times = () => ({ revision: attr9("number"), createdAt: attr9("number"), updatedAt: attr9("number") });
6914
+ var dispute = () => ({
6915
+ disputeId: key2(true),
6916
+ disputeStatus: attr9("string", true),
6917
+ disputeReason: attr9("string", true),
6918
+ disputeAmountCents: attr9("number", true),
6919
+ disputedAt: attr9("number", true),
6920
+ disputeClosedAt: attr9("number", true),
6921
+ disputePriorStatus: attr9("string", true),
6922
+ disputeEventId: key2(true),
6923
+ rechargePaymentIntentId: key2(true),
6924
+ rechargedAt: attr9("number", true),
6925
+ rechargeAttempt: attr9("json", true)
6926
+ });
6842
6927
  var membershipAuthorityEntities = {
6843
6928
  networkMembership: { attrs: {
6844
6929
  id: id10(),
@@ -6879,19 +6964,7 @@ var membershipAuthorityEntities = {
6879
6964
  ...times(),
6880
6965
  refundRequestedAt: attr9("number", true),
6881
6966
  restartId: key2(true),
6882
- // A card-network dispute on this membership's payment: Stripe's dispute id and status, the reason the cardholder gave,
6883
- // the status the membership held before it was suspended, and the re-issued charge once an operator collected again.
6884
- disputeId: key2(true),
6885
- disputeStatus: attr9("string", true),
6886
- disputeReason: attr9("string", true),
6887
- disputeAmountCents: attr9("number", true),
6888
- disputedAt: attr9("number", true),
6889
- disputeClosedAt: attr9("number", true),
6890
- disputePriorStatus: attr9("string", true),
6891
- disputeEventId: key2(true),
6892
- rechargePaymentIntentId: key2(true),
6893
- rechargedAt: attr9("number", true),
6894
- rechargeAttempt: attr9("json", true)
6967
+ ...dispute()
6895
6968
  } },
6896
6969
  membershipRestart: { attrs: {
6897
6970
  id: id10(),
@@ -6967,7 +7040,8 @@ var membershipAuthorityEntities = {
6967
7040
  refundedCents: attr9("number", true),
6968
7041
  providerRefundIds: attr9("json", true),
6969
7042
  regiftedAt: attr9("number", true),
6970
- formerRecipients: attr9("json", true)
7043
+ formerRecipients: attr9("json", true),
7044
+ ...dispute()
6971
7045
  } },
6972
7046
  networkSeatRecipient: { attrs: { id: id10(), appId: key2(), chapterId: key2(), environment: key2(), runtime: key2(), seatId: key2(), recipientEmailNormalized: key2(), createdAt: attr9("number") } },
6973
7047
  membershipEffect: { attrs: {
@@ -7327,7 +7401,7 @@ var record2 = (value) => value !== null && typeof value === "object" && !Array.i
7327
7401
  function unknownKeys2(value, allowed2, path, errors) {
7328
7402
  for (const key3 of Object.keys(value)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
7329
7403
  }
7330
- function text4(value, path, errors, max = MAX_TEXT2, required = true) {
7404
+ function text5(value, path, errors, max = MAX_TEXT2, required = true) {
7331
7405
  if (value === void 0 && !required) return void 0;
7332
7406
  if (typeof value !== "string" || required && !value.trim()) {
7333
7407
  errors.push(`${path} must be a non-empty string`);
@@ -7353,7 +7427,7 @@ function scopeValue(value, path, errors) {
7353
7427
  return { kind: raw2.kind };
7354
7428
  }
7355
7429
  if (raw2.kind === "chapter") {
7356
- const chapterId = text4(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
7430
+ const chapterId = text5(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
7357
7431
  if (chapterId && !APP_ID.test(chapterId)) errors.push(`${path}.chapterId must be a lowercase app slug`);
7358
7432
  return { kind: "chapter", chapterId };
7359
7433
  }
@@ -7367,22 +7441,22 @@ function resourceValue(value, path, errors) {
7367
7441
  return null;
7368
7442
  }
7369
7443
  unknownKeys2(raw2, RESOURCE_KEYS, path, errors);
7370
- const id11 = text4(raw2.id, `${path}.id`, errors, 80) ?? "";
7444
+ const id11 = text5(raw2.id, `${path}.id`, errors, 80) ?? "";
7371
7445
  if (id11 && !ID2.test(id11)) errors.push(`${path}.id must be lowercase kebab-case`);
7372
- const kind = text4(raw2.kind, `${path}.kind`, errors) ?? "";
7446
+ const kind = text5(raw2.kind, `${path}.kind`, errors) ?? "";
7373
7447
  if (!KINDS.has(kind)) errors.push(`${path}.kind is unsupported`);
7374
- const title = text4(raw2.title, `${path}.title`, errors, 240) ?? "";
7375
- const summary = text4(raw2.summary, `${path}.summary`, errors, 1e3, false);
7376
- const status2 = text4(raw2.status, `${path}.status`, errors) ?? "";
7448
+ const title = text5(raw2.title, `${path}.title`, errors, 240) ?? "";
7449
+ const summary = text5(raw2.summary, `${path}.summary`, errors, 1e3, false);
7450
+ const status2 = text5(raw2.status, `${path}.status`, errors) ?? "";
7377
7451
  if (!STATUSES.has(status2)) errors.push(`${path}.status must be draft, published, or archived`);
7378
7452
  const revision = integer3(raw2.revision, `${path}.revision`, errors);
7379
7453
  const scope2 = scopeValue(raw2.scope, `${path}.scope`, errors) ?? { kind: "global" };
7380
- const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text4(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
7454
+ const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text5(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
7381
7455
  if (!Array.isArray(raw2.tags)) errors.push(`${path}.tags must be an array`);
7382
7456
  if (tags.length > MAX_TAGS) errors.push(`${path}.tags may contain at most ${MAX_TAGS} items`);
7383
7457
  if (new Set(tags).size !== tags.length) errors.push(`${path}.tags must be unique`);
7384
- const bodyMarkdown = text4(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2, false);
7385
- const url = text4(raw2.url, `${path}.url`, errors, 2048, false);
7458
+ const bodyMarkdown = text5(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2, false);
7459
+ const url = text5(raw2.url, `${path}.url`, errors, 2048, false);
7386
7460
  if (url) {
7387
7461
  try {
7388
7462
  if (new URL(url).protocol !== "https:") errors.push(`${path}.url must use https`);
@@ -7394,9 +7468,9 @@ function resourceValue(value, path, errors) {
7394
7468
  if (raw2.brandAsset !== void 0 && !asset) errors.push(`${path}.brandAsset must be an object`);
7395
7469
  if (asset) unknownKeys2(asset, ASSET_KEYS, `${path}.brandAsset`, errors);
7396
7470
  const brandAsset = asset ? {
7397
- bookId: text4(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
7398
- assetId: text4(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
7399
- ...asset.filename === void 0 ? {} : { filename: text4(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
7471
+ bookId: text5(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
7472
+ assetId: text5(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
7473
+ ...asset.filename === void 0 ? {} : { filename: text5(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
7400
7474
  } : void 0;
7401
7475
  if ((kind === "document" || kind === "note") && !bodyMarkdown) errors.push(`${path}.bodyMarkdown is required for ${kind}`);
7402
7476
  if ((kind === "link" || kind === "inspiration") && !url) errors.push(`${path}.url is required for ${kind}`);
@@ -7411,17 +7485,17 @@ function resourceValue(value, path, errors) {
7411
7485
  if (provenanceRaw?.promotedFrom !== void 0 && !promotedRaw) errors.push(`${path}.provenance.promotedFrom must be an object`);
7412
7486
  if (promotedRaw) unknownKeys2(promotedRaw, PROMOTION_KEYS, `${path}.provenance.promotedFrom`, errors);
7413
7487
  const promotedFrom = promotedRaw ? {
7414
- chapterId: text4(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
7415
- resourceId: text4(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
7488
+ chapterId: text5(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
7489
+ resourceId: text5(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
7416
7490
  resourceRevision: integer3(promotedRaw.resourceRevision, `${path}.provenance.promotedFrom.resourceRevision`, errors),
7417
7491
  documentRevision: integer3(promotedRaw.documentRevision, `${path}.provenance.promotedFrom.documentRevision`, errors),
7418
- promotedBy: text4(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
7492
+ promotedBy: text5(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
7419
7493
  promotedAt: integer3(promotedRaw.promotedAt, `${path}.provenance.promotedFrom.promotedAt`, errors, 0)
7420
7494
  } : void 0;
7421
7495
  const provenance = {
7422
- createdBy: text4(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
7496
+ createdBy: text5(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
7423
7497
  createdAt: integer3(provenanceRaw?.createdAt, `${path}.provenance.createdAt`, errors, 0),
7424
- updatedBy: text4(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
7498
+ updatedBy: text5(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
7425
7499
  updatedAt: integer3(provenanceRaw?.updatedAt, `${path}.provenance.updatedAt`, errors, 0),
7426
7500
  ...promotedFrom ? { promotedFrom } : {}
7427
7501
  };
@@ -7440,7 +7514,7 @@ function validateBuilderLibraryDocument(value, options = {}) {
7440
7514
  if (sourceRaw) unknownKeys2(sourceRaw, SOURCE_KEYS2, "document.source", errors);
7441
7515
  const sourceType = sourceRaw?.type;
7442
7516
  if (sourceType !== "chapter" && sourceType !== "built_not_found") errors.push("document.source.type must be chapter or built_not_found");
7443
- const sourceAppId = text4(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
7517
+ const sourceAppId = text5(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
7444
7518
  if (sourceAppId && !APP_ID.test(sourceAppId)) errors.push("document.source.appId must be a lowercase app slug");
7445
7519
  if (options.sourceType && sourceType !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
7446
7520
  if (options.sourceAppId && sourceAppId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
@@ -7448,8 +7522,8 @@ function validateBuilderLibraryDocument(value, options = {}) {
7448
7522
  if (raw2.target !== void 0 && !targetRaw) errors.push("document.target must be an object");
7449
7523
  if (targetRaw) unknownKeys2(targetRaw, TARGET_KEYS2, "document.target", errors);
7450
7524
  const target = targetRaw ? {
7451
- appId: text4(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
7452
- environment: text4(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
7525
+ appId: text5(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
7526
+ environment: text5(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
7453
7527
  } : void 0;
7454
7528
  if (options.targetAppId && target?.appId !== options.targetAppId) errors.push(`document.target.appId must be ${options.targetAppId}`);
7455
7529
  if (!Array.isArray(raw2.resources)) errors.push("document.resources must be an array");
@@ -7544,9 +7618,9 @@ async function createBuilderLibraryEnvelope(document) {
7544
7618
  }
7545
7619
 
7546
7620
  // src/email-retirement.ts
7547
- function isRetiredEmailCopy(subject, text7) {
7621
+ function isRetiredEmailCopy(subject, text8) {
7548
7622
  const subjectCopy = subject.trimStart().replace(/^\[dev\]\s*/i, "");
7549
- const bodyCopy = text7.trimStart().replace(/^\(dev redirect; original recipient: [^\r\n]*\)\r?\n\r?\n/, "");
7623
+ const bodyCopy = text8.trimStart().replace(/^\(dev redirect; original recipient: [^\r\n]*\)\r?\n\r?\n/, "");
7550
7624
  return /^\(\s*unused\s*:/i.test(subjectCopy) || /^Unused\.\s*Switched off\b/i.test(bodyCopy);
7551
7625
  }
7552
7626
 
@@ -7624,14 +7698,92 @@ function planDelivery(input) {
7624
7698
  if (isRetiredEmailCopy(renderedSubject, renderedText)) return { deliver: false, reason: "template-retired" };
7625
7699
  if ((0, import_crm5.validateEmailCopy)({ subject: renderedSubject, text: renderedText }, { allowedVariables: [] }).length) return { deliver: false, reason: "template-invalid" };
7626
7700
  const subject = (redirect ? "[dev] " : "") + renderedSubject;
7627
- const text7 = redirect ? `(dev redirect; original recipient: ${input.to})
7701
+ const text8 = redirect ? `(dev redirect; original recipient: ${input.to})
7628
7702
 
7629
7703
  ` + renderedText : renderedText;
7630
- return { deliver: true, transport, to, subject, text: text7, redirected: redirect };
7704
+ return { deliver: true, transport, to, subject, text: text8, redirected: redirect };
7631
7705
  }
7632
7706
 
7633
7707
  // src/email-readiness.ts
7634
7708
  var import_crm6 = require("@odla-ai/crm");
7709
+
7710
+ // src/email-spam-signals.ts
7711
+ var TRIGGER_PHRASES = [
7712
+ "act now",
7713
+ "click here",
7714
+ "limited time",
7715
+ "guaranteed",
7716
+ "risk-free",
7717
+ "risk free",
7718
+ "no obligation",
7719
+ "100% free",
7720
+ "winner",
7721
+ "congratulations",
7722
+ "urgent",
7723
+ "double your",
7724
+ "earn money",
7725
+ "make money",
7726
+ "cash bonus",
7727
+ "once in a lifetime",
7728
+ "exclusive deal",
7729
+ "order now",
7730
+ "buy now",
7731
+ "this is not spam",
7732
+ "dear friend",
7733
+ "dear customer"
7734
+ ];
7735
+ var SUBJECT_TRIGGERS = ["free", ...TRIGGER_PHRASES];
7736
+ var DEV_HOSTS = /(?:^|\.)(?:workers\.dev|pages\.dev|localhost|ngrok(?:-free)?\.app|trycloudflare\.com)$/i;
7737
+ var SHORTENERS = /^(?:bit\.ly|tinyurl\.com|t\.co|goo\.gl|ow\.ly|is\.gd|buff\.ly|rebrand\.ly|cutt\.ly|tiny\.cc|shorturl\.at|rb\.gy)$/i;
7738
+ var URL_RE = /https?:\/\/[^\s<>"')\]]+/g;
7739
+ var strip = (s) => s.replace(/\{\{\w+\}\}/g, " ");
7740
+ var words = (s) => strip(s).split(/\s+/).filter(Boolean);
7741
+ var phraseIn = (phrases, haystack) => phrases.find((phrase) => new RegExp(`(?:^|[^a-z0-9])${phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9])`, "i").test(haystack));
7742
+ function emailSpamSignals(copy) {
7743
+ const subject = typeof copy.subject === "string" ? copy.subject : "";
7744
+ const text8 = typeof copy.text === "string" ? copy.text : "";
7745
+ const out = [];
7746
+ const letters = strip(subject).replace(/[^A-Za-z]/g, "");
7747
+ const shouting = letters.length >= 6 && letters.replace(/[^A-Z]/g, "").length / letters.length > 0.5 || words(subject).some((word) => /^[A-Z]{4,}$/.test(word.replace(/[^A-Za-z]/g, "")));
7748
+ if (shouting) out.push({ code: "subject-caps", message: "The subject shouts in capital letters. Filters read that as a spam signal; use sentence case." });
7749
+ if (/[!?]{2,}|!\s*$/.test(subject)) out.push({ code: "subject-punctuation", message: "Repeated or trailing exclamation marks in the subject are a classic spam signal." });
7750
+ if (/^\s*(?:re|fw|fwd)\s*:/i.test(subject)) out.push({ code: "subject-fake-reply", message: "A subject that starts like a reply or forward reads as a spoof when the recipient never wrote first." });
7751
+ const subjectHit = phraseIn(SUBJECT_TRIGGERS, strip(subject));
7752
+ if (subjectHit) out.push({ code: "subject-trigger-words", message: `The subject uses "${subjectHit}", a word filters weight against the sender.` });
7753
+ const bodyHit = phraseIn(TRIGGER_PHRASES, strip(text8));
7754
+ if (bodyHit) out.push({ code: "body-trigger-words", message: `The body uses "${bodyHit}", a phrase filters weight against the sender.` });
7755
+ if (/(?:^|\s)[A-Z]{3,}(?:\s+[A-Z]{3,}){2,}(?=\s|$)/.test(strip(text8))) out.push({ code: "body-caps", message: "The body shouts a run of capitalised words; use sentence case." });
7756
+ if (/!{2,}/.test(text8)) out.push({ code: "body-punctuation", message: "Repeated exclamation marks in the body are a classic spam signal." });
7757
+ const urls = [...text8.matchAll(URL_RE)].map((match) => match[0]);
7758
+ const hosts = urls.map((url) => {
7759
+ try {
7760
+ return new URL(url).hostname;
7761
+ } catch {
7762
+ return "";
7763
+ }
7764
+ });
7765
+ if (urls.some((url) => /^http:\/\//i.test(url))) out.push({ code: "link-insecure", message: "A link uses http:// instead of https://. Filters flag insecure links." });
7766
+ if (hosts.some((host) => DEV_HOSTS.test(host) || /^\d+\.\d+\.\d+\.\d+$/.test(host))) out.push({ code: "link-origin", message: "A link points at a development host (workers.dev, pages.dev, localhost or a raw IP). Link to the site's own domain." });
7767
+ if (hosts.some((host) => SHORTENERS.test(host))) out.push({ code: "link-shortener", message: "A link uses a URL shortener, which hides the destination and is heavily abused. Link to the real address." });
7768
+ if (new Set(urls).size > 5) out.push({ code: "link-many", message: "The body has more than five links. Keep lifecycle mail to the one or two the reader needs." });
7769
+ if (urls.length && words(text8).length < 20) out.push({ code: "body-link-only", message: "The body is little more than a link. Say what the reader is being sent and why before the address." });
7770
+ return out;
7771
+ }
7772
+ function emailSenderWarnings(input) {
7773
+ const out = [];
7774
+ const from = (input.fromEmail ?? "").trim().toLowerCase().replace(/^.*<([^>]+)>.*$/, "$1");
7775
+ const reply = (input.replyTo ?? "").trim().toLowerCase();
7776
+ const domain = (address) => address.split("@")[1] ?? "";
7777
+ if (/^(?:no-?reply|do-?not-?reply|donotreply)(?:[+.-][^@]*)?@/.test(from)) {
7778
+ out.push({ code: "sender-no-reply", message: "Mail is sent from a no-reply address. Gmail's sender guidelines advise against it; send from a monitored address so replies and unsubscribe requests reach someone." });
7779
+ }
7780
+ if (domain(from) && domain(reply) && domain(from) !== domain(reply)) {
7781
+ out.push({ code: "reply-to-domain", message: `Replies go to ${domain(reply)} while mail is sent from ${domain(from)}. A Reply-To on another domain is a mild spam signal; prefer an address on the sending domain.` });
7782
+ }
7783
+ return out;
7784
+ }
7785
+
7786
+ // src/email-readiness.ts
7635
7787
  function validateEmailReadiness(input) {
7636
7788
  const issues = [];
7637
7789
  for (const key3 of Object.keys(input.templates)) if (!input.events[key3]) {
@@ -7656,9 +7808,56 @@ ${template.text ?? ""}`.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
7656
7808
  }
7657
7809
  if (event2.requiresTier && input.tiersUnavailable) problems.push({ code: "tiers-unavailable", message: "Tier names are unavailable; delivery checks will block unresolved memberships." });
7658
7810
  else if (event2.requiresTier && noTierNames) problems.push({ code: "tiers-missing", message: "Configure the membership tier names needed by this event." });
7659
- return { ...event2, key: key3, status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready", issues: problems };
7811
+ return {
7812
+ ...event2,
7813
+ key: key3,
7814
+ status: template?.enabled === false ? "disabled" : problems.length ? "blocked" : "ready",
7815
+ issues: problems,
7816
+ warnings: template ? emailSpamSignals(template) : []
7817
+ };
7660
7818
  });
7661
- return { checkedAt: input.now ?? Date.now(), events: events2, issues };
7819
+ return { checkedAt: input.now ?? Date.now(), events: events2, issues, warnings: emailSenderWarnings(input.sender ?? {}) };
7820
+ }
7821
+
7822
+ // src/email-html.ts
7823
+ var URL_RE2 = /https?:\/\/[^\s<>"')\]]+[^\s<>"').,;:!?\]]/g;
7824
+ function escapeHtml(s) {
7825
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
7826
+ }
7827
+ function lineHtml(line) {
7828
+ let out = "", last = 0;
7829
+ for (const match of line.matchAll(URL_RE2)) {
7830
+ out += escapeHtml(line.slice(last, match.index));
7831
+ const url = escapeHtml(match[0]);
7832
+ out += `<a href="${url}">${url}</a>`;
7833
+ last = match.index + match[0].length;
7834
+ }
7835
+ return out + escapeHtml(line.slice(last));
7836
+ }
7837
+ function textToHtml(text8) {
7838
+ const paragraphs = text8.replace(/\r\n/g, "\n").trim().split(/\n{2,}/).filter((paragraph) => paragraph.trim());
7839
+ const body = paragraphs.map((paragraph) => `<p>${paragraph.split("\n").map(lineHtml).join("<br>\n")}</p>`).join("\n");
7840
+ return `<!doctype html>
7841
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
7842
+ <body style="margin:0;padding:24px 16px;background:#ffffff;color:#1a1a1a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;font-size:16px;line-height:1.5">
7843
+ ${body}
7844
+ </body></html>`;
7845
+ }
7846
+
7847
+ // src/email-deliverability.ts
7848
+ var ADMINISTRATOR_AUDIENCE = /^(?:administrator|admin|team|operator|owner|staff)s?$/i;
7849
+ function isRecipientFacing(event2) {
7850
+ return !event2 || !ADMINISTRATOR_AUDIENCE.test(event2.audience.trim());
7851
+ }
7852
+ function listUnsubscribeHeader(replyTo) {
7853
+ const address = replyTo.trim();
7854
+ if (!/^[^\s@,;<>?]+@[^\s@,;<>?]+\.[^\s@,;<>?]+$/.test(address)) return void 0;
7855
+ return `<mailto:${address}?subject=${encodeURIComponent("unsubscribe")}>`;
7856
+ }
7857
+ function deliverabilityHeaders(input) {
7858
+ if (!isRecipientFacing(input.event)) return void 0;
7859
+ const value = listUnsubscribeHeader(input.replyTo);
7860
+ return value ? { "List-Unsubscribe": value } : void 0;
7662
7861
  }
7663
7862
 
7664
7863
  // src/notify.ts
@@ -7826,11 +8025,16 @@ async function sendTemplated(deps, input) {
7826
8025
  if (decision.transport === "log-only") return { sent: true };
7827
8026
  let messageId;
7828
8027
  try {
8028
+ const text8 = String(row2.body);
8029
+ const html = deps.renderHtml === false ? void 0 : (deps.renderHtml ?? textToHtml)(text8);
8030
+ const headers = deliverabilityHeaders({ event: event2, replyTo: input.group.replyTo });
7829
8031
  const response = await deps.sender.send({
7830
8032
  from: fromAddress(deps.from, input.group.fromName),
7831
8033
  to: [decision.to],
7832
8034
  subject: String(row2.subject),
7833
- text: String(row2.body),
8035
+ text: text8,
8036
+ ...html ? { html } : {},
8037
+ ...headers ? { headers } : {},
7834
8038
  replyTo: input.group.replyTo
7835
8039
  });
7836
8040
  if (!response || typeof response.messageId !== "string" || !response.messageId) throw new Error("missing delivery receipt");
@@ -8283,7 +8487,7 @@ var ID4 = /^[a-z][a-z0-9-]{1,79}$/;
8283
8487
  var PRICE = /^price_[A-Za-z0-9]{12,}$/;
8284
8488
  var CURRENCY = /^[a-z]{3}$/;
8285
8489
  var record3 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
8286
- var text5 = (value, path, errors, max) => {
8490
+ var text6 = (value, path, errors, max) => {
8287
8491
  if (typeof value !== "string" || !value.trim()) {
8288
8492
  errors.push(`${path} is required`);
8289
8493
  return "";
@@ -8311,23 +8515,23 @@ function validateGiftOfferCatalog(input, tierIds) {
8311
8515
  }
8312
8516
  const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
8313
8517
  for (const key3 of Object.keys(item)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
8314
- const id11 = text5(item.id, `${path}.id`, errors, 80);
8518
+ const id11 = text6(item.id, `${path}.id`, errors, 80);
8315
8519
  if (id11 && !ID4.test(id11)) errors.push(`${path}.id must be a lowercase kebab-case id`);
8316
8520
  if (ids2.has(id11)) errors.push(`${path}.id duplicates "${id11}"`);
8317
8521
  ids2.add(id11);
8318
8522
  const targetRaw = record3(item.target);
8319
8523
  let target = { type: "entitlement", entitlementKey: "invalid" };
8320
8524
  if (targetRaw?.type === "tier") {
8321
- const tierId = text5(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
8525
+ const tierId = text6(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
8322
8526
  if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
8323
8527
  target = { type: "tier", tierId };
8324
8528
  } else if (targetRaw?.type === "entitlement") {
8325
- const entitlementKey = text5(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
8529
+ const entitlementKey = text6(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
8326
8530
  if (entitlementKey && !ID4.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
8327
8531
  target = { type: "entitlement", entitlementKey };
8328
8532
  } else errors.push(`${path}.target.type must be tier or entitlement`);
8329
8533
  if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
8330
- const stripePriceId = text5(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
8534
+ const stripePriceId = text6(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
8331
8535
  if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
8332
8536
  if (!Number.isSafeInteger(item.priceCents) || Number(item.priceCents) < 1 || Number(item.priceCents) > 1e8) errors.push(`${path}.priceCents must be an integer from 1 to 100,000,000`);
8333
8537
  const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
@@ -8349,9 +8553,9 @@ function validateGiftOfferCatalog(input, tierIds) {
8349
8553
  redemptionLifetimeHours: lifetime,
8350
8554
  ...activeFrom !== void 0 ? { activeFrom } : {},
8351
8555
  ...activeUntil !== void 0 ? { activeUntil } : {},
8352
- title: text5(item.title, `${path}.title`, errors, 160),
8353
- description: text5(item.description, `${path}.description`, errors, 2e3),
8354
- ctaLabel: text5(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
8556
+ title: text6(item.title, `${path}.title`, errors, 160),
8557
+ description: text6(item.description, `${path}.description`, errors, 2e3),
8558
+ ctaLabel: text6(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
8355
8559
  });
8356
8560
  }
8357
8561
  return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
@@ -9434,10 +9638,10 @@ function boundedString(value, path, errors, max, required = false) {
9434
9638
  errors.push(`${path} must be a string`);
9435
9639
  return void 0;
9436
9640
  }
9437
- const text7 = value.trim();
9438
- if (required && !text7) errors.push(`${path} is required`);
9439
- if (text7.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
9440
- return text7;
9641
+ const text8 = value.trim();
9642
+ if (required && !text8) errors.push(`${path} is required`);
9643
+ if (text8.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
9644
+ return text8;
9441
9645
  }
9442
9646
  function validateMembershipPageDocument(input, options = {}) {
9443
9647
  const errors = [];
@@ -9486,8 +9690,8 @@ function validateMembershipPageDocument(input, options = {}) {
9486
9690
  else {
9487
9691
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
9488
9692
  benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
9489
- const text7 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
9490
- return text7 ? [text7] : [];
9693
+ const text8 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
9694
+ return text8 ? [text8] : [];
9491
9695
  });
9492
9696
  }
9493
9697
  }
@@ -9586,7 +9790,7 @@ var recordOf = (value) => value !== null && typeof value === "object" && !Array.
9586
9790
  function unknownKeys3(value, allowed2, path, errors) {
9587
9791
  for (const key3 of Object.keys(value)) if (!allowed2.has(key3)) errors.push(`${path}.${key3} is not supported`);
9588
9792
  }
9589
- function text6(value, path, errors, max = 65536) {
9793
+ function text7(value, path, errors, max = 65536) {
9590
9794
  if (typeof value !== "string" || !value.trim()) {
9591
9795
  errors.push(`${path} is required`);
9592
9796
  return "";
@@ -9609,11 +9813,11 @@ function tiersOf(value, errors) {
9609
9813
  return [];
9610
9814
  }
9611
9815
  unknownKeys3(item, TIER_KEYS2, path, errors);
9612
- const id11 = text6(item.id, `${path}.id`, errors, 80);
9816
+ const id11 = text7(item.id, `${path}.id`, errors, 80);
9613
9817
  if (id11 && !TIER.test(id11)) errors.push(`${path}.id must be a lowercase kebab-case id`);
9614
9818
  if (seen.has(id11)) errors.push(`${path}.id duplicates "${id11}"`);
9615
9819
  seen.add(id11);
9616
- const name = text6(item.name, `${path}.name`, errors, 160);
9820
+ const name = text7(item.name, `${path}.name`, errors, 160);
9617
9821
  if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
9618
9822
  if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
9619
9823
  if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
@@ -9631,7 +9835,7 @@ function tiersOf(value, errors) {
9631
9835
  if (item.interval !== void 0 && item.interval !== "year" && item.interval !== "month") {
9632
9836
  errors.push(`${path}.interval must be year or month`);
9633
9837
  }
9634
- const blurb = item.blurb === void 0 ? void 0 : text6(item.blurb, `${path}.blurb`, errors, 4e3);
9838
+ const blurb = item.blurb === void 0 ? void 0 : text7(item.blurb, `${path}.blurb`, errors, 4e3);
9635
9839
  return [{
9636
9840
  id: id11,
9637
9841
  name,
@@ -9689,7 +9893,7 @@ function policyOf(value, errors, tiers2) {
9689
9893
  const result = {};
9690
9894
  for (const key3 of allowed2) {
9691
9895
  if (input[key3] === void 0 && key3 !== "disclaimerText" && key3 !== "refundPolicyText") continue;
9692
- result[key3] = text6(input[key3], `document.policy.${key3}`, errors);
9896
+ result[key3] = text7(input[key3], `document.policy.${key3}`, errors);
9693
9897
  }
9694
9898
  const tierPolicies = {};
9695
9899
  if (input.tierPolicies !== void 0) {
@@ -9704,8 +9908,8 @@ function policyOf(value, errors, tiers2) {
9704
9908
  }
9705
9909
  unknownKeys3(policy2, /* @__PURE__ */ new Set(["refundPolicyText", "trustCopy"]), path, errors);
9706
9910
  tierPolicies[id11] = {
9707
- refundPolicyText: text6(policy2.refundPolicyText, `${path}.refundPolicyText`, errors),
9708
- ...policy2.trustCopy !== void 0 ? { trustCopy: text6(policy2.trustCopy, `${path}.trustCopy`, errors) } : {}
9911
+ refundPolicyText: text7(policy2.refundPolicyText, `${path}.refundPolicyText`, errors),
9912
+ ...policy2.trustCopy !== void 0 ? { trustCopy: text7(policy2.trustCopy, `${path}.trustCopy`, errors) } : {}
9709
9913
  };
9710
9914
  }
9711
9915
  }
@@ -9745,9 +9949,9 @@ function validateSignupControlDocument(input) {
9745
9949
  if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
9746
9950
  const source = recordOf(root.source);
9747
9951
  const target = recordOf(root.target);
9748
- const appId = text6(source?.appId, "document.source.appId", errors, 63);
9749
- const chapterId = text6(target?.chapterId, "document.target.chapterId", errors, 63);
9750
- const environment = text6(target?.environment, "document.target.environment", errors, 63);
9952
+ const appId = text7(source?.appId, "document.source.appId", errors, 63);
9953
+ const chapterId = text7(target?.chapterId, "document.target.chapterId", errors, 63);
9954
+ const environment = text7(target?.environment, "document.target.environment", errors, 63);
9751
9955
  if (appId && !SLUG9.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
9752
9956
  if (chapterId && !SLUG9.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
9753
9957
  if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
@@ -9924,9 +10128,9 @@ function markdownBenefits(lines, path, errors) {
9924
10128
  errors.push(`${path} line ${index2 + 1} must be a Markdown bullet`);
9925
10129
  continue;
9926
10130
  }
9927
- const text7 = (match[1] ?? "").trim();
9928
- if (text7.length > 2e3) errors.push(`${path} line ${index2 + 1} must be at most 2,000 characters`);
9929
- if (text7) benefits.push(text7);
10131
+ const text8 = (match[1] ?? "").trim();
10132
+ if (text8.length > 2e3) errors.push(`${path} line ${index2 + 1} must be at most 2,000 characters`);
10133
+ if (text8) benefits.push(text8);
9930
10134
  }
9931
10135
  if (benefits.length > MAX_BENEFITS2) errors.push(`${path} supports at most ${MAX_BENEFITS2} entries`);
9932
10136
  return benefits.slice(0, MAX_BENEFITS2);