@odla-ai/chapter 0.45.0 → 0.46.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/README.md CHANGED
@@ -811,6 +811,8 @@ export const chapter = defineChapter({
811
811
  name: "Example Chapter",
812
812
  url: "https://chapter.example.com",
813
813
  fields: { person: ["name", "email"] },
814
+ // Independent consent to compare the chapter's commercial projection.
815
+ commercialParity: true,
814
816
  }],
815
817
  },
816
818
  });
@@ -900,6 +902,20 @@ removing it changes no customer-facing behavior. The BNF frontend may be down
900
902
  while the webhook continues to ingest. Registering a Stripe endpoint and
901
903
  enabling production remain separate reviewed operations.
902
904
 
905
+ `POST` with `{ command: "reconcile-chapters" }` is a separate, two-sided opt-in
906
+ comparison. A hub target and the matching follower reader must both set
907
+ `commercialParity: true`; neither record browsing nor Stripe shadow mode grants
908
+ it implicitly. The hub signs a `GET /api/network/commercial-parity` request with
909
+ the edge's `network_share_*` secret. The chapter returns only bounded status,
910
+ cancellation, counts, completeness, and domain-separated HMAC-SHA-256 digests of
911
+ application, Stripe customer, and Stripe subscription identifiers. Name, email,
912
+ raw identifiers, and arbitrary application fields never cross the edge. The hub
913
+ compares those digests with its exact Stripe partition and reports chapter-only,
914
+ shadow-only, cancellation, count, unavailable, invalid, and truncation
915
+ exceptions without persisting or changing either side. Chapter application
916
+ status and Stripe subscription status remain distinct vocabularies and are shown
917
+ side by side rather than falsely compared for equality.
918
+
903
919
  For a hub with network targets, the standard admin catalog is Dashboard,
904
920
  Network, People, Portfolio, and Settings. Dashboard Overview renders aggregate network
905
921
  health; People contains CRM types whose `workspace` is absent or `"people"`;
@@ -934,6 +950,8 @@ export const chapter = defineChapter({
934
950
  editableFields: { person: ["name", "email", "phone", "linkedin"] },
935
951
  stageTransitions: ["person"],
936
952
  admissionGrants: true,
953
+ // Independent disclosure consent. This may be the reader's only ability.
954
+ commercialParity: true,
937
955
  }],
938
956
  },
939
957
  });
@@ -229,6 +229,10 @@ interface ChapterNetworkTarget {
229
229
  /** Allow package-owned desired admission decisions for existing leader CRM
230
230
  * people to be delivered to this target. Omitted means default deny. */
231
231
  admissionGrants?: boolean;
232
+ /** Allow this leader to request a PII-free commercial parity snapshot from
233
+ * this follower. The follower must independently enable the same reader;
234
+ * omitted means no application or Stripe identifier digest crosses. */
235
+ commercialParity?: boolean;
232
236
  }
233
237
  /** One signed peer allowed to browse a privacy-bounded projection of this
234
238
  * site's CRM. The follower owns this allowlist; a leader target declaration
@@ -253,6 +257,9 @@ interface ChapterNetworkReader {
253
257
  /** Accept signed desired admission decisions from this reader. Independent
254
258
  * of record browsing; an admission-only reader may omit `fields`. */
255
259
  admissionGrants?: boolean;
260
+ /** Disclose a bounded, PII-free commercial parity snapshot to this signed
261
+ * reader. Identifiers leave only as edge-keyed, domain-separated HMACs. */
262
+ commercialParity?: boolean;
256
263
  }
257
264
  /** Leader/follower network configuration. A follower needs no code config to
258
265
  * receive pushed records. It must explicitly declare `readers` before any peer
@@ -298,6 +305,7 @@ interface ResolvedNetworkTarget {
298
305
  editableFields: Record<string, readonly string[]>;
299
306
  stageTransitions: readonly string[];
300
307
  admissionGrants?: boolean;
308
+ commercialParity?: boolean;
301
309
  }
302
310
  /** A validated follower-owned record projection for one signed reader. */
303
311
  interface ResolvedNetworkReader {
@@ -307,6 +315,7 @@ interface ResolvedNetworkReader {
307
315
  editableFields: Record<string, readonly string[]>;
308
316
  stageTransitions: readonly string[];
309
317
  admissionGrants?: boolean;
318
+ commercialParity?: boolean;
310
319
  }
311
320
  /** Fully-resolved leader network configuration. */
312
321
  interface ResolvedNetwork {
package/dist/index.cjs CHANGED
@@ -61,6 +61,7 @@ __export(index_exports, {
61
61
  brandTokens: () => brandTokens,
62
62
  bucketSeries: () => bucketSeries,
63
63
  buildBuilderLibraryManifest: () => buildBuilderLibraryManifest,
64
+ buildChapterCommercialParitySnapshot: () => buildChapterCommercialParitySnapshot,
64
65
  buildGroupSeed: () => buildGroupSeed,
65
66
  buildTierSeeds: () => buildTierSeeds,
66
67
  builderLibraryDigest: () => builderLibraryDigest,
@@ -86,6 +87,7 @@ __export(index_exports, {
86
87
  clerkListUsers: () => clerkListUsers,
87
88
  clerkSetRole: () => clerkSetRole,
88
89
  clerkUserRequest: () => clerkUserRequest,
90
+ commercialParityDigest: () => commercialParityDigest,
89
91
  composeMemberContent: () => composeMemberContent,
90
92
  createAdmissionGrantEnvelope: () => createAdmissionGrantEnvelope,
91
93
  createBuilderLibraryEnvelope: () => createBuilderLibraryEnvelope,
@@ -158,6 +160,7 @@ __export(index_exports, {
158
160
  projectSharedRecord: () => projectSharedRecord,
159
161
  promoteBuilderLibraryResource: () => promoteBuilderLibraryResource,
160
162
  publicGiftOffers: () => publicGiftOffers,
163
+ reconcileCommercialParitySnapshot: () => reconcileCommercialParitySnapshot,
161
164
  reconcileMeetings: () => reconcileMeetings,
162
165
  reconcileStripeSnapshot: () => reconcileStripeSnapshot,
163
166
  refundedPatch: () => refundedPatch,
@@ -1679,8 +1682,8 @@ var APPLICATION_LABELS = {
1679
1682
  state: "State"
1680
1683
  };
1681
1684
  function humanize(id6) {
1682
- const text5 = id6.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
1683
- return text5 ? `${text5[0].toUpperCase()}${text5.slice(1)}` : id6;
1685
+ const text6 = id6.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
1686
+ return text6 ? `${text6[0].toUpperCase()}${text6.slice(1)}` : id6;
1684
1687
  }
1685
1688
  function applicationDefs(chapter) {
1686
1689
  try {
@@ -1938,7 +1941,8 @@ function resolveNetwork(config, crm) {
1938
1941
  sharedNotes,
1939
1942
  editableFields,
1940
1943
  stageTransitions,
1941
- ...target.admissionGrants === true ? { admissionGrants: true } : {}
1944
+ ...target.admissionGrants === true ? { admissionGrants: true } : {},
1945
+ ...target.commercialParity === true ? { commercialParity: true } : {}
1942
1946
  });
1943
1947
  }
1944
1948
  const readerIds = /* @__PURE__ */ new Set();
@@ -1953,9 +1957,9 @@ function resolveNetwork(config, crm) {
1953
1957
  readerIds.add(reader.id);
1954
1958
  const readerFields = reader.fields ?? {};
1955
1959
  const entries = Object.entries(readerFields);
1956
- if (entries.length === 0 && !reader.admissionGrants) {
1960
+ if (entries.length === 0 && !reader.admissionGrants && !reader.commercialParity) {
1957
1961
  throw new Error(
1958
- `defineChapter.network.readers.${reader.id}.fields: at least one CRM type is required unless this is an admission-only reader`
1962
+ `defineChapter.network.readers.${reader.id}.fields: at least one CRM type is required unless this is an admission-only or commercial-parity reader`
1959
1963
  );
1960
1964
  }
1961
1965
  for (const [type, names] of entries) {
@@ -2013,7 +2017,8 @@ function resolveNetwork(config, crm) {
2013
2017
  sharedNotes,
2014
2018
  editableFields,
2015
2019
  stageTransitions,
2016
- ...reader.admissionGrants === true ? { admissionGrants: true } : {}
2020
+ ...reader.admissionGrants === true ? { admissionGrants: true } : {},
2021
+ ...reader.commercialParity === true ? { commercialParity: true } : {}
2017
2022
  });
2018
2023
  }
2019
2024
  return { targets, readers };
@@ -2580,6 +2585,149 @@ function reconcileStripeSnapshot(shadow, snapshots, sources) {
2580
2585
  };
2581
2586
  }
2582
2587
 
2588
+ // src/network-commercial-parity.ts
2589
+ var DIGEST = /^[0-9a-f]{64}$/;
2590
+ var MAX_RECORDS = 500;
2591
+ var text2 = (value, max = 255) => typeof value === "string" && value.length > 0 && value.length <= max ? value : null;
2592
+ function parityMessage(partition, kind, value) {
2593
+ return [
2594
+ "chapter-commercial-parity",
2595
+ "v1",
2596
+ partition.appId,
2597
+ partition.chapterId,
2598
+ partition.environment,
2599
+ partition.dataEnvironment,
2600
+ partition.runtime,
2601
+ kind,
2602
+ value
2603
+ ].join("");
2604
+ }
2605
+ async function parityKey(secret) {
2606
+ const encoder4 = new TextEncoder();
2607
+ return crypto.subtle.importKey(
2608
+ "raw",
2609
+ encoder4.encode(secret),
2610
+ { name: "HMAC", hash: "SHA-256" },
2611
+ false,
2612
+ ["sign"]
2613
+ );
2614
+ }
2615
+ async function digestWithKey(key, partition, kind, value) {
2616
+ const encoder4 = new TextEncoder();
2617
+ const signed = await crypto.subtle.sign("HMAC", key, encoder4.encode(parityMessage(partition, kind, value)));
2618
+ return [...new Uint8Array(signed)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
2619
+ }
2620
+ async function commercialParityDigest(secret, partition, kind, value) {
2621
+ return digestWithKey(await parityKey(secret), partition, kind, value);
2622
+ }
2623
+ async function buildChapterCommercialParitySnapshot(input) {
2624
+ const requestedMax = input.maxRecords ?? MAX_RECORDS;
2625
+ const maxRecords = Number.isSafeInteger(requestedMax) ? Math.max(0, Math.min(requestedMax, MAX_RECORDS)) : MAX_RECORDS;
2626
+ const eligible = input.rows.filter((row) => text2(row.id) && (text2(row.stripeCustomerId) || text2(row.stripeSubscriptionId)));
2627
+ const selected2 = eligible.slice(0, maxRecords);
2628
+ const key = await parityKey(input.secret);
2629
+ const records = await Promise.all(selected2.map(async (row) => {
2630
+ const applicationId = text2(row.id);
2631
+ const customerId = text2(row.stripeCustomerId);
2632
+ const subscriptionId = text2(row.stripeSubscriptionId);
2633
+ return {
2634
+ applicationDigest: await digestWithKey(key, input.partition, "application", applicationId),
2635
+ customerDigest: customerId ? await digestWithKey(key, input.partition, "customer", customerId) : null,
2636
+ subscriptionDigest: subscriptionId ? await digestWithKey(key, input.partition, "subscription", subscriptionId) : null,
2637
+ status: text2(row.status, 64),
2638
+ canceled: row.canceled === true
2639
+ };
2640
+ }));
2641
+ records.sort((left, right) => left.applicationDigest.localeCompare(right.applicationDigest));
2642
+ const truncated = input.scanTruncated || eligible.length > maxRecords;
2643
+ return {
2644
+ version: 1,
2645
+ site: { id: input.siteId },
2646
+ partition: input.partition,
2647
+ generatedAt: input.now ?? Date.now(),
2648
+ complete: !truncated,
2649
+ truncated,
2650
+ counts: { scanned: input.scanned, eligible: eligible.length, returned: records.length },
2651
+ records
2652
+ };
2653
+ }
2654
+ function recordKey(record4) {
2655
+ return `${record4.applicationDigest}:${record4.customerDigest ?? "-"}:${record4.subscriptionDigest ?? "-"}`;
2656
+ }
2657
+ function validSnapshot(snapshot, source) {
2658
+ const partition = snapshot?.partition;
2659
+ const records = snapshot?.records;
2660
+ if (snapshot?.version !== 1 || snapshot.site?.id !== source.chapterId || !partition || !Array.isArray(records) || records.length > MAX_RECORDS || partition.appId !== source.appId || partition.chapterId !== source.chapterId || partition.environment !== source.environment || partition.dataEnvironment !== source.dataEnvironment || partition.runtime !== source.runtime || typeof snapshot.complete !== "boolean" || typeof snapshot.truncated !== "boolean" || !snapshot.counts) return false;
2661
+ const seen = /* @__PURE__ */ new Set();
2662
+ for (const record4 of records) {
2663
+ if (!DIGEST.test(record4.applicationDigest) || record4.customerDigest !== null && !DIGEST.test(record4.customerDigest) || record4.subscriptionDigest !== null && !DIGEST.test(record4.subscriptionDigest) || record4.status !== null && text2(record4.status, 64) === null || typeof record4.canceled !== "boolean") return false;
2664
+ const key = recordKey(record4);
2665
+ if (seen.has(key)) return false;
2666
+ seen.add(key);
2667
+ }
2668
+ return Number.isSafeInteger(snapshot.counts.scanned) && Number.isSafeInteger(snapshot.counts.eligible) && Number.isSafeInteger(snapshot.counts.returned) && snapshot.counts.returned === records.length && snapshot.counts.scanned >= snapshot.counts.eligible && snapshot.counts.eligible >= snapshot.counts.returned;
2669
+ }
2670
+ async function reconcileCommercialParitySnapshot(input) {
2671
+ if (!validSnapshot(input.snapshot, input.source)) throw new Error("invalid chapter commercial parity snapshot");
2672
+ const sourceKey = networkCommercialSourceKey(input.source);
2673
+ const key = await parityKey(input.secret);
2674
+ const shadow = await Promise.all(input.projection.subscriptions.filter((row) => row.sourceKey === sourceKey && text2(row.applicationId) && text2(row.objectId)).map(async (row) => {
2675
+ const customerId = text2(row.customerId);
2676
+ const subscriptionId = text2(row.subscriptionId) ?? text2(row.objectId);
2677
+ return {
2678
+ applicationDigest: await digestWithKey(key, input.snapshot.partition, "application", row.applicationId),
2679
+ customerDigest: customerId ? await digestWithKey(key, input.snapshot.partition, "customer", customerId) : null,
2680
+ subscriptionDigest: subscriptionId ? await digestWithKey(key, input.snapshot.partition, "subscription", subscriptionId) : null,
2681
+ status: null,
2682
+ canceled: false,
2683
+ stripeStatus: text2(row.status, 64),
2684
+ stripeCanceled: row.status === "canceled"
2685
+ };
2686
+ }));
2687
+ const chapterByKey = new Map(input.snapshot.records.map((record4) => [recordKey(record4), record4]));
2688
+ const shadowByKey = new Map(shadow.map((record4) => [recordKey(record4), record4]));
2689
+ const exceptions2 = [];
2690
+ const records = [];
2691
+ for (const [key2, chapter] of chapterByKey) {
2692
+ const stripe = shadowByKey.get(key2);
2693
+ if (!stripe) {
2694
+ exceptions2.push({ kind: "chapter_only", applicationDigest: chapter.applicationDigest, detail: { chapterStatus: chapter.status } });
2695
+ continue;
2696
+ }
2697
+ const compared = { ...chapter, stripeStatus: stripe.stripeStatus, stripeCanceled: stripe.stripeCanceled };
2698
+ records.push(compared);
2699
+ if (chapter.canceled !== stripe.stripeCanceled) {
2700
+ exceptions2.push({
2701
+ kind: "cancellation_mismatch",
2702
+ applicationDigest: chapter.applicationDigest,
2703
+ detail: { chapterCanceled: chapter.canceled, stripeCanceled: stripe.stripeCanceled }
2704
+ });
2705
+ }
2706
+ }
2707
+ for (const [key2, stripe] of shadowByKey) {
2708
+ if (!chapterByKey.has(key2)) {
2709
+ exceptions2.push({ kind: "shadow_only", applicationDigest: stripe.applicationDigest, detail: { stripeStatus: stripe.stripeStatus } });
2710
+ }
2711
+ }
2712
+ if (!input.snapshot.complete || input.snapshot.truncated) {
2713
+ exceptions2.push({
2714
+ kind: "chapter_truncated",
2715
+ detail: { scanned: input.snapshot.counts.scanned, eligible: input.snapshot.counts.eligible, returned: input.snapshot.counts.returned }
2716
+ });
2717
+ }
2718
+ if (chapterByKey.size !== shadowByKey.size) {
2719
+ exceptions2.push({ kind: "count_mismatch", detail: { chapter: chapterByKey.size, shadow: shadowByKey.size } });
2720
+ }
2721
+ records.sort((left, right) => left.applicationDigest.localeCompare(right.applicationDigest));
2722
+ return {
2723
+ ok: input.snapshot.complete && exceptions2.length === 0,
2724
+ complete: input.snapshot.complete,
2725
+ counts: { chapter: chapterByKey.size, shadow: shadowByKey.size, matched: records.length },
2726
+ records,
2727
+ exceptions: exceptions2
2728
+ };
2729
+ }
2730
+
2583
2731
  // src/leader.ts
2584
2732
  var import_crm3 = require("@odla-ai/crm");
2585
2733
  function leaderCrmConfig(options = {}) {
@@ -2869,6 +3017,7 @@ function createChapterIntegration(chapter, options = {}) {
2869
3017
  path: "/api/network/shared-notes",
2870
3018
  expectedStatus: 401
2871
3019
  }] : [],
3020
+ ...chapter.network.readers.some((reader) => reader.commercialParity) ? [{ path: "/api/network/commercial-parity", expectedStatus: 401 }] : [],
2872
3021
  // Formation metadata is public only when the host explicitly enables
2873
3022
  // the bounded intake contract.
2874
3023
  ...chapter.formation.enabled ? [{ path: "/api/formation/config", expectedStatus: 200 }] : [],
@@ -3228,7 +3377,7 @@ var record2 = (value) => value !== null && typeof value === "object" && !Array.i
3228
3377
  function unknownKeys2(value, allowed2, path, errors) {
3229
3378
  for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
3230
3379
  }
3231
- function text2(value, path, errors, max = MAX_TEXT2, required = true) {
3380
+ function text3(value, path, errors, max = MAX_TEXT2, required = true) {
3232
3381
  if (value === void 0 && !required) return void 0;
3233
3382
  if (typeof value !== "string" || required && !value.trim()) {
3234
3383
  errors.push(`${path} must be a non-empty string`);
@@ -3254,7 +3403,7 @@ function scopeValue(value, path, errors) {
3254
3403
  return { kind: raw2.kind };
3255
3404
  }
3256
3405
  if (raw2.kind === "chapter") {
3257
- const chapterId = text2(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
3406
+ const chapterId = text3(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
3258
3407
  if (chapterId && !APP_ID.test(chapterId)) errors.push(`${path}.chapterId must be a lowercase app slug`);
3259
3408
  return { kind: "chapter", chapterId };
3260
3409
  }
@@ -3268,22 +3417,22 @@ function resourceValue(value, path, errors) {
3268
3417
  return null;
3269
3418
  }
3270
3419
  unknownKeys2(raw2, RESOURCE_KEYS, path, errors);
3271
- const id6 = text2(raw2.id, `${path}.id`, errors, 80) ?? "";
3420
+ const id6 = text3(raw2.id, `${path}.id`, errors, 80) ?? "";
3272
3421
  if (id6 && !ID2.test(id6)) errors.push(`${path}.id must be lowercase kebab-case`);
3273
- const kind = text2(raw2.kind, `${path}.kind`, errors) ?? "";
3422
+ const kind = text3(raw2.kind, `${path}.kind`, errors) ?? "";
3274
3423
  if (!KINDS.has(kind)) errors.push(`${path}.kind is unsupported`);
3275
- const title = text2(raw2.title, `${path}.title`, errors, 240) ?? "";
3276
- const summary = text2(raw2.summary, `${path}.summary`, errors, 1e3, false);
3277
- const status = text2(raw2.status, `${path}.status`, errors) ?? "";
3424
+ const title = text3(raw2.title, `${path}.title`, errors, 240) ?? "";
3425
+ const summary = text3(raw2.summary, `${path}.summary`, errors, 1e3, false);
3426
+ const status = text3(raw2.status, `${path}.status`, errors) ?? "";
3278
3427
  if (!STATUSES.has(status)) errors.push(`${path}.status must be draft, published, or archived`);
3279
3428
  const revision = integer2(raw2.revision, `${path}.revision`, errors);
3280
3429
  const scope = scopeValue(raw2.scope, `${path}.scope`, errors) ?? { kind: "global" };
3281
- const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text2(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
3430
+ const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text3(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
3282
3431
  if (!Array.isArray(raw2.tags)) errors.push(`${path}.tags must be an array`);
3283
3432
  if (tags.length > MAX_TAGS) errors.push(`${path}.tags may contain at most ${MAX_TAGS} items`);
3284
3433
  if (new Set(tags).size !== tags.length) errors.push(`${path}.tags must be unique`);
3285
- const bodyMarkdown = text2(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2, false);
3286
- const url = text2(raw2.url, `${path}.url`, errors, 2048, false);
3434
+ const bodyMarkdown = text3(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2, false);
3435
+ const url = text3(raw2.url, `${path}.url`, errors, 2048, false);
3287
3436
  if (url) {
3288
3437
  try {
3289
3438
  if (new URL(url).protocol !== "https:") errors.push(`${path}.url must use https`);
@@ -3295,9 +3444,9 @@ function resourceValue(value, path, errors) {
3295
3444
  if (raw2.brandAsset !== void 0 && !asset) errors.push(`${path}.brandAsset must be an object`);
3296
3445
  if (asset) unknownKeys2(asset, ASSET_KEYS, `${path}.brandAsset`, errors);
3297
3446
  const brandAsset = asset ? {
3298
- bookId: text2(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
3299
- assetId: text2(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
3300
- ...asset.filename === void 0 ? {} : { filename: text2(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
3447
+ bookId: text3(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
3448
+ assetId: text3(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
3449
+ ...asset.filename === void 0 ? {} : { filename: text3(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
3301
3450
  } : void 0;
3302
3451
  if ((kind === "document" || kind === "note") && !bodyMarkdown) errors.push(`${path}.bodyMarkdown is required for ${kind}`);
3303
3452
  if ((kind === "link" || kind === "inspiration") && !url) errors.push(`${path}.url is required for ${kind}`);
@@ -3312,17 +3461,17 @@ function resourceValue(value, path, errors) {
3312
3461
  if (provenanceRaw?.promotedFrom !== void 0 && !promotedRaw) errors.push(`${path}.provenance.promotedFrom must be an object`);
3313
3462
  if (promotedRaw) unknownKeys2(promotedRaw, PROMOTION_KEYS, `${path}.provenance.promotedFrom`, errors);
3314
3463
  const promotedFrom = promotedRaw ? {
3315
- chapterId: text2(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
3316
- resourceId: text2(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
3464
+ chapterId: text3(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
3465
+ resourceId: text3(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
3317
3466
  resourceRevision: integer2(promotedRaw.resourceRevision, `${path}.provenance.promotedFrom.resourceRevision`, errors),
3318
3467
  documentRevision: integer2(promotedRaw.documentRevision, `${path}.provenance.promotedFrom.documentRevision`, errors),
3319
- promotedBy: text2(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
3468
+ promotedBy: text3(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
3320
3469
  promotedAt: integer2(promotedRaw.promotedAt, `${path}.provenance.promotedFrom.promotedAt`, errors, 0)
3321
3470
  } : void 0;
3322
3471
  const provenance = {
3323
- createdBy: text2(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
3472
+ createdBy: text3(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
3324
3473
  createdAt: integer2(provenanceRaw?.createdAt, `${path}.provenance.createdAt`, errors, 0),
3325
- updatedBy: text2(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
3474
+ updatedBy: text3(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
3326
3475
  updatedAt: integer2(provenanceRaw?.updatedAt, `${path}.provenance.updatedAt`, errors, 0),
3327
3476
  ...promotedFrom ? { promotedFrom } : {}
3328
3477
  };
@@ -3341,7 +3490,7 @@ function validateBuilderLibraryDocument(value, options = {}) {
3341
3490
  if (sourceRaw) unknownKeys2(sourceRaw, SOURCE_KEYS2, "document.source", errors);
3342
3491
  const sourceType = sourceRaw?.type;
3343
3492
  if (sourceType !== "chapter" && sourceType !== "built_not_found") errors.push("document.source.type must be chapter or built_not_found");
3344
- const sourceAppId = text2(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
3493
+ const sourceAppId = text3(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
3345
3494
  if (sourceAppId && !APP_ID.test(sourceAppId)) errors.push("document.source.appId must be a lowercase app slug");
3346
3495
  if (options.sourceType && sourceType !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
3347
3496
  if (options.sourceAppId && sourceAppId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
@@ -3349,8 +3498,8 @@ function validateBuilderLibraryDocument(value, options = {}) {
3349
3498
  if (raw2.target !== void 0 && !targetRaw) errors.push("document.target must be an object");
3350
3499
  if (targetRaw) unknownKeys2(targetRaw, TARGET_KEYS2, "document.target", errors);
3351
3500
  const target = targetRaw ? {
3352
- appId: text2(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
3353
- environment: text2(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
3501
+ appId: text3(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
3502
+ environment: text3(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
3354
3503
  } : void 0;
3355
3504
  if (options.targetAppId && target?.appId !== options.targetAppId) errors.push(`document.target.appId must be ${options.targetAppId}`);
3356
3505
  if (!Array.isArray(raw2.resources)) errors.push("document.resources must be an array");
@@ -3474,10 +3623,10 @@ function planDelivery(input) {
3474
3623
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
3475
3624
  const to = redirect ? input.group.debugEmail : input.to;
3476
3625
  const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
3477
- const text5 = redirect ? `(dev redirect; original recipient: ${input.to})
3626
+ const text6 = redirect ? `(dev redirect; original recipient: ${input.to})
3478
3627
 
3479
3628
  ` + render(tpl.text, vars) : render(tpl.text, vars);
3480
- return { deliver: true, transport, to, subject, text: text5, redirected: redirect };
3629
+ return { deliver: true, transport, to, subject, text: text6, redirected: redirect };
3481
3630
  }
3482
3631
 
3483
3632
  // src/notify.ts
@@ -3883,7 +4032,7 @@ var ID4 = /^[a-z][a-z0-9-]{1,79}$/;
3883
4032
  var PRICE = /^price_[A-Za-z0-9]{12,}$/;
3884
4033
  var CURRENCY = /^[a-z]{3}$/;
3885
4034
  var record3 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
3886
- var text3 = (value, path, errors, max) => {
4035
+ var text4 = (value, path, errors, max) => {
3887
4036
  if (typeof value !== "string" || !value.trim()) {
3888
4037
  errors.push(`${path} is required`);
3889
4038
  return "";
@@ -3911,23 +4060,23 @@ function validateGiftOfferCatalog(input, tierIds) {
3911
4060
  }
3912
4061
  const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
3913
4062
  for (const key of Object.keys(item)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
3914
- const id6 = text3(item.id, `${path}.id`, errors, 80);
4063
+ const id6 = text4(item.id, `${path}.id`, errors, 80);
3915
4064
  if (id6 && !ID4.test(id6)) errors.push(`${path}.id must be a lowercase kebab-case id`);
3916
4065
  if (ids.has(id6)) errors.push(`${path}.id duplicates "${id6}"`);
3917
4066
  ids.add(id6);
3918
4067
  const targetRaw = record3(item.target);
3919
4068
  let target = { type: "entitlement", entitlementKey: "invalid" };
3920
4069
  if (targetRaw?.type === "tier") {
3921
- const tierId = text3(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
4070
+ const tierId = text4(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
3922
4071
  if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
3923
4072
  target = { type: "tier", tierId };
3924
4073
  } else if (targetRaw?.type === "entitlement") {
3925
- const entitlementKey = text3(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
4074
+ const entitlementKey = text4(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
3926
4075
  if (entitlementKey && !ID4.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
3927
4076
  target = { type: "entitlement", entitlementKey };
3928
4077
  } else errors.push(`${path}.target.type must be tier or entitlement`);
3929
4078
  if (item.billingMode !== "one_time" && item.billingMode !== "subscription") errors.push(`${path}.billingMode must be one_time or subscription`);
3930
- const stripePriceId = text3(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
4079
+ const stripePriceId = text4(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
3931
4080
  if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
3932
4081
  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`);
3933
4082
  const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
@@ -3949,9 +4098,9 @@ function validateGiftOfferCatalog(input, tierIds) {
3949
4098
  redemptionLifetimeHours: lifetime,
3950
4099
  ...activeFrom !== void 0 ? { activeFrom } : {},
3951
4100
  ...activeUntil !== void 0 ? { activeUntil } : {},
3952
- title: text3(item.title, `${path}.title`, errors, 160),
3953
- description: text3(item.description, `${path}.description`, errors, 2e3),
3954
- ctaLabel: text3(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
4101
+ title: text4(item.title, `${path}.title`, errors, 160),
4102
+ description: text4(item.description, `${path}.description`, errors, 2e3),
4103
+ ctaLabel: text4(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
3955
4104
  });
3956
4105
  }
3957
4106
  return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
@@ -4873,10 +5022,10 @@ function boundedString(value, path, errors, max, required = false) {
4873
5022
  errors.push(`${path} must be a string`);
4874
5023
  return void 0;
4875
5024
  }
4876
- const text5 = value.trim();
4877
- if (required && !text5) errors.push(`${path} is required`);
4878
- if (text5.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
4879
- return text5;
5025
+ const text6 = value.trim();
5026
+ if (required && !text6) errors.push(`${path} is required`);
5027
+ if (text6.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
5028
+ return text6;
4880
5029
  }
4881
5030
  function validateMembershipPageDocument(input, options = {}) {
4882
5031
  const errors = [];
@@ -4925,8 +5074,8 @@ function validateMembershipPageDocument(input, options = {}) {
4925
5074
  else {
4926
5075
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
4927
5076
  benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
4928
- const text5 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
4929
- return text5 ? [text5] : [];
5077
+ const text6 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
5078
+ return text6 ? [text6] : [];
4930
5079
  });
4931
5080
  }
4932
5081
  }
@@ -5025,7 +5174,7 @@ var recordOf = (value) => value !== null && typeof value === "object" && !Array.
5025
5174
  function unknownKeys3(value, allowed2, path, errors) {
5026
5175
  for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
5027
5176
  }
5028
- function text4(value, path, errors, max = 65536) {
5177
+ function text5(value, path, errors, max = 65536) {
5029
5178
  if (typeof value !== "string" || !value.trim()) {
5030
5179
  errors.push(`${path} is required`);
5031
5180
  return "";
@@ -5048,11 +5197,11 @@ function tiersOf(value, errors) {
5048
5197
  return [];
5049
5198
  }
5050
5199
  unknownKeys3(item, TIER_KEYS2, path, errors);
5051
- const id6 = text4(item.id, `${path}.id`, errors, 80);
5200
+ const id6 = text5(item.id, `${path}.id`, errors, 80);
5052
5201
  if (id6 && !TIER.test(id6)) errors.push(`${path}.id must be a lowercase kebab-case id`);
5053
5202
  if (seen.has(id6)) errors.push(`${path}.id duplicates "${id6}"`);
5054
5203
  seen.add(id6);
5055
- const name = text4(item.name, `${path}.name`, errors, 160);
5204
+ const name = text5(item.name, `${path}.name`, errors, 160);
5056
5205
  if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
5057
5206
  if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
5058
5207
  if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
@@ -5064,7 +5213,7 @@ function tiersOf(value, errors) {
5064
5213
  if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
5065
5214
  errors.push(`${path}.stripePriceId is required for an available paid tier`);
5066
5215
  }
5067
- const blurb = item.blurb === void 0 ? void 0 : text4(item.blurb, `${path}.blurb`, errors, 4e3);
5216
+ const blurb = item.blurb === void 0 ? void 0 : text5(item.blurb, `${path}.blurb`, errors, 4e3);
5068
5217
  return [{
5069
5218
  id: id6,
5070
5219
  name,
@@ -5120,7 +5269,7 @@ function policyOf(value, errors) {
5120
5269
  const result = {};
5121
5270
  for (const key of allowed2) {
5122
5271
  if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
5123
- result[key] = text4(input[key], `document.policy.${key}`, errors);
5272
+ result[key] = text5(input[key], `document.policy.${key}`, errors);
5124
5273
  }
5125
5274
  return result;
5126
5275
  }
@@ -5158,9 +5307,9 @@ function validateSignupControlDocument(input) {
5158
5307
  if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
5159
5308
  const source = recordOf(root.source);
5160
5309
  const target = recordOf(root.target);
5161
- const appId = text4(source?.appId, "document.source.appId", errors, 63);
5162
- const chapterId = text4(target?.chapterId, "document.target.chapterId", errors, 63);
5163
- const environment = text4(target?.environment, "document.target.environment", errors, 63);
5310
+ const appId = text5(source?.appId, "document.source.appId", errors, 63);
5311
+ const chapterId = text5(target?.chapterId, "document.target.chapterId", errors, 63);
5312
+ const environment = text5(target?.environment, "document.target.environment", errors, 63);
5164
5313
  if (appId && !SLUG9.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
5165
5314
  if (chapterId && !SLUG9.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
5166
5315
  if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
@@ -5204,7 +5353,7 @@ function validateSignupControlDocument(input) {
5204
5353
  var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
5205
5354
  var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
5206
5355
  var TOKEN_NAME = /^--ui-[a-z0-9][a-z0-9-]{0,94}$/;
5207
- var DIGEST = /^sha256:[0-9a-f]{64}$/;
5356
+ var DIGEST2 = /^sha256:[0-9a-f]{64}$/;
5208
5357
  var UNSAFE_VALUE = /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i;
5209
5358
  var MAX_TOKENS = 256;
5210
5359
  var MAX_VALUE_LENGTH = 1e3;
@@ -5248,7 +5397,7 @@ function stateFor(revision, options) {
5248
5397
  metadata: { source: "bootstrap", revision: null, digest: null, cacheKey: "signup-control:bootstrap" }
5249
5398
  };
5250
5399
  }
5251
- if (!Number.isSafeInteger(revision.revision) || revision.revision < 1 || !DIGEST.test(revision.digest)) {
5400
+ if (!Number.isSafeInteger(revision.revision) || revision.revision < 1 || !DIGEST2.test(revision.digest)) {
5252
5401
  throw new TypeError("signup-control theme revision metadata is invalid");
5253
5402
  }
5254
5403
  return {
@@ -5337,9 +5486,9 @@ function markdownBenefits(lines, path, errors) {
5337
5486
  errors.push(`${path} line ${index2 + 1} must be a Markdown bullet`);
5338
5487
  continue;
5339
5488
  }
5340
- const text5 = (match[1] ?? "").trim();
5341
- if (text5.length > 2e3) errors.push(`${path} line ${index2 + 1} must be at most 2,000 characters`);
5342
- if (text5) benefits.push(text5);
5489
+ const text6 = (match[1] ?? "").trim();
5490
+ if (text6.length > 2e3) errors.push(`${path} line ${index2 + 1} must be at most 2,000 characters`);
5491
+ if (text6) benefits.push(text6);
5343
5492
  }
5344
5493
  if (benefits.length > MAX_BENEFITS2) errors.push(`${path} supports at most ${MAX_BENEFITS2} entries`);
5345
5494
  return benefits.slice(0, MAX_BENEFITS2);