@odla-ai/chapter 0.34.0 → 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
@@ -27,6 +27,8 @@ __export(index_exports, {
27
27
  SCHEDULING_DEFAULTS: () => SCHEDULING_DEFAULTS,
28
28
  SIGNUP_CONTROL_PATH: () => SIGNUP_CONTROL_PATH,
29
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,
30
32
  applicantProfile: () => applicantProfile,
31
33
  applicationBookingUpdate: () => applicationBookingUpdate,
32
34
  applicationSummary: () => applicationSummary,
@@ -70,6 +72,8 @@ __export(index_exports, {
70
72
  formationFields: () => formationFields,
71
73
  getVaultSecret: () => getVaultSecret,
72
74
  hasDisclaimerAck: () => hasDisclaimerAck,
75
+ hydrateSignupControlTheme: () => hydrateSignupControlTheme,
76
+ injectSignupControlThemeHead: () => injectSignupControlThemeHead,
73
77
  introIdempotencyKey: () => introIdempotencyKey,
74
78
  isAdminRole: () => isAdminRole,
75
79
  isAlreadySent: () => isAlreadySent,
@@ -99,6 +103,7 @@ __export(index_exports, {
99
103
  reconcileMeetings: () => reconcileMeetings,
100
104
  refundedPatch: () => refundedPatch,
101
105
  render: () => render,
106
+ renderSignupControlThemeHead: () => renderSignupControlThemeHead,
102
107
  renderSummary: () => renderSummary,
103
108
  renderTemplateBody: () => renderTemplateBody,
104
109
  renewalPatch: () => renewalPatch,
@@ -3026,12 +3031,18 @@ function themeOf(value, errors) {
3026
3031
  }
3027
3032
  unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
3028
3033
  if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
3034
+ let serializedLength = 0;
3029
3035
  for (const [key, token] of Object.entries(tokens)) {
3030
- if (!/^--[a-z0-9-]{1,100}$/.test(key)) errors.push(`document.theme.tokens.${key} must be a CSS custom-property name`);
3031
- if (typeof token !== "string" && typeof token !== "number" || String(token).length > 1e3) {
3032
- 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`);
3033
3043
  }
3034
3044
  }
3045
+ if (serializedLength > 65536) errors.push("document.theme.tokens exceeds the serialized size bound");
3035
3046
  return { tokens };
3036
3047
  }
3037
3048
  function validateSignupControlDocument(input) {
@@ -3083,6 +3094,116 @@ function validateSignupControlDocument(input) {
3083
3094
  } };
3084
3095
  }
3085
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
+
3086
3207
  // src/membership-markdown.ts
3087
3208
  var MAX_BENEFITS2 = 24;
3088
3209
  var MAX_MARKDOWN2 = 65536;