@odla-ai/chapter 0.32.3 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -188,6 +188,19 @@ var networkNotes = {
188
188
  createdAt: attr("number", { indexed: true })
189
189
  }
190
190
  };
191
+ var signupControls = {
192
+ attrs: {
193
+ id: id(),
194
+ sourceId: attr("string", { indexed: true }),
195
+ targetId: attr("string", { indexed: true }),
196
+ environment: attr("string", { indexed: true }),
197
+ revision: attr("number", { indexed: true }),
198
+ digest: attr("string", { indexed: true }),
199
+ document: attr("json"),
200
+ active: attr("boolean", { indexed: true }),
201
+ receivedAt: attr("number", { indexed: true })
202
+ }
203
+ };
191
204
  var applications = {
192
205
  attrs: {
193
206
  id: id(),
@@ -335,7 +348,7 @@ var emailLog = {
335
348
  sentAt: attr("number", { indexed: true })
336
349
  }
337
350
  };
338
- function chapterDb(mode, auth, includeNetworkNotes = false, application = resolveApplication(void 0)) {
351
+ function chapterDb(mode, auth, includeNetworkNotes = false, application = resolveApplication(void 0), includeSignupControl = false) {
339
352
  const entities = {};
340
353
  if (mode === "chapter") {
341
354
  entities.applications = applicationsFor(application);
@@ -343,6 +356,7 @@ function chapterDb(mode, auth, includeNetworkNotes = false, application = resolv
343
356
  entities.tiers = tiers;
344
357
  entities.meetings = meetings;
345
358
  entities.emailLog = emailLog;
359
+ if (includeSignupControl) entities.signupControls = signupControls;
346
360
  }
347
361
  if (auth.source === "table") entities.admins = admins;
348
362
  if (auth.superAdmins) entities.superAdmins = superAdmins;
@@ -1116,15 +1130,33 @@ function resolveNetwork(config, crm) {
1116
1130
  return { targets, readers };
1117
1131
  }
1118
1132
 
1119
- // src/config.ts
1133
+ // src/config-signup-control.ts
1120
1134
  var SLUG2 = /^[a-z0-9][a-z0-9-]{1,62}$/;
1135
+ var SECRET_NAME = /^[a-z][a-z0-9_]{1,127}$/;
1136
+ function resolveSignupControl(input) {
1137
+ if (!input) return null;
1138
+ if (!SLUG2.test(input.sourceId)) {
1139
+ throw new Error("defineChapter.signupControl.sourceId: must be a lowercase app slug");
1140
+ }
1141
+ if (input.stripeMode !== "test" && input.stripeMode !== "live") {
1142
+ throw new Error('defineChapter.signupControl.stripeMode: must be "test" or "live"');
1143
+ }
1144
+ const secretName = input.secretName?.trim() || "signup_control_secret";
1145
+ if (!SECRET_NAME.test(secretName)) {
1146
+ throw new Error("defineChapter.signupControl.secretName: must be a vault-key identifier");
1147
+ }
1148
+ return { sourceId: input.sourceId, secretName, stripeMode: input.stripeMode };
1149
+ }
1150
+
1151
+ // src/config.ts
1152
+ var SLUG3 = /^[a-z0-9][a-z0-9-]{1,62}$/;
1121
1153
  function isResolvedCrm(x) {
1122
1154
  return !!x && typeof x === "object" && "prepare" in x && typeof x.prepare === "function";
1123
1155
  }
1124
1156
  function defineChapter(config) {
1125
1157
  if (!config || typeof config !== "object") throw new Error("defineChapter: a config object is required");
1126
1158
  const { id: id2, name } = config;
1127
- if (typeof id2 !== "string" || !SLUG2.test(id2)) {
1159
+ if (typeof id2 !== "string" || !SLUG3.test(id2)) {
1128
1160
  throw new Error(`defineChapter.id: must be a lowercase slug [a-z0-9-] (2-63 chars) \u2014 got ${JSON.stringify(id2)}`);
1129
1161
  }
1130
1162
  if (typeof name !== "string" || name.trim() === "") throw new Error("defineChapter.name: a non-empty name is required");
@@ -1148,8 +1180,18 @@ function defineChapter(config) {
1148
1180
  const brand = { ...config.brand, wordmark: config.brand?.wordmark ?? name };
1149
1181
  const copy = resolveChapterCopy(config.copy);
1150
1182
  const network = resolveNetwork(config, crm);
1183
+ const signupControl = resolveSignupControl(config.signupControl);
1151
1184
  const formation = resolveLeaderFormation(config.formation, crm);
1152
- const { schema, rules } = chapterDb(mode, auth, network.targets.length > 0, application);
1185
+ if (signupControl && mode !== "chapter") {
1186
+ throw new Error("defineChapter.signupControl: only chapter-mode followers can receive signup control");
1187
+ }
1188
+ const { schema, rules } = chapterDb(
1189
+ mode,
1190
+ auth,
1191
+ network.targets.length > 0,
1192
+ application,
1193
+ Boolean(signupControl)
1194
+ );
1153
1195
  const services = config.services ?? ["db", "calendar", "o11y"];
1154
1196
  const account = config.account ?? "none";
1155
1197
  if (account !== "invite" && account !== "create" && account !== "none") {
@@ -1204,6 +1246,7 @@ function defineChapter(config) {
1204
1246
  brand,
1205
1247
  copy,
1206
1248
  network,
1249
+ signupControl,
1207
1250
  formation,
1208
1251
  crm,
1209
1252
  auth,
@@ -1534,10 +1577,10 @@ function planDelivery(input) {
1534
1577
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
1535
1578
  const to = redirect ? input.group.debugEmail : input.to;
1536
1579
  const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
1537
- const text = redirect ? `(dev redirect; original recipient: ${input.to})
1580
+ const text2 = redirect ? `(dev redirect; original recipient: ${input.to})
1538
1581
 
1539
1582
  ` + render(tpl.text, vars) : render(tpl.text, vars);
1540
- return { deliver: true, transport, to, subject, text, redirected: redirect };
1583
+ return { deliver: true, transport, to, subject, text: text2, redirected: redirect };
1541
1584
  }
1542
1585
 
1543
1586
  // src/notify.ts
@@ -2528,6 +2571,36 @@ async function loadOfferableTiers(db, group, hasSecretKey) {
2528
2571
  return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
2529
2572
  }
2530
2573
 
2574
+ // src/signup-control-contract.ts
2575
+ var SIGNUP_CONTROL_SCHEMA_VERSION = 1;
2576
+ var SIGNUP_CONTROL_PATH = "/api/signup-control";
2577
+
2578
+ // src/signup-control-canonical.ts
2579
+ var encoder = new TextEncoder();
2580
+ function canonicalValue(value) {
2581
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
2582
+ return JSON.stringify(value);
2583
+ }
2584
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
2585
+ if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
2586
+ const record = value;
2587
+ const entries = Object.keys(record).sort().flatMap((key) => {
2588
+ const item = record[key];
2589
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
2590
+ });
2591
+ return `{${entries.join(",")}}`;
2592
+ }
2593
+ function canonicalSignupControl(value) {
2594
+ return canonicalValue(value);
2595
+ }
2596
+ async function signupControlDigest(document) {
2597
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
2598
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
2599
+ }
2600
+ async function createSignupControlEnvelope(document) {
2601
+ return { document, digest: await signupControlDigest(document) };
2602
+ }
2603
+
2531
2604
  // src/membership-page.ts
2532
2605
  var MEMBERSHIP_PAGE_RENDERER = "membership-cards-v1";
2533
2606
  var TIER_ID = /^[a-z][a-z0-9-]{0,79}$/;
@@ -2568,10 +2641,10 @@ function boundedString(value, path, errors, max, required = false) {
2568
2641
  errors.push(`${path} must be a string`);
2569
2642
  return void 0;
2570
2643
  }
2571
- const text = value.trim();
2572
- if (required && !text) errors.push(`${path} is required`);
2573
- if (text.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
2574
- return text;
2644
+ const text2 = value.trim();
2645
+ if (required && !text2) errors.push(`${path} is required`);
2646
+ if (text2.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
2647
+ return text2;
2575
2648
  }
2576
2649
  function validateMembershipPageDocument(input, options = {}) {
2577
2650
  const errors = [];
@@ -2620,8 +2693,8 @@ function validateMembershipPageDocument(input, options = {}) {
2620
2693
  else {
2621
2694
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
2622
2695
  benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
2623
- const text = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
2624
- return text ? [text] : [];
2696
+ const text2 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
2697
+ return text2 ? [text2] : [];
2625
2698
  });
2626
2699
  }
2627
2700
  }
@@ -2696,6 +2769,188 @@ function membershipPageDocument(input, options = {}) {
2696
2769
  return result.value;
2697
2770
  }
2698
2771
 
2772
+ // src/signup-control-validate.ts
2773
+ var SLUG4 = /^[a-z0-9][a-z0-9-]{1,62}$/;
2774
+ var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
2775
+ var TIER = /^[a-z][a-z0-9-]{0,79}$/;
2776
+ var PRICE = /^price_[A-Za-z0-9]{12,}$/;
2777
+ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
2778
+ "schemaVersion",
2779
+ "revision",
2780
+ "generatedAt",
2781
+ "source",
2782
+ "target",
2783
+ "tiers",
2784
+ "membershipPage",
2785
+ "application",
2786
+ "policy",
2787
+ "scheduling",
2788
+ "theme"
2789
+ ]);
2790
+ var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
2791
+ var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
2792
+ function unknownKeys(value, allowed, path, errors) {
2793
+ for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
2794
+ }
2795
+ function text(value, path, errors, max = 65536) {
2796
+ if (typeof value !== "string" || !value.trim()) {
2797
+ errors.push(`${path} is required`);
2798
+ return "";
2799
+ }
2800
+ if (value.length > max) errors.push(`${path} must be at most ${max} characters`);
2801
+ return value.trim();
2802
+ }
2803
+ function tiersOf(value, errors) {
2804
+ if (!Array.isArray(value) || !value.length) {
2805
+ errors.push("document.tiers must be a non-empty array");
2806
+ return [];
2807
+ }
2808
+ if (value.length > 20) errors.push("document.tiers supports at most 20 entries");
2809
+ const seen = /* @__PURE__ */ new Set();
2810
+ return value.slice(0, 20).flatMap((raw, index) => {
2811
+ const path = `document.tiers[${index}]`;
2812
+ const item = recordOf(raw);
2813
+ if (!item) {
2814
+ errors.push(`${path} must be an object`);
2815
+ return [];
2816
+ }
2817
+ unknownKeys(item, TIER_KEYS2, path, errors);
2818
+ const id2 = text(item.id, `${path}.id`, errors, 80);
2819
+ if (id2 && !TIER.test(id2)) errors.push(`${path}.id must be a lowercase kebab-case id`);
2820
+ if (seen.has(id2)) errors.push(`${path}.id duplicates "${id2}"`);
2821
+ seen.add(id2);
2822
+ const name = text(item.name, `${path}.name`, errors, 160);
2823
+ if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
2824
+ if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
2825
+ if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
2826
+ errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
2827
+ }
2828
+ if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE.test(item.stripePriceId))) {
2829
+ errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
2830
+ }
2831
+ if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
2832
+ errors.push(`${path}.stripePriceId is required for an available paid tier`);
2833
+ }
2834
+ const blurb = item.blurb === void 0 ? void 0 : text(item.blurb, `${path}.blurb`, errors, 4e3);
2835
+ return [{
2836
+ id: id2,
2837
+ name,
2838
+ available: item.available === true,
2839
+ sortOrder: Number(item.sortOrder) || 0,
2840
+ priceCents: Number(item.priceCents) || 0,
2841
+ ...typeof item.stripePriceId === "string" ? { stripePriceId: item.stripePriceId } : {},
2842
+ ...blurb ? { blurb } : {}
2843
+ }];
2844
+ });
2845
+ }
2846
+ function applicationOf(value, errors) {
2847
+ if (value === void 0) return void 0;
2848
+ const input = recordOf(value);
2849
+ if (!input) {
2850
+ errors.push("document.application must be an object");
2851
+ return void 0;
2852
+ }
2853
+ unknownKeys(input, /* @__PURE__ */ new Set(["required", "optional", "conditions", "copy"]), "document.application", errors);
2854
+ try {
2855
+ const resolved = resolveApplication(input);
2856
+ const copy = recordOf(input.copy);
2857
+ if (input.copy !== void 0 && (!copy || Object.values(copy).some((item) => typeof item !== "string"))) {
2858
+ errors.push("document.application.copy must contain only string values");
2859
+ }
2860
+ return {
2861
+ required: resolved.required,
2862
+ optional: resolved.optional,
2863
+ ...Object.keys(resolved.conditions).length ? { conditions: resolved.conditions } : {},
2864
+ ...copy ? { copy } : {}
2865
+ };
2866
+ } catch (error) {
2867
+ errors.push(error instanceof Error ? error.message : "document.application is invalid");
2868
+ return void 0;
2869
+ }
2870
+ }
2871
+ function policyOf(value, errors) {
2872
+ if (value === void 0) return void 0;
2873
+ const input = recordOf(value);
2874
+ if (!input) {
2875
+ errors.push("document.policy must be an object");
2876
+ return void 0;
2877
+ }
2878
+ const allowed = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
2879
+ unknownKeys(input, allowed, "document.policy", errors);
2880
+ const result = {};
2881
+ for (const key of allowed) {
2882
+ if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
2883
+ result[key] = text(input[key], `document.policy.${key}`, errors);
2884
+ }
2885
+ return result;
2886
+ }
2887
+ function themeOf(value, errors) {
2888
+ if (value === void 0) return void 0;
2889
+ const input = recordOf(value);
2890
+ const tokens = recordOf(input?.tokens);
2891
+ if (!input || !tokens) {
2892
+ errors.push("document.theme.tokens must be an object");
2893
+ return void 0;
2894
+ }
2895
+ unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
2896
+ if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
2897
+ for (const [key, token] of Object.entries(tokens)) {
2898
+ if (!/^--[a-z0-9-]{1,100}$/.test(key)) errors.push(`document.theme.tokens.${key} must be a CSS custom-property name`);
2899
+ if (typeof token !== "string" && typeof token !== "number" || String(token).length > 1e3) {
2900
+ errors.push(`document.theme.tokens.${key} must be a bounded string or number`);
2901
+ }
2902
+ }
2903
+ return { tokens };
2904
+ }
2905
+ function validateSignupControlDocument(input) {
2906
+ const errors = [];
2907
+ const root = recordOf(input);
2908
+ if (!root) return { ok: false, errors: ["document must be an object"] };
2909
+ unknownKeys(root, ROOT_KEYS2, "document", errors);
2910
+ if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
2911
+ if (!Number.isSafeInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
2912
+ if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
2913
+ const source = recordOf(root.source);
2914
+ const target = recordOf(root.target);
2915
+ const appId = text(source?.appId, "document.source.appId", errors, 63);
2916
+ const chapterId = text(target?.chapterId, "document.target.chapterId", errors, 63);
2917
+ const environment = text(target?.environment, "document.target.environment", errors, 63);
2918
+ if (appId && !SLUG4.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
2919
+ if (chapterId && !SLUG4.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
2920
+ if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
2921
+ if (target?.stripeMode !== "test" && target?.stripeMode !== "live") errors.push("document.target.stripeMode must be test or live");
2922
+ const tiers2 = tiersOf(root.tiers, errors);
2923
+ const available = tiers2.filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder);
2924
+ const page = validateMembershipPageDocument(root.membershipPage, { tiers: available });
2925
+ if (!page.ok) errors.push(...page.errors);
2926
+ else if (page.value.tiers.some((tier, index) => tier.tierId !== available[index]?.id)) {
2927
+ errors.push("document.membershipPage.tiers must follow the available tier display order");
2928
+ }
2929
+ const application = applicationOf(root.application, errors);
2930
+ const policy = policyOf(root.policy, errors);
2931
+ let scheduling;
2932
+ if (root.scheduling !== void 0) {
2933
+ const checked = validateScheduling(root.scheduling);
2934
+ if (!checked.ok) errors.push(...Object.entries(checked.errors).map(([key, value]) => `document.scheduling.${key}: ${value}`));
2935
+ else scheduling = root.scheduling;
2936
+ }
2937
+ const theme = themeOf(root.theme, errors);
2938
+ if (errors.length || !page.ok) return { ok: false, errors };
2939
+ return { ok: true, value: {
2940
+ schemaVersion: 1,
2941
+ revision: Number(root.revision),
2942
+ generatedAt: Number(root.generatedAt),
2943
+ source: { appId },
2944
+ target: { chapterId, environment, stripeMode: target?.stripeMode },
2945
+ tiers: tiers2,
2946
+ membershipPage: page.value,
2947
+ ...application ? { application } : {},
2948
+ ...policy ? { policy } : {},
2949
+ ...scheduling ? { scheduling } : {},
2950
+ ...theme ? { theme } : {}
2951
+ } };
2952
+ }
2953
+
2699
2954
  // src/membership-markdown.ts
2700
2955
  var MAX_BENEFITS2 = 24;
2701
2956
  var MAX_MARKDOWN2 = 65536;
@@ -2723,9 +2978,9 @@ function markdownBenefits(lines, path, errors) {
2723
2978
  errors.push(`${path} line ${index + 1} must be a Markdown bullet`);
2724
2979
  continue;
2725
2980
  }
2726
- const text = (match[1] ?? "").trim();
2727
- if (text.length > 2e3) errors.push(`${path} line ${index + 1} must be at most 2,000 characters`);
2728
- if (text) benefits.push(text);
2981
+ const text2 = (match[1] ?? "").trim();
2982
+ if (text2.length > 2e3) errors.push(`${path} line ${index + 1} must be at most 2,000 characters`);
2983
+ if (text2) benefits.push(text2);
2729
2984
  }
2730
2985
  if (benefits.length > MAX_BENEFITS2) errors.push(`${path} supports at most ${MAX_BENEFITS2} entries`);
2731
2986
  return benefits.slice(0, MAX_BENEFITS2);
@@ -2820,6 +3075,8 @@ export {
2820
3075
  DEFAULT_SHARE_FIELDS,
2821
3076
  MEMBERSHIP_PAGE_RENDERER,
2822
3077
  SCHEDULING_DEFAULTS,
3078
+ SIGNUP_CONTROL_PATH,
3079
+ SIGNUP_CONTROL_SCHEMA_VERSION,
2823
3080
  applicantProfile,
2824
3081
  applicationBookingUpdate,
2825
3082
  applicationSummary,
@@ -2835,6 +3092,7 @@ export {
2835
3092
  canChangeRole,
2836
3093
  canTransition,
2837
3094
  canceledPatch,
3095
+ canonicalSignupControl,
2838
3096
  chapterBrandFromTokens,
2839
3097
  chapterDb,
2840
3098
  clampArray,
@@ -2848,6 +3106,7 @@ export {
2848
3106
  createChapterIntegration,
2849
3107
  createClerkInvitation,
2850
3108
  createClerkUser,
3109
+ createSignupControlEnvelope,
2851
3110
  dashboardMetricData,
2852
3111
  defaultCrm,
2853
3112
  defineChapter,
@@ -2898,11 +3157,13 @@ export {
2898
3157
  resolveLeaderFormation,
2899
3158
  resolvePipeline,
2900
3159
  resolveScheduling,
3160
+ resolveSignupControl,
2901
3161
  resolveTiers,
2902
3162
  roleFromClaim,
2903
3163
  sendTemplated,
2904
3164
  sharedPersonInput,
2905
3165
  sharedRecordFromCrm,
3166
+ signupControlDigest,
2906
3167
  slotWindow,
2907
3168
  stageIndex,
2908
3169
  stripeCall,
@@ -2917,6 +3178,7 @@ export {
2917
3178
  updateClerkUserMetadataByEmail,
2918
3179
  validateMembershipPageDocument,
2919
3180
  validateScheduling,
3181
+ validateSignupControlDocument,
2920
3182
  verifyStripeSignature,
2921
3183
  webhookMutationId
2922
3184
  };