@odla-ai/chapter 0.45.0 → 0.47.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
  });
@@ -1493,8 +1511,10 @@ These bite silently — a smoke test won't catch them:
1493
1511
  - **Bootstrap the first admin according to the selected auth source.** Chapter
1494
1512
  mode defaults to Clerk claims: set the first operator's Clerk
1495
1513
  `public_metadata.role` to `admin`. If that operator also needs the
1496
- read-only cross-site tier, add their lowercase email to `superAdmins` in odla
1497
- Studio. Hub mode defaults to table auth: add the first operator's lowercase
1514
+ super-admin tier, prefer `auth: { superAdminSource: "odla" }` and grant their
1515
+ app-scoped role on Database → Users in odla Studio. Existing sites retain the
1516
+ legacy `superAdmins` table until they opt in. Hub mode defaults to table auth:
1517
+ add the first operator's lowercase
1498
1518
  email to `admins` in Studio. Neither allowlist is ever written by a worker
1499
1519
  route or provisioning seed; that is deliberate, so the running app cannot
1500
1520
  grant itself admin. Confirm the result by signing in, not merely by inspecting
@@ -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 {
@@ -449,7 +458,7 @@ interface ChapterScheduling {
449
458
  *
450
459
  * - `"claim"` reads a role ladder from a JWT claim (the member-site model:
451
460
  * `provisional → member → admin`). Admin is the last (highest) rung; the
452
- * read-only `superAdmins` tier sits above it.
461
+ * Studio-managed super-admin tier sits above it.
453
462
  * - `"table"` gates on the deny-all `admins` allowlist (the hub model):
454
463
  * a binary "email is an admin or isn't", set only in odla Studio.
455
464
  *
@@ -461,10 +470,13 @@ interface ChapterAuth {
461
470
  /** Role ladder low→high, for `source: "claim"`. The last entry is admin.
462
471
  * Default `["provisional", "member", "admin"]`. */
463
472
  ladder?: readonly string[];
464
- /** Provision the read-only `superAdmins` tier table — the only tier that may
465
- * create/modify admins, and (like every namespace) deny-all + written only in
466
- * odla Studio. Default: `true` for `"claim"`, `false` for `"table"`. */
473
+ /** Enable the Studio-managed super-admin tier — the only tier that may
474
+ * create/modify admins. Default: `true` for `"claim"`, `false` for `"table"`. */
467
475
  superAdmins?: boolean;
476
+ /** Authority behind the super-admin tier. `"table"` preserves the legacy
477
+ * app allowlist; `"odla"` reads protected `$users.role`. Default `"table"`
478
+ * for backward compatibility. */
479
+ superAdminSource?: "table" | "odla";
468
480
  }
469
481
  /** The fully-resolved auth policy (defaults applied) carried on the {@link Chapter}. */
470
482
  interface ResolvedAuth {
@@ -474,6 +486,7 @@ interface ResolvedAuth {
474
486
  /** The highest ladder rung (last entry) — the "admin" gate. */
475
487
  adminRole: string;
476
488
  superAdmins: boolean;
489
+ superAdminSource: "none" | "table" | "odla";
477
490
  }
478
491
  /** The application status pipeline. Which statuses exist, and the subsets a site
479
492
  * allows a call to be booked from / an application approved from. Defaults to
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,
@@ -1012,7 +1015,7 @@ function chapterDb(mode, auth, includeNetworkNotes = false, application = resolv
1012
1015
  }
1013
1016
  }
1014
1017
  if (auth.source === "table") entities.admins = admins;
1015
- if (auth.superAdmins) entities.superAdmins = superAdmins;
1018
+ if (auth.superAdminSource === "table") entities.superAdmins = superAdmins;
1016
1019
  if (includeNetworkNotes) entities.networkNotes = networkNotes;
1017
1020
  if (includeBuilderLibrary) {
1018
1021
  entities.builderLibraryControls = builderLibraryControls;
@@ -1195,7 +1198,12 @@ function resolveAuth(mode, auth) {
1195
1198
  }
1196
1199
  const adminRole = ladder[ladder.length - 1];
1197
1200
  const superAdmins2 = a.superAdmins ?? source === "claim";
1198
- return { source, claim, ladder, adminRole, superAdmins: superAdmins2 };
1201
+ const configuredSuperSource = a.superAdminSource ?? "table";
1202
+ if (configuredSuperSource !== "table" && configuredSuperSource !== "odla") {
1203
+ throw new Error(`defineChapter.auth.superAdminSource: must be "table" or "odla" \u2014 got ${JSON.stringify(a.superAdminSource)}`);
1204
+ }
1205
+ const superAdminSource = superAdmins2 ? configuredSuperSource : "none";
1206
+ return { source, claim, ladder, adminRole, superAdmins: superAdmins2, superAdminSource };
1199
1207
  }
1200
1208
  function roleFromClaim(payload, auth) {
1201
1209
  const raw2 = payload[auth.claim];
@@ -1679,8 +1687,8 @@ var APPLICATION_LABELS = {
1679
1687
  state: "State"
1680
1688
  };
1681
1689
  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;
1690
+ const text6 = id6.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
1691
+ return text6 ? `${text6[0].toUpperCase()}${text6.slice(1)}` : id6;
1684
1692
  }
1685
1693
  function applicationDefs(chapter) {
1686
1694
  try {
@@ -1938,7 +1946,8 @@ function resolveNetwork(config, crm) {
1938
1946
  sharedNotes,
1939
1947
  editableFields,
1940
1948
  stageTransitions,
1941
- ...target.admissionGrants === true ? { admissionGrants: true } : {}
1949
+ ...target.admissionGrants === true ? { admissionGrants: true } : {},
1950
+ ...target.commercialParity === true ? { commercialParity: true } : {}
1942
1951
  });
1943
1952
  }
1944
1953
  const readerIds = /* @__PURE__ */ new Set();
@@ -1953,9 +1962,9 @@ function resolveNetwork(config, crm) {
1953
1962
  readerIds.add(reader.id);
1954
1963
  const readerFields = reader.fields ?? {};
1955
1964
  const entries = Object.entries(readerFields);
1956
- if (entries.length === 0 && !reader.admissionGrants) {
1965
+ if (entries.length === 0 && !reader.admissionGrants && !reader.commercialParity) {
1957
1966
  throw new Error(
1958
- `defineChapter.network.readers.${reader.id}.fields: at least one CRM type is required unless this is an admission-only reader`
1967
+ `defineChapter.network.readers.${reader.id}.fields: at least one CRM type is required unless this is an admission-only or commercial-parity reader`
1959
1968
  );
1960
1969
  }
1961
1970
  for (const [type, names] of entries) {
@@ -2013,7 +2022,8 @@ function resolveNetwork(config, crm) {
2013
2022
  sharedNotes,
2014
2023
  editableFields,
2015
2024
  stageTransitions,
2016
- ...reader.admissionGrants === true ? { admissionGrants: true } : {}
2025
+ ...reader.admissionGrants === true ? { admissionGrants: true } : {},
2026
+ ...reader.commercialParity === true ? { commercialParity: true } : {}
2017
2027
  });
2018
2028
  }
2019
2029
  return { targets, readers };
@@ -2580,6 +2590,149 @@ function reconcileStripeSnapshot(shadow, snapshots, sources) {
2580
2590
  };
2581
2591
  }
2582
2592
 
2593
+ // src/network-commercial-parity.ts
2594
+ var DIGEST = /^[0-9a-f]{64}$/;
2595
+ var MAX_RECORDS = 500;
2596
+ var text2 = (value, max = 255) => typeof value === "string" && value.length > 0 && value.length <= max ? value : null;
2597
+ function parityMessage(partition, kind, value) {
2598
+ return [
2599
+ "chapter-commercial-parity",
2600
+ "v1",
2601
+ partition.appId,
2602
+ partition.chapterId,
2603
+ partition.environment,
2604
+ partition.dataEnvironment,
2605
+ partition.runtime,
2606
+ kind,
2607
+ value
2608
+ ].join("");
2609
+ }
2610
+ async function parityKey(secret) {
2611
+ const encoder4 = new TextEncoder();
2612
+ return crypto.subtle.importKey(
2613
+ "raw",
2614
+ encoder4.encode(secret),
2615
+ { name: "HMAC", hash: "SHA-256" },
2616
+ false,
2617
+ ["sign"]
2618
+ );
2619
+ }
2620
+ async function digestWithKey(key, partition, kind, value) {
2621
+ const encoder4 = new TextEncoder();
2622
+ const signed = await crypto.subtle.sign("HMAC", key, encoder4.encode(parityMessage(partition, kind, value)));
2623
+ return [...new Uint8Array(signed)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
2624
+ }
2625
+ async function commercialParityDigest(secret, partition, kind, value) {
2626
+ return digestWithKey(await parityKey(secret), partition, kind, value);
2627
+ }
2628
+ async function buildChapterCommercialParitySnapshot(input) {
2629
+ const requestedMax = input.maxRecords ?? MAX_RECORDS;
2630
+ const maxRecords = Number.isSafeInteger(requestedMax) ? Math.max(0, Math.min(requestedMax, MAX_RECORDS)) : MAX_RECORDS;
2631
+ const eligible = input.rows.filter((row) => text2(row.id) && (text2(row.stripeCustomerId) || text2(row.stripeSubscriptionId)));
2632
+ const selected2 = eligible.slice(0, maxRecords);
2633
+ const key = await parityKey(input.secret);
2634
+ const records = await Promise.all(selected2.map(async (row) => {
2635
+ const applicationId = text2(row.id);
2636
+ const customerId = text2(row.stripeCustomerId);
2637
+ const subscriptionId = text2(row.stripeSubscriptionId);
2638
+ return {
2639
+ applicationDigest: await digestWithKey(key, input.partition, "application", applicationId),
2640
+ customerDigest: customerId ? await digestWithKey(key, input.partition, "customer", customerId) : null,
2641
+ subscriptionDigest: subscriptionId ? await digestWithKey(key, input.partition, "subscription", subscriptionId) : null,
2642
+ status: text2(row.status, 64),
2643
+ canceled: row.canceled === true
2644
+ };
2645
+ }));
2646
+ records.sort((left, right) => left.applicationDigest.localeCompare(right.applicationDigest));
2647
+ const truncated = input.scanTruncated || eligible.length > maxRecords;
2648
+ return {
2649
+ version: 1,
2650
+ site: { id: input.siteId },
2651
+ partition: input.partition,
2652
+ generatedAt: input.now ?? Date.now(),
2653
+ complete: !truncated,
2654
+ truncated,
2655
+ counts: { scanned: input.scanned, eligible: eligible.length, returned: records.length },
2656
+ records
2657
+ };
2658
+ }
2659
+ function recordKey(record4) {
2660
+ return `${record4.applicationDigest}:${record4.customerDigest ?? "-"}:${record4.subscriptionDigest ?? "-"}`;
2661
+ }
2662
+ function validSnapshot(snapshot, source) {
2663
+ const partition = snapshot?.partition;
2664
+ const records = snapshot?.records;
2665
+ 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;
2666
+ const seen = /* @__PURE__ */ new Set();
2667
+ for (const record4 of records) {
2668
+ 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;
2669
+ const key = recordKey(record4);
2670
+ if (seen.has(key)) return false;
2671
+ seen.add(key);
2672
+ }
2673
+ 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;
2674
+ }
2675
+ async function reconcileCommercialParitySnapshot(input) {
2676
+ if (!validSnapshot(input.snapshot, input.source)) throw new Error("invalid chapter commercial parity snapshot");
2677
+ const sourceKey = networkCommercialSourceKey(input.source);
2678
+ const key = await parityKey(input.secret);
2679
+ const shadow = await Promise.all(input.projection.subscriptions.filter((row) => row.sourceKey === sourceKey && text2(row.applicationId) && text2(row.objectId)).map(async (row) => {
2680
+ const customerId = text2(row.customerId);
2681
+ const subscriptionId = text2(row.subscriptionId) ?? text2(row.objectId);
2682
+ return {
2683
+ applicationDigest: await digestWithKey(key, input.snapshot.partition, "application", row.applicationId),
2684
+ customerDigest: customerId ? await digestWithKey(key, input.snapshot.partition, "customer", customerId) : null,
2685
+ subscriptionDigest: subscriptionId ? await digestWithKey(key, input.snapshot.partition, "subscription", subscriptionId) : null,
2686
+ status: null,
2687
+ canceled: false,
2688
+ stripeStatus: text2(row.status, 64),
2689
+ stripeCanceled: row.status === "canceled"
2690
+ };
2691
+ }));
2692
+ const chapterByKey = new Map(input.snapshot.records.map((record4) => [recordKey(record4), record4]));
2693
+ const shadowByKey = new Map(shadow.map((record4) => [recordKey(record4), record4]));
2694
+ const exceptions2 = [];
2695
+ const records = [];
2696
+ for (const [key2, chapter] of chapterByKey) {
2697
+ const stripe = shadowByKey.get(key2);
2698
+ if (!stripe) {
2699
+ exceptions2.push({ kind: "chapter_only", applicationDigest: chapter.applicationDigest, detail: { chapterStatus: chapter.status } });
2700
+ continue;
2701
+ }
2702
+ const compared = { ...chapter, stripeStatus: stripe.stripeStatus, stripeCanceled: stripe.stripeCanceled };
2703
+ records.push(compared);
2704
+ if (chapter.canceled !== stripe.stripeCanceled) {
2705
+ exceptions2.push({
2706
+ kind: "cancellation_mismatch",
2707
+ applicationDigest: chapter.applicationDigest,
2708
+ detail: { chapterCanceled: chapter.canceled, stripeCanceled: stripe.stripeCanceled }
2709
+ });
2710
+ }
2711
+ }
2712
+ for (const [key2, stripe] of shadowByKey) {
2713
+ if (!chapterByKey.has(key2)) {
2714
+ exceptions2.push({ kind: "shadow_only", applicationDigest: stripe.applicationDigest, detail: { stripeStatus: stripe.stripeStatus } });
2715
+ }
2716
+ }
2717
+ if (!input.snapshot.complete || input.snapshot.truncated) {
2718
+ exceptions2.push({
2719
+ kind: "chapter_truncated",
2720
+ detail: { scanned: input.snapshot.counts.scanned, eligible: input.snapshot.counts.eligible, returned: input.snapshot.counts.returned }
2721
+ });
2722
+ }
2723
+ if (chapterByKey.size !== shadowByKey.size) {
2724
+ exceptions2.push({ kind: "count_mismatch", detail: { chapter: chapterByKey.size, shadow: shadowByKey.size } });
2725
+ }
2726
+ records.sort((left, right) => left.applicationDigest.localeCompare(right.applicationDigest));
2727
+ return {
2728
+ ok: input.snapshot.complete && exceptions2.length === 0,
2729
+ complete: input.snapshot.complete,
2730
+ counts: { chapter: chapterByKey.size, shadow: shadowByKey.size, matched: records.length },
2731
+ records,
2732
+ exceptions: exceptions2
2733
+ };
2734
+ }
2735
+
2583
2736
  // src/leader.ts
2584
2737
  var import_crm3 = require("@odla-ai/crm");
2585
2738
  function leaderCrmConfig(options = {}) {
@@ -2869,6 +3022,7 @@ function createChapterIntegration(chapter, options = {}) {
2869
3022
  path: "/api/network/shared-notes",
2870
3023
  expectedStatus: 401
2871
3024
  }] : [],
3025
+ ...chapter.network.readers.some((reader) => reader.commercialParity) ? [{ path: "/api/network/commercial-parity", expectedStatus: 401 }] : [],
2872
3026
  // Formation metadata is public only when the host explicitly enables
2873
3027
  // the bounded intake contract.
2874
3028
  ...chapter.formation.enabled ? [{ path: "/api/formation/config", expectedStatus: 200 }] : [],
@@ -3228,7 +3382,7 @@ var record2 = (value) => value !== null && typeof value === "object" && !Array.i
3228
3382
  function unknownKeys2(value, allowed2, path, errors) {
3229
3383
  for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
3230
3384
  }
3231
- function text2(value, path, errors, max = MAX_TEXT2, required = true) {
3385
+ function text3(value, path, errors, max = MAX_TEXT2, required = true) {
3232
3386
  if (value === void 0 && !required) return void 0;
3233
3387
  if (typeof value !== "string" || required && !value.trim()) {
3234
3388
  errors.push(`${path} must be a non-empty string`);
@@ -3254,7 +3408,7 @@ function scopeValue(value, path, errors) {
3254
3408
  return { kind: raw2.kind };
3255
3409
  }
3256
3410
  if (raw2.kind === "chapter") {
3257
- const chapterId = text2(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
3411
+ const chapterId = text3(raw2.chapterId, `${path}.chapterId`, errors, 63) ?? "";
3258
3412
  if (chapterId && !APP_ID.test(chapterId)) errors.push(`${path}.chapterId must be a lowercase app slug`);
3259
3413
  return { kind: "chapter", chapterId };
3260
3414
  }
@@ -3268,22 +3422,22 @@ function resourceValue(value, path, errors) {
3268
3422
  return null;
3269
3423
  }
3270
3424
  unknownKeys2(raw2, RESOURCE_KEYS, path, errors);
3271
- const id6 = text2(raw2.id, `${path}.id`, errors, 80) ?? "";
3425
+ const id6 = text3(raw2.id, `${path}.id`, errors, 80) ?? "";
3272
3426
  if (id6 && !ID2.test(id6)) errors.push(`${path}.id must be lowercase kebab-case`);
3273
- const kind = text2(raw2.kind, `${path}.kind`, errors) ?? "";
3427
+ const kind = text3(raw2.kind, `${path}.kind`, errors) ?? "";
3274
3428
  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) ?? "";
3429
+ const title = text3(raw2.title, `${path}.title`, errors, 240) ?? "";
3430
+ const summary = text3(raw2.summary, `${path}.summary`, errors, 1e3, false);
3431
+ const status = text3(raw2.status, `${path}.status`, errors) ?? "";
3278
3432
  if (!STATUSES.has(status)) errors.push(`${path}.status must be draft, published, or archived`);
3279
3433
  const revision = integer2(raw2.revision, `${path}.revision`, errors);
3280
3434
  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) ?? "") : [];
3435
+ const tags = Array.isArray(raw2.tags) ? raw2.tags.map((tag, index2) => text3(tag, `${path}.tags[${index2}]`, errors, 80) ?? "") : [];
3282
3436
  if (!Array.isArray(raw2.tags)) errors.push(`${path}.tags must be an array`);
3283
3437
  if (tags.length > MAX_TAGS) errors.push(`${path}.tags may contain at most ${MAX_TAGS} items`);
3284
3438
  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);
3439
+ const bodyMarkdown = text3(raw2.bodyMarkdown, `${path}.bodyMarkdown`, errors, MAX_MARKDOWN2, false);
3440
+ const url = text3(raw2.url, `${path}.url`, errors, 2048, false);
3287
3441
  if (url) {
3288
3442
  try {
3289
3443
  if (new URL(url).protocol !== "https:") errors.push(`${path}.url must use https`);
@@ -3295,9 +3449,9 @@ function resourceValue(value, path, errors) {
3295
3449
  if (raw2.brandAsset !== void 0 && !asset) errors.push(`${path}.brandAsset must be an object`);
3296
3450
  if (asset) unknownKeys2(asset, ASSET_KEYS, `${path}.brandAsset`, errors);
3297
3451
  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) ?? "" }
3452
+ bookId: text3(asset.bookId, `${path}.brandAsset.bookId`, errors, 160) ?? "",
3453
+ assetId: text3(asset.assetId, `${path}.brandAsset.assetId`, errors, 160) ?? "",
3454
+ ...asset.filename === void 0 ? {} : { filename: text3(asset.filename, `${path}.brandAsset.filename`, errors, 240) ?? "" }
3301
3455
  } : void 0;
3302
3456
  if ((kind === "document" || kind === "note") && !bodyMarkdown) errors.push(`${path}.bodyMarkdown is required for ${kind}`);
3303
3457
  if ((kind === "link" || kind === "inspiration") && !url) errors.push(`${path}.url is required for ${kind}`);
@@ -3312,17 +3466,17 @@ function resourceValue(value, path, errors) {
3312
3466
  if (provenanceRaw?.promotedFrom !== void 0 && !promotedRaw) errors.push(`${path}.provenance.promotedFrom must be an object`);
3313
3467
  if (promotedRaw) unknownKeys2(promotedRaw, PROMOTION_KEYS, `${path}.provenance.promotedFrom`, errors);
3314
3468
  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) ?? "",
3469
+ chapterId: text3(promotedRaw.chapterId, `${path}.provenance.promotedFrom.chapterId`, errors, 63) ?? "",
3470
+ resourceId: text3(promotedRaw.resourceId, `${path}.provenance.promotedFrom.resourceId`, errors, 80) ?? "",
3317
3471
  resourceRevision: integer2(promotedRaw.resourceRevision, `${path}.provenance.promotedFrom.resourceRevision`, errors),
3318
3472
  documentRevision: integer2(promotedRaw.documentRevision, `${path}.provenance.promotedFrom.documentRevision`, errors),
3319
- promotedBy: text2(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
3473
+ promotedBy: text3(promotedRaw.promotedBy, `${path}.provenance.promotedFrom.promotedBy`, errors, 320) ?? "",
3320
3474
  promotedAt: integer2(promotedRaw.promotedAt, `${path}.provenance.promotedFrom.promotedAt`, errors, 0)
3321
3475
  } : void 0;
3322
3476
  const provenance = {
3323
- createdBy: text2(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
3477
+ createdBy: text3(provenanceRaw?.createdBy, `${path}.provenance.createdBy`, errors, 320) ?? "",
3324
3478
  createdAt: integer2(provenanceRaw?.createdAt, `${path}.provenance.createdAt`, errors, 0),
3325
- updatedBy: text2(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
3479
+ updatedBy: text3(provenanceRaw?.updatedBy, `${path}.provenance.updatedBy`, errors, 320) ?? "",
3326
3480
  updatedAt: integer2(provenanceRaw?.updatedAt, `${path}.provenance.updatedAt`, errors, 0),
3327
3481
  ...promotedFrom ? { promotedFrom } : {}
3328
3482
  };
@@ -3341,7 +3495,7 @@ function validateBuilderLibraryDocument(value, options = {}) {
3341
3495
  if (sourceRaw) unknownKeys2(sourceRaw, SOURCE_KEYS2, "document.source", errors);
3342
3496
  const sourceType = sourceRaw?.type;
3343
3497
  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) ?? "";
3498
+ const sourceAppId = text3(sourceRaw?.appId, "document.source.appId", errors, 63) ?? "";
3345
3499
  if (sourceAppId && !APP_ID.test(sourceAppId)) errors.push("document.source.appId must be a lowercase app slug");
3346
3500
  if (options.sourceType && sourceType !== options.sourceType) errors.push(`document.source.type must be ${options.sourceType}`);
3347
3501
  if (options.sourceAppId && sourceAppId !== options.sourceAppId) errors.push(`document.source.appId must be ${options.sourceAppId}`);
@@ -3349,8 +3503,8 @@ function validateBuilderLibraryDocument(value, options = {}) {
3349
3503
  if (raw2.target !== void 0 && !targetRaw) errors.push("document.target must be an object");
3350
3504
  if (targetRaw) unknownKeys2(targetRaw, TARGET_KEYS2, "document.target", errors);
3351
3505
  const target = targetRaw ? {
3352
- appId: text2(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
3353
- environment: text2(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
3506
+ appId: text3(targetRaw.appId, "document.target.appId", errors, 63) ?? "",
3507
+ environment: text3(targetRaw.environment, "document.target.environment", errors, 80) ?? ""
3354
3508
  } : void 0;
3355
3509
  if (options.targetAppId && target?.appId !== options.targetAppId) errors.push(`document.target.appId must be ${options.targetAppId}`);
3356
3510
  if (!Array.isArray(raw2.resources)) errors.push("document.resources must be an array");
@@ -3474,10 +3628,10 @@ function planDelivery(input) {
3474
3628
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
3475
3629
  const to = redirect ? input.group.debugEmail : input.to;
3476
3630
  const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
3477
- const text5 = redirect ? `(dev redirect; original recipient: ${input.to})
3631
+ const text6 = redirect ? `(dev redirect; original recipient: ${input.to})
3478
3632
 
3479
3633
  ` + render(tpl.text, vars) : render(tpl.text, vars);
3480
- return { deliver: true, transport, to, subject, text: text5, redirected: redirect };
3634
+ return { deliver: true, transport, to, subject, text: text6, redirected: redirect };
3481
3635
  }
3482
3636
 
3483
3637
  // src/notify.ts
@@ -3883,7 +4037,7 @@ var ID4 = /^[a-z][a-z0-9-]{1,79}$/;
3883
4037
  var PRICE = /^price_[A-Za-z0-9]{12,}$/;
3884
4038
  var CURRENCY = /^[a-z]{3}$/;
3885
4039
  var record3 = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
3886
- var text3 = (value, path, errors, max) => {
4040
+ var text4 = (value, path, errors, max) => {
3887
4041
  if (typeof value !== "string" || !value.trim()) {
3888
4042
  errors.push(`${path} is required`);
3889
4043
  return "";
@@ -3911,23 +4065,23 @@ function validateGiftOfferCatalog(input, tierIds) {
3911
4065
  }
3912
4066
  const allowed2 = /* @__PURE__ */ new Set(["id", "target", "billingMode", "stripePriceId", "priceCents", "currency", "redemptionLifetimeHours", "activeFrom", "activeUntil", "title", "description", "ctaLabel"]);
3913
4067
  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);
4068
+ const id6 = text4(item.id, `${path}.id`, errors, 80);
3915
4069
  if (id6 && !ID4.test(id6)) errors.push(`${path}.id must be a lowercase kebab-case id`);
3916
4070
  if (ids.has(id6)) errors.push(`${path}.id duplicates "${id6}"`);
3917
4071
  ids.add(id6);
3918
4072
  const targetRaw = record3(item.target);
3919
4073
  let target = { type: "entitlement", entitlementKey: "invalid" };
3920
4074
  if (targetRaw?.type === "tier") {
3921
- const tierId = text3(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
4075
+ const tierId = text4(targetRaw.tierId, `${path}.target.tierId`, errors, 80);
3922
4076
  if (!tierIds.has(tierId)) errors.push(`${path}.target.tierId must reference a declared tier`);
3923
4077
  target = { type: "tier", tierId };
3924
4078
  } else if (targetRaw?.type === "entitlement") {
3925
- const entitlementKey = text3(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
4079
+ const entitlementKey = text4(targetRaw.entitlementKey, `${path}.target.entitlementKey`, errors, 80);
3926
4080
  if (entitlementKey && !ID4.test(entitlementKey)) errors.push(`${path}.target.entitlementKey must be a lowercase kebab-case id`);
3927
4081
  target = { type: "entitlement", entitlementKey };
3928
4082
  } else errors.push(`${path}.target.type must be tier or entitlement`);
3929
4083
  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);
4084
+ const stripePriceId = text4(item.stripePriceId, `${path}.stripePriceId`, errors, 128);
3931
4085
  if (stripePriceId && !PRICE.test(stripePriceId)) errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
3932
4086
  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
4087
  const currency = typeof item.currency === "string" ? item.currency.trim().toLowerCase() : "";
@@ -3949,9 +4103,9 @@ function validateGiftOfferCatalog(input, tierIds) {
3949
4103
  redemptionLifetimeHours: lifetime,
3950
4104
  ...activeFrom !== void 0 ? { activeFrom } : {},
3951
4105
  ...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)
4106
+ title: text4(item.title, `${path}.title`, errors, 160),
4107
+ description: text4(item.description, `${path}.description`, errors, 2e3),
4108
+ ctaLabel: text4(item.ctaLabel, `${path}.ctaLabel`, errors, 120)
3955
4109
  });
3956
4110
  }
3957
4111
  return errors.length ? { ok: false, errors } : { ok: true, value: { schemaVersion: 1, offers } };
@@ -4873,10 +5027,10 @@ function boundedString(value, path, errors, max, required = false) {
4873
5027
  errors.push(`${path} must be a string`);
4874
5028
  return void 0;
4875
5029
  }
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;
5030
+ const text6 = value.trim();
5031
+ if (required && !text6) errors.push(`${path} is required`);
5032
+ if (text6.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
5033
+ return text6;
4880
5034
  }
4881
5035
  function validateMembershipPageDocument(input, options = {}) {
4882
5036
  const errors = [];
@@ -4925,8 +5079,8 @@ function validateMembershipPageDocument(input, options = {}) {
4925
5079
  else {
4926
5080
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
4927
5081
  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] : [];
5082
+ const text6 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
5083
+ return text6 ? [text6] : [];
4930
5084
  });
4931
5085
  }
4932
5086
  }
@@ -5025,7 +5179,7 @@ var recordOf = (value) => value !== null && typeof value === "object" && !Array.
5025
5179
  function unknownKeys3(value, allowed2, path, errors) {
5026
5180
  for (const key of Object.keys(value)) if (!allowed2.has(key)) errors.push(`${path}.${key} is not supported`);
5027
5181
  }
5028
- function text4(value, path, errors, max = 65536) {
5182
+ function text5(value, path, errors, max = 65536) {
5029
5183
  if (typeof value !== "string" || !value.trim()) {
5030
5184
  errors.push(`${path} is required`);
5031
5185
  return "";
@@ -5048,11 +5202,11 @@ function tiersOf(value, errors) {
5048
5202
  return [];
5049
5203
  }
5050
5204
  unknownKeys3(item, TIER_KEYS2, path, errors);
5051
- const id6 = text4(item.id, `${path}.id`, errors, 80);
5205
+ const id6 = text5(item.id, `${path}.id`, errors, 80);
5052
5206
  if (id6 && !TIER.test(id6)) errors.push(`${path}.id must be a lowercase kebab-case id`);
5053
5207
  if (seen.has(id6)) errors.push(`${path}.id duplicates "${id6}"`);
5054
5208
  seen.add(id6);
5055
- const name = text4(item.name, `${path}.name`, errors, 160);
5209
+ const name = text5(item.name, `${path}.name`, errors, 160);
5056
5210
  if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
5057
5211
  if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
5058
5212
  if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
@@ -5064,7 +5218,7 @@ function tiersOf(value, errors) {
5064
5218
  if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
5065
5219
  errors.push(`${path}.stripePriceId is required for an available paid tier`);
5066
5220
  }
5067
- const blurb = item.blurb === void 0 ? void 0 : text4(item.blurb, `${path}.blurb`, errors, 4e3);
5221
+ const blurb = item.blurb === void 0 ? void 0 : text5(item.blurb, `${path}.blurb`, errors, 4e3);
5068
5222
  return [{
5069
5223
  id: id6,
5070
5224
  name,
@@ -5120,7 +5274,7 @@ function policyOf(value, errors) {
5120
5274
  const result = {};
5121
5275
  for (const key of allowed2) {
5122
5276
  if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
5123
- result[key] = text4(input[key], `document.policy.${key}`, errors);
5277
+ result[key] = text5(input[key], `document.policy.${key}`, errors);
5124
5278
  }
5125
5279
  return result;
5126
5280
  }
@@ -5158,9 +5312,9 @@ function validateSignupControlDocument(input) {
5158
5312
  if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
5159
5313
  const source = recordOf(root.source);
5160
5314
  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);
5315
+ const appId = text5(source?.appId, "document.source.appId", errors, 63);
5316
+ const chapterId = text5(target?.chapterId, "document.target.chapterId", errors, 63);
5317
+ const environment = text5(target?.environment, "document.target.environment", errors, 63);
5164
5318
  if (appId && !SLUG9.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
5165
5319
  if (chapterId && !SLUG9.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
5166
5320
  if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
@@ -5204,7 +5358,7 @@ function validateSignupControlDocument(input) {
5204
5358
  var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
5205
5359
  var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
5206
5360
  var TOKEN_NAME = /^--ui-[a-z0-9][a-z0-9-]{0,94}$/;
5207
- var DIGEST = /^sha256:[0-9a-f]{64}$/;
5361
+ var DIGEST2 = /^sha256:[0-9a-f]{64}$/;
5208
5362
  var UNSAFE_VALUE = /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i;
5209
5363
  var MAX_TOKENS = 256;
5210
5364
  var MAX_VALUE_LENGTH = 1e3;
@@ -5248,7 +5402,7 @@ function stateFor(revision, options) {
5248
5402
  metadata: { source: "bootstrap", revision: null, digest: null, cacheKey: "signup-control:bootstrap" }
5249
5403
  };
5250
5404
  }
5251
- if (!Number.isSafeInteger(revision.revision) || revision.revision < 1 || !DIGEST.test(revision.digest)) {
5405
+ if (!Number.isSafeInteger(revision.revision) || revision.revision < 1 || !DIGEST2.test(revision.digest)) {
5252
5406
  throw new TypeError("signup-control theme revision metadata is invalid");
5253
5407
  }
5254
5408
  return {
@@ -5337,9 +5491,9 @@ function markdownBenefits(lines, path, errors) {
5337
5491
  errors.push(`${path} line ${index2 + 1} must be a Markdown bullet`);
5338
5492
  continue;
5339
5493
  }
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);
5494
+ const text6 = (match[1] ?? "").trim();
5495
+ if (text6.length > 2e3) errors.push(`${path} line ${index2 + 1} must be at most 2,000 characters`);
5496
+ if (text6) benefits.push(text6);
5343
5497
  }
5344
5498
  if (benefits.length > MAX_BENEFITS2) errors.push(`${path} supports at most ${MAX_BENEFITS2} entries`);
5345
5499
  return benefits.slice(0, MAX_BENEFITS2);