@odla-ai/chapter 0.32.3 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -24,6 +24,8 @@ __export(index_exports, {
24
24
  DEFAULT_SHARE_FIELDS: () => DEFAULT_SHARE_FIELDS,
25
25
  MEMBERSHIP_PAGE_RENDERER: () => MEMBERSHIP_PAGE_RENDERER,
26
26
  SCHEDULING_DEFAULTS: () => SCHEDULING_DEFAULTS,
27
+ SIGNUP_CONTROL_PATH: () => SIGNUP_CONTROL_PATH,
28
+ SIGNUP_CONTROL_SCHEMA_VERSION: () => SIGNUP_CONTROL_SCHEMA_VERSION,
27
29
  applicantProfile: () => applicantProfile,
28
30
  applicationBookingUpdate: () => applicationBookingUpdate,
29
31
  applicationSummary: () => applicationSummary,
@@ -39,6 +41,7 @@ __export(index_exports, {
39
41
  canChangeRole: () => canChangeRole,
40
42
  canTransition: () => canTransition,
41
43
  canceledPatch: () => canceledPatch,
44
+ canonicalSignupControl: () => canonicalSignupControl,
42
45
  chapterBrandFromTokens: () => chapterBrandFromTokens,
43
46
  chapterDb: () => chapterDb,
44
47
  clampArray: () => clampArray,
@@ -52,6 +55,7 @@ __export(index_exports, {
52
55
  createChapterIntegration: () => createChapterIntegration,
53
56
  createClerkInvitation: () => createClerkInvitation,
54
57
  createClerkUser: () => createClerkUser,
58
+ createSignupControlEnvelope: () => createSignupControlEnvelope,
55
59
  dashboardMetricData: () => dashboardMetricData,
56
60
  defaultCrm: () => defaultCrm,
57
61
  defineChapter: () => defineChapter,
@@ -102,11 +106,13 @@ __export(index_exports, {
102
106
  resolveLeaderFormation: () => resolveLeaderFormation,
103
107
  resolvePipeline: () => resolvePipeline,
104
108
  resolveScheduling: () => resolveScheduling,
109
+ resolveSignupControl: () => resolveSignupControl,
105
110
  resolveTiers: () => resolveTiers,
106
111
  roleFromClaim: () => roleFromClaim,
107
112
  sendTemplated: () => sendTemplated,
108
113
  sharedPersonInput: () => sharedPersonInput,
109
114
  sharedRecordFromCrm: () => sharedRecordFromCrm,
115
+ signupControlDigest: () => signupControlDigest,
110
116
  slotWindow: () => slotWindow,
111
117
  stageIndex: () => stageIndex,
112
118
  stripeCall: () => stripeCall,
@@ -121,6 +127,7 @@ __export(index_exports, {
121
127
  updateClerkUserMetadataByEmail: () => updateClerkUserMetadataByEmail,
122
128
  validateMembershipPageDocument: () => validateMembershipPageDocument,
123
129
  validateScheduling: () => validateScheduling,
130
+ validateSignupControlDocument: () => validateSignupControlDocument,
124
131
  verifyStripeSignature: () => import_stripe.verifyStripeSignature,
125
132
  webhookMutationId: () => webhookMutationId
126
133
  });
@@ -316,6 +323,19 @@ var networkNotes = {
316
323
  createdAt: attr("number", { indexed: true })
317
324
  }
318
325
  };
326
+ var signupControls = {
327
+ attrs: {
328
+ id: id(),
329
+ sourceId: attr("string", { indexed: true }),
330
+ targetId: attr("string", { indexed: true }),
331
+ environment: attr("string", { indexed: true }),
332
+ revision: attr("number", { indexed: true }),
333
+ digest: attr("string", { indexed: true }),
334
+ document: attr("json"),
335
+ active: attr("boolean", { indexed: true }),
336
+ receivedAt: attr("number", { indexed: true })
337
+ }
338
+ };
319
339
  var applications = {
320
340
  attrs: {
321
341
  id: id(),
@@ -463,7 +483,7 @@ var emailLog = {
463
483
  sentAt: attr("number", { indexed: true })
464
484
  }
465
485
  };
466
- function chapterDb(mode, auth, includeNetworkNotes = false, application = resolveApplication(void 0)) {
486
+ function chapterDb(mode, auth, includeNetworkNotes = false, application = resolveApplication(void 0), includeSignupControl = false) {
467
487
  const entities = {};
468
488
  if (mode === "chapter") {
469
489
  entities.applications = applicationsFor(application);
@@ -471,6 +491,7 @@ function chapterDb(mode, auth, includeNetworkNotes = false, application = resolv
471
491
  entities.tiers = tiers;
472
492
  entities.meetings = meetings;
473
493
  entities.emailLog = emailLog;
494
+ if (includeSignupControl) entities.signupControls = signupControls;
474
495
  }
475
496
  if (auth.source === "table") entities.admins = admins;
476
497
  if (auth.superAdmins) entities.superAdmins = superAdmins;
@@ -1244,15 +1265,33 @@ function resolveNetwork(config, crm) {
1244
1265
  return { targets, readers };
1245
1266
  }
1246
1267
 
1247
- // src/config.ts
1268
+ // src/config-signup-control.ts
1248
1269
  var SLUG2 = /^[a-z0-9][a-z0-9-]{1,62}$/;
1270
+ var SECRET_NAME = /^[a-z][a-z0-9_]{1,127}$/;
1271
+ function resolveSignupControl(input) {
1272
+ if (!input) return null;
1273
+ if (!SLUG2.test(input.sourceId)) {
1274
+ throw new Error("defineChapter.signupControl.sourceId: must be a lowercase app slug");
1275
+ }
1276
+ if (input.stripeMode !== "test" && input.stripeMode !== "live") {
1277
+ throw new Error('defineChapter.signupControl.stripeMode: must be "test" or "live"');
1278
+ }
1279
+ const secretName = input.secretName?.trim() || "signup_control_secret";
1280
+ if (!SECRET_NAME.test(secretName)) {
1281
+ throw new Error("defineChapter.signupControl.secretName: must be a vault-key identifier");
1282
+ }
1283
+ return { sourceId: input.sourceId, secretName, stripeMode: input.stripeMode };
1284
+ }
1285
+
1286
+ // src/config.ts
1287
+ var SLUG3 = /^[a-z0-9][a-z0-9-]{1,62}$/;
1249
1288
  function isResolvedCrm(x) {
1250
1289
  return !!x && typeof x === "object" && "prepare" in x && typeof x.prepare === "function";
1251
1290
  }
1252
1291
  function defineChapter(config) {
1253
1292
  if (!config || typeof config !== "object") throw new Error("defineChapter: a config object is required");
1254
1293
  const { id: id2, name } = config;
1255
- if (typeof id2 !== "string" || !SLUG2.test(id2)) {
1294
+ if (typeof id2 !== "string" || !SLUG3.test(id2)) {
1256
1295
  throw new Error(`defineChapter.id: must be a lowercase slug [a-z0-9-] (2-63 chars) \u2014 got ${JSON.stringify(id2)}`);
1257
1296
  }
1258
1297
  if (typeof name !== "string" || name.trim() === "") throw new Error("defineChapter.name: a non-empty name is required");
@@ -1276,8 +1315,18 @@ function defineChapter(config) {
1276
1315
  const brand = { ...config.brand, wordmark: config.brand?.wordmark ?? name };
1277
1316
  const copy = resolveChapterCopy(config.copy);
1278
1317
  const network = resolveNetwork(config, crm);
1318
+ const signupControl = resolveSignupControl(config.signupControl);
1279
1319
  const formation = resolveLeaderFormation(config.formation, crm);
1280
- const { schema, rules } = chapterDb(mode, auth, network.targets.length > 0, application);
1320
+ if (signupControl && mode !== "chapter") {
1321
+ throw new Error("defineChapter.signupControl: only chapter-mode followers can receive signup control");
1322
+ }
1323
+ const { schema, rules } = chapterDb(
1324
+ mode,
1325
+ auth,
1326
+ network.targets.length > 0,
1327
+ application,
1328
+ Boolean(signupControl)
1329
+ );
1281
1330
  const services = config.services ?? ["db", "calendar", "o11y"];
1282
1331
  const account = config.account ?? "none";
1283
1332
  if (account !== "invite" && account !== "create" && account !== "none") {
@@ -1332,6 +1381,7 @@ function defineChapter(config) {
1332
1381
  brand,
1333
1382
  copy,
1334
1383
  network,
1384
+ signupControl,
1335
1385
  formation,
1336
1386
  crm,
1337
1387
  auth,
@@ -1662,10 +1712,10 @@ function planDelivery(input) {
1662
1712
  const transport = !isProd && !redirect ? "log-only" : input.cloudflareReady ? "cloudflare" : "log-only";
1663
1713
  const to = redirect ? input.group.debugEmail : input.to;
1664
1714
  const subject = (redirect ? "[dev] " : "") + render(tpl.subject, vars);
1665
- const text = redirect ? `(dev redirect; original recipient: ${input.to})
1715
+ const text2 = redirect ? `(dev redirect; original recipient: ${input.to})
1666
1716
 
1667
1717
  ` + render(tpl.text, vars) : render(tpl.text, vars);
1668
- return { deliver: true, transport, to, subject, text, redirected: redirect };
1718
+ return { deliver: true, transport, to, subject, text: text2, redirected: redirect };
1669
1719
  }
1670
1720
 
1671
1721
  // src/notify.ts
@@ -2651,6 +2701,36 @@ async function loadOfferableTiers(db, group, hasSecretKey) {
2651
2701
  return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
2652
2702
  }
2653
2703
 
2704
+ // src/signup-control-contract.ts
2705
+ var SIGNUP_CONTROL_SCHEMA_VERSION = 1;
2706
+ var SIGNUP_CONTROL_PATH = "/api/signup-control";
2707
+
2708
+ // src/signup-control-canonical.ts
2709
+ var encoder = new TextEncoder();
2710
+ function canonicalValue(value) {
2711
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
2712
+ return JSON.stringify(value);
2713
+ }
2714
+ if (Array.isArray(value)) return `[${value.map(canonicalValue).join(",")}]`;
2715
+ if (typeof value !== "object") throw new TypeError("signup control contains an unsupported value");
2716
+ const record = value;
2717
+ const entries = Object.keys(record).sort().flatMap((key) => {
2718
+ const item = record[key];
2719
+ return item === void 0 ? [] : [`${JSON.stringify(key)}:${canonicalValue(item)}`];
2720
+ });
2721
+ return `{${entries.join(",")}}`;
2722
+ }
2723
+ function canonicalSignupControl(value) {
2724
+ return canonicalValue(value);
2725
+ }
2726
+ async function signupControlDigest(document) {
2727
+ const digest = await crypto.subtle.digest("SHA-256", encoder.encode(canonicalSignupControl(document)));
2728
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
2729
+ }
2730
+ async function createSignupControlEnvelope(document) {
2731
+ return { document, digest: await signupControlDigest(document) };
2732
+ }
2733
+
2654
2734
  // src/membership-page.ts
2655
2735
  var MEMBERSHIP_PAGE_RENDERER = "membership-cards-v1";
2656
2736
  var TIER_ID = /^[a-z][a-z0-9-]{0,79}$/;
@@ -2691,10 +2771,10 @@ function boundedString(value, path, errors, max, required = false) {
2691
2771
  errors.push(`${path} must be a string`);
2692
2772
  return void 0;
2693
2773
  }
2694
- const text = value.trim();
2695
- if (required && !text) errors.push(`${path} is required`);
2696
- if (text.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
2697
- return text;
2774
+ const text2 = value.trim();
2775
+ if (required && !text2) errors.push(`${path} is required`);
2776
+ if (text2.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
2777
+ return text2;
2698
2778
  }
2699
2779
  function validateMembershipPageDocument(input, options = {}) {
2700
2780
  const errors = [];
@@ -2743,8 +2823,8 @@ function validateMembershipPageDocument(input, options = {}) {
2743
2823
  else {
2744
2824
  if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
2745
2825
  benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
2746
- const text = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
2747
- return text ? [text] : [];
2826
+ const text2 = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
2827
+ return text2 ? [text2] : [];
2748
2828
  });
2749
2829
  }
2750
2830
  }
@@ -2819,6 +2899,188 @@ function membershipPageDocument(input, options = {}) {
2819
2899
  return result.value;
2820
2900
  }
2821
2901
 
2902
+ // src/signup-control-validate.ts
2903
+ var SLUG4 = /^[a-z0-9][a-z0-9-]{1,62}$/;
2904
+ var ENVIRONMENT = /^[a-z][a-z0-9_-]{0,62}$/;
2905
+ var TIER = /^[a-z][a-z0-9-]{0,79}$/;
2906
+ var PRICE = /^price_[A-Za-z0-9]{12,}$/;
2907
+ var ROOT_KEYS2 = /* @__PURE__ */ new Set([
2908
+ "schemaVersion",
2909
+ "revision",
2910
+ "generatedAt",
2911
+ "source",
2912
+ "target",
2913
+ "tiers",
2914
+ "membershipPage",
2915
+ "application",
2916
+ "policy",
2917
+ "scheduling",
2918
+ "theme"
2919
+ ]);
2920
+ var TIER_KEYS2 = /* @__PURE__ */ new Set(["id", "name", "available", "sortOrder", "priceCents", "stripePriceId", "blurb"]);
2921
+ var recordOf = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
2922
+ function unknownKeys(value, allowed, path, errors) {
2923
+ for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
2924
+ }
2925
+ function text(value, path, errors, max = 65536) {
2926
+ if (typeof value !== "string" || !value.trim()) {
2927
+ errors.push(`${path} is required`);
2928
+ return "";
2929
+ }
2930
+ if (value.length > max) errors.push(`${path} must be at most ${max} characters`);
2931
+ return value.trim();
2932
+ }
2933
+ function tiersOf(value, errors) {
2934
+ if (!Array.isArray(value) || !value.length) {
2935
+ errors.push("document.tiers must be a non-empty array");
2936
+ return [];
2937
+ }
2938
+ if (value.length > 20) errors.push("document.tiers supports at most 20 entries");
2939
+ const seen = /* @__PURE__ */ new Set();
2940
+ return value.slice(0, 20).flatMap((raw, index) => {
2941
+ const path = `document.tiers[${index}]`;
2942
+ const item = recordOf(raw);
2943
+ if (!item) {
2944
+ errors.push(`${path} must be an object`);
2945
+ return [];
2946
+ }
2947
+ unknownKeys(item, TIER_KEYS2, path, errors);
2948
+ const id2 = text(item.id, `${path}.id`, errors, 80);
2949
+ if (id2 && !TIER.test(id2)) errors.push(`${path}.id must be a lowercase kebab-case id`);
2950
+ if (seen.has(id2)) errors.push(`${path}.id duplicates "${id2}"`);
2951
+ seen.add(id2);
2952
+ const name = text(item.name, `${path}.name`, errors, 160);
2953
+ if (typeof item.available !== "boolean") errors.push(`${path}.available must be boolean`);
2954
+ if (!Number.isInteger(item.sortOrder) || Number(item.sortOrder) < 0) errors.push(`${path}.sortOrder must be a non-negative integer`);
2955
+ if (!Number.isInteger(item.priceCents) || Number(item.priceCents) < 0 || Number(item.priceCents) > 1e8) {
2956
+ errors.push(`${path}.priceCents must be an integer from 0 to 100,000,000`);
2957
+ }
2958
+ if (item.stripePriceId !== void 0 && (typeof item.stripePriceId !== "string" || !PRICE.test(item.stripePriceId))) {
2959
+ errors.push(`${path}.stripePriceId must be a real Stripe Price id`);
2960
+ }
2961
+ if (item.available === true && Number(item.priceCents) > 0 && !item.stripePriceId) {
2962
+ errors.push(`${path}.stripePriceId is required for an available paid tier`);
2963
+ }
2964
+ const blurb = item.blurb === void 0 ? void 0 : text(item.blurb, `${path}.blurb`, errors, 4e3);
2965
+ return [{
2966
+ id: id2,
2967
+ name,
2968
+ available: item.available === true,
2969
+ sortOrder: Number(item.sortOrder) || 0,
2970
+ priceCents: Number(item.priceCents) || 0,
2971
+ ...typeof item.stripePriceId === "string" ? { stripePriceId: item.stripePriceId } : {},
2972
+ ...blurb ? { blurb } : {}
2973
+ }];
2974
+ });
2975
+ }
2976
+ function applicationOf(value, errors) {
2977
+ if (value === void 0) return void 0;
2978
+ const input = recordOf(value);
2979
+ if (!input) {
2980
+ errors.push("document.application must be an object");
2981
+ return void 0;
2982
+ }
2983
+ unknownKeys(input, /* @__PURE__ */ new Set(["required", "optional", "conditions", "copy"]), "document.application", errors);
2984
+ try {
2985
+ const resolved = resolveApplication(input);
2986
+ const copy = recordOf(input.copy);
2987
+ if (input.copy !== void 0 && (!copy || Object.values(copy).some((item) => typeof item !== "string"))) {
2988
+ errors.push("document.application.copy must contain only string values");
2989
+ }
2990
+ return {
2991
+ required: resolved.required,
2992
+ optional: resolved.optional,
2993
+ ...Object.keys(resolved.conditions).length ? { conditions: resolved.conditions } : {},
2994
+ ...copy ? { copy } : {}
2995
+ };
2996
+ } catch (error) {
2997
+ errors.push(error instanceof Error ? error.message : "document.application is invalid");
2998
+ return void 0;
2999
+ }
3000
+ }
3001
+ function policyOf(value, errors) {
3002
+ if (value === void 0) return void 0;
3003
+ const input = recordOf(value);
3004
+ if (!input) {
3005
+ errors.push("document.policy must be an object");
3006
+ return void 0;
3007
+ }
3008
+ const allowed = /* @__PURE__ */ new Set(["disclaimerText", "refundPolicyText", "trustCopy", "commitmentText", "normsText"]);
3009
+ unknownKeys(input, allowed, "document.policy", errors);
3010
+ const result = {};
3011
+ for (const key of allowed) {
3012
+ if (input[key] === void 0 && key !== "disclaimerText" && key !== "refundPolicyText") continue;
3013
+ result[key] = text(input[key], `document.policy.${key}`, errors);
3014
+ }
3015
+ return result;
3016
+ }
3017
+ function themeOf(value, errors) {
3018
+ if (value === void 0) return void 0;
3019
+ const input = recordOf(value);
3020
+ const tokens = recordOf(input?.tokens);
3021
+ if (!input || !tokens) {
3022
+ errors.push("document.theme.tokens must be an object");
3023
+ return void 0;
3024
+ }
3025
+ unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
3026
+ if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
3027
+ for (const [key, token] of Object.entries(tokens)) {
3028
+ if (!/^--[a-z0-9-]{1,100}$/.test(key)) errors.push(`document.theme.tokens.${key} must be a CSS custom-property name`);
3029
+ if (typeof token !== "string" && typeof token !== "number" || String(token).length > 1e3) {
3030
+ errors.push(`document.theme.tokens.${key} must be a bounded string or number`);
3031
+ }
3032
+ }
3033
+ return { tokens };
3034
+ }
3035
+ function validateSignupControlDocument(input) {
3036
+ const errors = [];
3037
+ const root = recordOf(input);
3038
+ if (!root) return { ok: false, errors: ["document must be an object"] };
3039
+ unknownKeys(root, ROOT_KEYS2, "document", errors);
3040
+ if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
3041
+ if (!Number.isSafeInteger(root.revision) || Number(root.revision) < 1) errors.push("document.revision must be a positive integer");
3042
+ if (!Number.isSafeInteger(root.generatedAt) || Number(root.generatedAt) < 1) errors.push("document.generatedAt must be epoch milliseconds");
3043
+ const source = recordOf(root.source);
3044
+ const target = recordOf(root.target);
3045
+ const appId = text(source?.appId, "document.source.appId", errors, 63);
3046
+ const chapterId = text(target?.chapterId, "document.target.chapterId", errors, 63);
3047
+ const environment = text(target?.environment, "document.target.environment", errors, 63);
3048
+ if (appId && !SLUG4.test(appId)) errors.push("document.source.appId must be a lowercase app slug");
3049
+ if (chapterId && !SLUG4.test(chapterId)) errors.push("document.target.chapterId must be a lowercase app slug");
3050
+ if (environment && !ENVIRONMENT.test(environment)) errors.push("document.target.environment is invalid");
3051
+ if (target?.stripeMode !== "test" && target?.stripeMode !== "live") errors.push("document.target.stripeMode must be test or live");
3052
+ const tiers2 = tiersOf(root.tiers, errors);
3053
+ const available = tiers2.filter((tier) => tier.available).sort((a, b) => a.sortOrder - b.sortOrder);
3054
+ const page = validateMembershipPageDocument(root.membershipPage, { tiers: available });
3055
+ if (!page.ok) errors.push(...page.errors);
3056
+ else if (page.value.tiers.some((tier, index) => tier.tierId !== available[index]?.id)) {
3057
+ errors.push("document.membershipPage.tiers must follow the available tier display order");
3058
+ }
3059
+ const application = applicationOf(root.application, errors);
3060
+ const policy = policyOf(root.policy, errors);
3061
+ let scheduling;
3062
+ if (root.scheduling !== void 0) {
3063
+ const checked = validateScheduling(root.scheduling);
3064
+ if (!checked.ok) errors.push(...Object.entries(checked.errors).map(([key, value]) => `document.scheduling.${key}: ${value}`));
3065
+ else scheduling = root.scheduling;
3066
+ }
3067
+ const theme = themeOf(root.theme, errors);
3068
+ if (errors.length || !page.ok) return { ok: false, errors };
3069
+ return { ok: true, value: {
3070
+ schemaVersion: 1,
3071
+ revision: Number(root.revision),
3072
+ generatedAt: Number(root.generatedAt),
3073
+ source: { appId },
3074
+ target: { chapterId, environment, stripeMode: target?.stripeMode },
3075
+ tiers: tiers2,
3076
+ membershipPage: page.value,
3077
+ ...application ? { application } : {},
3078
+ ...policy ? { policy } : {},
3079
+ ...scheduling ? { scheduling } : {},
3080
+ ...theme ? { theme } : {}
3081
+ } };
3082
+ }
3083
+
2822
3084
  // src/membership-markdown.ts
2823
3085
  var MAX_BENEFITS2 = 24;
2824
3086
  var MAX_MARKDOWN2 = 65536;
@@ -2846,9 +3108,9 @@ function markdownBenefits(lines, path, errors) {
2846
3108
  errors.push(`${path} line ${index + 1} must be a Markdown bullet`);
2847
3109
  continue;
2848
3110
  }
2849
- const text = (match[1] ?? "").trim();
2850
- if (text.length > 2e3) errors.push(`${path} line ${index + 1} must be at most 2,000 characters`);
2851
- if (text) benefits.push(text);
3111
+ const text2 = (match[1] ?? "").trim();
3112
+ if (text2.length > 2e3) errors.push(`${path} line ${index + 1} must be at most 2,000 characters`);
3113
+ if (text2) benefits.push(text2);
2852
3114
  }
2853
3115
  if (benefits.length > MAX_BENEFITS2) errors.push(`${path} supports at most ${MAX_BENEFITS2} entries`);
2854
3116
  return benefits.slice(0, MAX_BENEFITS2);