@odla-ai/chapter 0.33.2 → 0.35.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
@@ -432,8 +432,11 @@ The document carries the source, exact chapter/environment/Stripe-mode target,
432
432
  monotonic revision, ordered and available tiers, immutable Stripe Price ids,
433
433
  the membership-page renderer document, and explicitly delegated application,
434
434
  policy, scheduling, and theme sections. Theme tokens include typography as
435
- ordinary CSS custom properties such as `--ui-font-display` and
436
- `--ui-font-body`.
435
+ allowlisted `--ui-*` CSS custom properties such as `--ui-font-display` and
436
+ `--ui-font-sans`. Chapter rejects non-`--ui-*` names, non-finite values,
437
+ oversized token sets, control characters, style-tag breakout, declaration
438
+ breakout, comments, imports, expressions, and URL-bearing values. Application
439
+ aliases such as `--teal` or `--deepocean` remain host-owned CSS.
437
440
 
438
441
  The packaged follower receiver validates the signed request timestamp, sender,
439
442
  target, environment, Stripe mode, schema, and SHA-256 digest. It rejects a stale
@@ -455,6 +458,84 @@ count before asking odla-db to order revision history. A freshly provisioned
455
458
  follower therefore acknowledges revision 1 immediately instead of depending on
456
459
  an ordered query over an empty result set.
457
460
 
461
+ #### Render active tokens before first paint
462
+
463
+ Put Chapter's marker after the local bootstrap theme and before any application
464
+ boot script in the HTML head:
465
+
466
+ ```html
467
+ <link rel="stylesheet" href="/bootstrap-theme.css">
468
+ <!--odla:signup-control-theme-->
469
+ <script type="module" src="/app.js"></script>
470
+ ```
471
+
472
+ A host route can transform the static asset with the follower-local active row:
473
+
474
+ ```ts
475
+ import { chapterWorker, withActiveSignupControlTheme } from "@odla-ai/chapter/worker";
476
+ import { chapter } from "./chapter.config.mjs";
477
+
478
+ export default chapterWorker({
479
+ chapter,
480
+ routes: [async (request, url, env, context) => {
481
+ if (request.method !== "GET" || url.pathname !== "/apply") return null;
482
+ const asset = await env.ASSETS.fetch(new URL("/index.html", url));
483
+ return withActiveSignupControlTheme(asset, {
484
+ db: context.makeDb(env),
485
+ chapterId: chapter.id,
486
+ environment: env.ODLA_ENV,
487
+ // The site's existing deterministic fallback when no revision is active.
488
+ bootstrapTokens: { "--ui-accent": "#096ea8" },
489
+ });
490
+ }],
491
+ });
492
+ ```
493
+
494
+ `withActiveSignupControlTheme` reads only the follower database, revalidates the
495
+ stored document and its canonical digest, and injects one bounded style block
496
+ with matching revision/digest metadata. It never calls the controlling hub. A
497
+ missing active row emits the documented `bootstrap` state; a failed local read
498
+ or corrupt active row fails closed instead of looking like absence. The response
499
+ defaults to `Cache-Control: no-store`, carries digest/revision headers, and gets
500
+ an ETag over the complete transformed HTML. This prevents a pre-activation page
501
+ from surviving activation. A host-owned cache may instead call
502
+ `renderActiveSignupControlHtml()` and include
503
+ `result.theme.metadata.cacheKey` in its complete page cache key.
504
+
505
+ For an SSR or static-HTML transform, use the same helper on the generated string:
506
+
507
+ ```ts
508
+ import { renderActiveSignupControlHtml } from "@odla-ai/chapter/worker";
509
+
510
+ const result = await renderActiveSignupControlHtml(generatedHtml, {
511
+ db, chapterId: chapter.id, environment,
512
+ bootstrapTokens: { "--ui-accent": "#096ea8" },
513
+ });
514
+ await writeHtml(result.html); // cache with result.theme.metadata.cacheKey
515
+ ```
516
+
517
+ After `GET /api/join-config`, hydrate the existing style element rather than
518
+ writing a second theme system. Matching revision/digest metadata produces no DOM
519
+ write and therefore no visual token change:
520
+
521
+ ```ts
522
+ import {
523
+ SIGNUP_CONTROL_THEME_STYLE_ID,
524
+ hydrateSignupControlTheme,
525
+ } from "@odla-ai/chapter";
526
+
527
+ const style = document.getElementById(SIGNUP_CONTROL_THEME_STYLE_ID);
528
+ if (style instanceof HTMLStyleElement) {
529
+ hydrateSignupControlTheme(style, joinConfig.signupControl ?? null, {
530
+ bootstrapTokens: { "--ui-accent": "#096ea8" },
531
+ });
532
+ }
533
+ ```
534
+
535
+ The host still decides which routes use managed theming and owns any legacy
536
+ token aliases. Keep the marker after bootstrap declarations so the active
537
+ `--ui-*` values win in the initial parsed document.
538
+
458
539
  The hub owns a durable outbox: create/verify immutable Stripe Prices first,
459
540
  persist the desired revision, retry signed delivery, and mark it active only
460
541
  after the exact revision/digest receipt. Version history is for audit and
package/dist/index.cjs CHANGED
@@ -20,12 +20,15 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ CHAPTER_PROMOTION_ADAPTER: () => CHAPTER_PROMOTION_ADAPTER,
23
24
  DEFAULT_CHAPTER_COPY: () => DEFAULT_CHAPTER_COPY,
24
25
  DEFAULT_SHARE_FIELDS: () => DEFAULT_SHARE_FIELDS,
25
26
  MEMBERSHIP_PAGE_RENDERER: () => MEMBERSHIP_PAGE_RENDERER,
26
27
  SCHEDULING_DEFAULTS: () => SCHEDULING_DEFAULTS,
27
28
  SIGNUP_CONTROL_PATH: () => SIGNUP_CONTROL_PATH,
28
29
  SIGNUP_CONTROL_SCHEMA_VERSION: () => SIGNUP_CONTROL_SCHEMA_VERSION,
30
+ SIGNUP_CONTROL_THEME_MARKER: () => SIGNUP_CONTROL_THEME_MARKER,
31
+ SIGNUP_CONTROL_THEME_STYLE_ID: () => SIGNUP_CONTROL_THEME_STYLE_ID,
29
32
  applicantProfile: () => applicantProfile,
30
33
  applicationBookingUpdate: () => applicationBookingUpdate,
31
34
  applicationSummary: () => applicationSummary,
@@ -61,6 +64,7 @@ __export(index_exports, {
61
64
  defineChapter: () => defineChapter,
62
65
  emailGroupFrom: () => emailGroupFrom,
63
66
  endForSlot: () => endForSlot,
67
+ exportChapterPromotion: () => exportChapterPromotion,
64
68
  findApplicationRef: () => findApplicationRef,
65
69
  findTier: () => findTier,
66
70
  firstPaymentPatch: () => firstPaymentPatch,
@@ -68,6 +72,8 @@ __export(index_exports, {
68
72
  formationFields: () => formationFields,
69
73
  getVaultSecret: () => getVaultSecret,
70
74
  hasDisclaimerAck: () => hasDisclaimerAck,
75
+ hydrateSignupControlTheme: () => hydrateSignupControlTheme,
76
+ injectSignupControlThemeHead: () => injectSignupControlThemeHead,
71
77
  introIdempotencyKey: () => introIdempotencyKey,
72
78
  isAdminRole: () => isAdminRole,
73
79
  isAlreadySent: () => isAlreadySent,
@@ -97,6 +103,7 @@ __export(index_exports, {
97
103
  reconcileMeetings: () => reconcileMeetings,
98
104
  refundedPatch: () => refundedPatch,
99
105
  render: () => render,
106
+ renderSignupControlThemeHead: () => renderSignupControlThemeHead,
100
107
  renderSummary: () => renderSummary,
101
108
  renderTemplateBody: () => renderTemplateBody,
102
109
  renewalPatch: () => renewalPatch,
@@ -3024,12 +3031,18 @@ function themeOf(value, errors) {
3024
3031
  }
3025
3032
  unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
3026
3033
  if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
3034
+ let serializedLength = 0;
3027
3035
  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`);
3036
+ if (!/^--ui-[a-z0-9][a-z0-9-]{0,94}$/.test(key)) {
3037
+ errors.push(`document.theme.tokens.${key} must be an allowlisted --ui-* custom property`);
3038
+ }
3039
+ const rendered = String(token).trim();
3040
+ serializedLength += key.length + rendered.length + 4;
3041
+ if (typeof token !== "string" && typeof token !== "number" || typeof token === "number" && !Number.isFinite(token) || !rendered || rendered.length > 1e3 || /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i.test(rendered)) {
3042
+ errors.push(`document.theme.tokens.${key} must be a bounded breakout-safe CSS value`);
3031
3043
  }
3032
3044
  }
3045
+ if (serializedLength > 65536) errors.push("document.theme.tokens exceeds the serialized size bound");
3033
3046
  return { tokens };
3034
3047
  }
3035
3048
  function validateSignupControlDocument(input) {
@@ -3081,6 +3094,116 @@ function validateSignupControlDocument(input) {
3081
3094
  } };
3082
3095
  }
3083
3096
 
3097
+ // src/signup-control-theme.ts
3098
+ var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
3099
+ var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
3100
+ var TOKEN_NAME = /^--ui-[a-z0-9][a-z0-9-]{0,94}$/;
3101
+ var DIGEST = /^sha256:[0-9a-f]{64}$/;
3102
+ var UNSAFE_VALUE = /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i;
3103
+ var MAX_TOKENS = 256;
3104
+ var MAX_VALUE_LENGTH = 1e3;
3105
+ var MAX_SERIALIZED_LENGTH = 65536;
3106
+ function validatedTokens(input) {
3107
+ const entries = Object.entries(input ?? {});
3108
+ if (entries.length > MAX_TOKENS) {
3109
+ throw new TypeError(`signup-control theme supports at most ${MAX_TOKENS} tokens`);
3110
+ }
3111
+ const tokens = {};
3112
+ let serializedLength = 0;
3113
+ for (const [name, raw] of entries.sort(([left], [right]) => left.localeCompare(right))) {
3114
+ if (!TOKEN_NAME.test(name)) {
3115
+ throw new TypeError(`signup-control theme token "${name}" must be an allowlisted --ui-* property`);
3116
+ }
3117
+ if (typeof raw !== "string" && typeof raw !== "number" || typeof raw === "number" && !Number.isFinite(raw)) {
3118
+ throw new TypeError(`signup-control theme token "${name}" must be a finite string or number`);
3119
+ }
3120
+ const value = String(raw).trim();
3121
+ if (!value || value.length > MAX_VALUE_LENGTH || UNSAFE_VALUE.test(value)) {
3122
+ throw new TypeError(`signup-control theme token "${name}" has an unsafe or oversized value`);
3123
+ }
3124
+ serializedLength += name.length + value.length + 4;
3125
+ if (serializedLength > MAX_SERIALIZED_LENGTH) {
3126
+ throw new TypeError("signup-control theme exceeds the serialized size bound");
3127
+ }
3128
+ tokens[name] = value;
3129
+ }
3130
+ return tokens;
3131
+ }
3132
+ function cssFor(tokens) {
3133
+ const declarations = Object.entries(tokens).map(([name, value]) => ` ${name}: ${value};`);
3134
+ return declarations.length ? `:root {
3135
+ ${declarations.join("\n")}
3136
+ }` : "";
3137
+ }
3138
+ function stateFor(revision, options) {
3139
+ if (!revision) {
3140
+ return {
3141
+ css: cssFor(validatedTokens(options.bootstrapTokens)),
3142
+ metadata: { source: "bootstrap", revision: null, digest: null, cacheKey: "signup-control:bootstrap" }
3143
+ };
3144
+ }
3145
+ if (!Number.isSafeInteger(revision.revision) || revision.revision < 1 || !DIGEST.test(revision.digest)) {
3146
+ throw new TypeError("signup-control theme revision metadata is invalid");
3147
+ }
3148
+ return {
3149
+ css: cssFor(validatedTokens(revision.theme?.tokens)),
3150
+ metadata: {
3151
+ source: "active",
3152
+ revision: revision.revision,
3153
+ digest: revision.digest,
3154
+ cacheKey: `signup-control:${revision.digest}`
3155
+ }
3156
+ };
3157
+ }
3158
+ function attributes(metadata) {
3159
+ return [
3160
+ `id="${SIGNUP_CONTROL_THEME_STYLE_ID}"`,
3161
+ `data-signup-control-source="${metadata.source}"`,
3162
+ `data-signup-control-cache-key="${metadata.cacheKey}"`,
3163
+ ...metadata.revision === null ? [] : [`data-signup-control-revision="${metadata.revision}"`],
3164
+ ...metadata.digest === null ? [] : [`data-signup-control-digest="${metadata.digest}"`]
3165
+ ].join(" ");
3166
+ }
3167
+ function renderSignupControlThemeHead(revision, options = {}) {
3168
+ const { css, metadata } = stateFor(revision, options);
3169
+ return { css, metadata, head: `<style ${attributes(metadata)}>${css}</style>` };
3170
+ }
3171
+ function injectSignupControlThemeHead(html, rendered, marker = SIGNUP_CONTROL_THEME_MARKER) {
3172
+ const existing = /<style\b(?=[^>]*\bid\s*=\s*["']odla-signup-control-theme["'])[^>]*>[\s\S]*?<\/style>/gi;
3173
+ const existingBlocks = html.match(existing) ?? [];
3174
+ if (existingBlocks.length > 1) {
3175
+ throw new TypeError("signup-control theme style block must appear at most once");
3176
+ }
3177
+ if (existingBlocks.length === 1) return html.replace(existing, rendered.head);
3178
+ const markerIndex = html.indexOf(marker);
3179
+ if (markerIndex >= 0) {
3180
+ if (html.indexOf(marker, markerIndex + marker.length) >= 0) {
3181
+ throw new TypeError("signup-control theme marker must appear exactly once");
3182
+ }
3183
+ return `${html.slice(0, markerIndex)}${rendered.head}${html.slice(markerIndex + marker.length)}`;
3184
+ }
3185
+ const closingHead = html.search(/<\/head\s*>/i);
3186
+ if (closingHead < 0) throw new TypeError("signup-control theme requires an HTML head");
3187
+ return `${html.slice(0, closingHead)}${rendered.head}${html.slice(closingHead)}`;
3188
+ }
3189
+ function hydrateSignupControlTheme(target, revision, options = {}) {
3190
+ const next = stateFor(revision, options);
3191
+ const currentSource = target.getAttribute("data-signup-control-source");
3192
+ const currentRevision = target.getAttribute("data-signup-control-revision");
3193
+ const currentDigest = target.getAttribute("data-signup-control-digest");
3194
+ if (currentSource === next.metadata.source && currentRevision === (next.metadata.revision === null ? null : String(next.metadata.revision)) && currentDigest === next.metadata.digest) {
3195
+ return false;
3196
+ }
3197
+ target.textContent = next.css;
3198
+ target.setAttribute("data-signup-control-source", next.metadata.source);
3199
+ target.setAttribute("data-signup-control-cache-key", next.metadata.cacheKey);
3200
+ if (next.metadata.revision === null) target.removeAttribute("data-signup-control-revision");
3201
+ else target.setAttribute("data-signup-control-revision", String(next.metadata.revision));
3202
+ if (next.metadata.digest === null) target.removeAttribute("data-signup-control-digest");
3203
+ else target.setAttribute("data-signup-control-digest", next.metadata.digest);
3204
+ return true;
3205
+ }
3206
+
3084
3207
  // src/membership-markdown.ts
3085
3208
  var MAX_BENEFITS2 = 24;
3086
3209
  var MAX_MARKDOWN2 = 65536;
@@ -3200,4 +3323,95 @@ function membershipTierPresentationFromMarkdown(presentation, source) {
3200
3323
  if (!result.ok) throw new TypeError(`Invalid membership Markdown: ${result.errors.join("; ")}`);
3201
3324
  return result.value;
3202
3325
  }
3326
+
3327
+ // src/promotion.ts
3328
+ var import_promotion = require("@odla-ai/promotion");
3329
+ var CHAPTER_PROMOTION_ADAPTER = "@odla-ai/chapter/v1";
3330
+ var exclusions = (chapterId, paidTierIds) => [
3331
+ {
3332
+ kind: "chapter.providers",
3333
+ id: chapterId,
3334
+ reason: "provider_identity",
3335
+ paths: [
3336
+ ...paidTierIds.map((id2) => `tiers[${id2}].stripePriceId`),
3337
+ "groups.stripePublishableKey",
3338
+ "applications.stripeCustomerId",
3339
+ "applications.stripeSubscriptionId",
3340
+ "meetings.googleEventId"
3341
+ ],
3342
+ detail: "Stripe, Clerk, and Google identities are resolved independently in each target environment."
3343
+ },
3344
+ {
3345
+ kind: "chapter.runtime",
3346
+ id: chapterId,
3347
+ reason: "runtime_state",
3348
+ paths: ["applications.*", "meetings.*", "emailLog.*", "crm_*.*"],
3349
+ detail: "Applicants, meetings, messages, and CRM rows are operational state, not managed signup content."
3350
+ },
3351
+ {
3352
+ kind: "chapter.access",
3353
+ id: chapterId,
3354
+ reason: "personal_data",
3355
+ paths: ["admins.*", "superAdmins.*", "networkNotes.*"],
3356
+ detail: "People, access lists, and private network notes remain local to the target environment."
3357
+ },
3358
+ {
3359
+ kind: "chapter.history",
3360
+ id: chapterId,
3361
+ reason: "authority_history",
3362
+ paths: ["signupControls[active=false]", "signupControls.*At", "signupControls.environment"],
3363
+ detail: "The source retains immutable delivery history; only selected published content is exported."
3364
+ }
3365
+ ];
3366
+ async function exportChapterPromotion(input) {
3367
+ if (!/^sha256:[0-9a-f]{64}$/.test(input.digest)) throw new TypeError("chapter promotion digest is invalid");
3368
+ const checked = validateSignupControlDocument(input.document);
3369
+ if (!checked.ok) throw new TypeError(checked.errors.join("; "));
3370
+ const document = checked.value;
3371
+ if (await signupControlDigest(document) !== input.digest) throw new TypeError("chapter promotion digest does not match its document");
3372
+ const revision = `chapter:${document.target.chapterId}:${document.revision}`;
3373
+ const promotionProvenance = {
3374
+ adapter: CHAPTER_PROMOTION_ADAPTER,
3375
+ sourceIds: [`signup-control:${document.revision}`, document.source.appId, document.target.chapterId].sort(),
3376
+ receiptDigests: [input.digest]
3377
+ };
3378
+ const artifact = (kind, id2, content) => ({
3379
+ kind,
3380
+ id: id2,
3381
+ sourceRevision: revision,
3382
+ provenance: promotionProvenance,
3383
+ content: (0, import_promotion.canonicalPromotionValue)(content)
3384
+ });
3385
+ const chapterId = document.target.chapterId;
3386
+ const artifacts = [
3387
+ artifact("chapter.catalog", chapterId, {
3388
+ chapterId,
3389
+ tiers: document.tiers.map(({ stripePriceId: _providerId, ...tier }) => tier)
3390
+ }),
3391
+ artifact("chapter.membership-page", chapterId, document.membershipPage),
3392
+ ...document.application ? [artifact("chapter.application", chapterId, document.application)] : [],
3393
+ ...document.policy ? [artifact("chapter.policy", chapterId, document.policy)] : [],
3394
+ ...document.scheduling ? [artifact("chapter.scheduling", chapterId, document.scheduling)] : [],
3395
+ ...document.theme ? [artifact("chapter.theme", chapterId, document.theme)] : []
3396
+ ];
3397
+ return {
3398
+ adapter: CHAPTER_PROMOTION_ADAPTER,
3399
+ artifacts,
3400
+ exclusions: exclusions(chapterId, document.tiers.filter((tier) => tier.stripePriceId).map((tier) => tier.id)),
3401
+ preconditions: [
3402
+ {
3403
+ code: "chapter.signup-control.schema",
3404
+ path: "services.chapter.signupControl.schemaVersion",
3405
+ expected: document.schemaVersion,
3406
+ detail: "The target must support this Chapter signup-control document version."
3407
+ },
3408
+ {
3409
+ code: "chapter.identity",
3410
+ path: "app.appId",
3411
+ expected: chapterId,
3412
+ detail: "Chapter content can only target the chapter named by the signed source revision."
3413
+ }
3414
+ ]
3415
+ };
3416
+ }
3203
3417
  //# sourceMappingURL=index.cjs.map