@odla-ai/chapter 0.20.0 → 0.20.2

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.
@@ -0,0 +1,652 @@
1
+ # Greenfield runbook: build a Chapter website
2
+
3
+ Audience: an LLM or coding agent starting from an empty repository.
4
+
5
+ Read this file completely before creating files or running provisioning. This
6
+ runbook ships with the installed `@odla-ai/chapter` version; its `README.md`,
7
+ `package.json` exports, and `dist/*.d.ts` are the version-matched API authority.
8
+ Do not substitute remembered APIs or a reference site's private source.
9
+
10
+ If a site, Worker, database, login, scheduled job, or production route already
11
+ exists, stop and use [`adopt-existing.md`](./adopt-existing.md). A redesign in a
12
+ new directory is not a safe adoption.
13
+
14
+ ## What Chapter supplies—and what the host must build
15
+
16
+ Chapter supplies:
17
+
18
+ - the validated `defineChapter()` engine and generated schema/rules/seed;
19
+ - the Cloudflare Worker API through `chapterWorker()`;
20
+ - the join, payment, booking, member, admin, CRM, and network behavior;
21
+ - member-facing React components and the complete Clerk-gated admin console;
22
+ - the provisioning descriptor consumed by `@odla-ai/cli`.
23
+
24
+ Chapter does **not** generate a website. The host still owns:
25
+
26
+ - `package.json`, Vite/React or Vite/Preact, HTML entries, routing, and CSS;
27
+ - public pages, navigation, copy, imagery, SEO, legal text, and the join form;
28
+ - the member auth wrapper that gives `MembersArea` an authenticated `api`;
29
+ - Wrangler configuration, observability wrapping, provider setup, and tests.
30
+
31
+ Do not reimplement Chapter's worker routes, auth checks, admin shell, CRM,
32
+ payment, booking, account, or lifecycle logic just to achieve a different
33
+ brand. Public presentation is site-owned; application mechanics are
34
+ package-owned.
35
+
36
+ ## Non-negotiable boundaries
37
+
38
+ 1. Pin every ODLA package to an exact tested version. Never use `latest`, `^`,
39
+ `~`, or `--legacy-peer-deps` in a committed Chapter site.
40
+ 2. Build and prove `dev` before adding `prod`. Production provisioning, secret
41
+ transfer, deploy, DNS, billing activation, and real outbound email are human
42
+ checkpoints.
43
+ 3. Never print, paste into chat, or commit a secret. Never read back
44
+ `.dev.vars`, `.odla/credentials.local.json`, or `.odla/dev-token.json`.
45
+ A Clerk `pk_test_...` publishable key is public; Clerk/Stripe secret keys,
46
+ webhook secrets, ODLA credentials, and network share secrets are not.
47
+ 4. Keep database rules default-deny. The Worker uses the admin credential;
48
+ browser code never receives `ODLA_API_KEY`.
49
+ 5. `createChapterIntegration()` seeds the `groups` row only when absent.
50
+ Provisioning again does not overwrite owner-edited runtime copy, prices,
51
+ templates, scheduling, or Stripe fields. Compare config to the live row
52
+ deliberately after the first seed.
53
+ 6. `@odla-ai/cli smoke` proves platform/schema reachability, not the Chapter
54
+ application journey. The route, auth, payment, calendar, email, and visual
55
+ checks below are still required.
56
+ 7. The only live environment name in this runbook is the literal `prod`.
57
+ Although the CLI also recognizes `production` as live, Chapter's email
58
+ fail-safe currently recognizes only `prod`; using `production` suppresses or
59
+ debug-redirects lifecycle mail.
60
+
61
+ ## Phase 0 — Resolve the product and brand brief
62
+
63
+ Write `PRODUCT.md` before code. Record decisions rather than inventing business
64
+ facts. Ask the human only for missing choices that change money, identity,
65
+ outbound communication, authorization, or the public brand.
66
+
67
+ | Decision | Required answer |
68
+ | --- | --- |
69
+ | Profile | `chapter` for a public membership site; `hub` for an admin-only CRM hub |
70
+ | Identity | lowercase app id, public name, approved domains, owner |
71
+ | Audience | who the site serves, why it exists, primary action |
72
+ | Public IA | required pages and exact navigation labels |
73
+ | Brand | wordmark, short badge, voice, approved logo/images, light and dark palettes, typography |
74
+ | Membership | price, currency, interval, refund policy, disclaimer/consent |
75
+ | Application | exact fields, required/optional status, caps, confidential vs Clerk-visible vs CRM-projected |
76
+ | Account side effect | `none`, server-side `create`, or emailed Clerk `invite` |
77
+ | Lifecycle | pipeline, booking eligibility, approval effect, refund rule, email trigger |
78
+ | Operations | notification/reply/debug addresses, scheduling timezone/hours |
79
+ | Providers | intended ODLA account, Clerk app/workspace, Stripe test account, Google calendar, verified email sender |
80
+
81
+ ### Brand procedure
82
+
83
+ 1. Inventory approved logos, marks, photography, illustrations, fonts, and
84
+ usage rights. Do not redraw a logo, scrape a reference site's assets, or
85
+ generate claims/testimonials.
86
+ 2. If no direction exists, present three clearly different one-screen visual
87
+ directions and pause for selection. Do not blend unapproved directions.
88
+ 3. Convert the selected direction into semantic tokens:
89
+ `brand.palette` for light mode and `brand.paletteDark` for dark mode. Define
90
+ background, surface tiers, text tiers, border tiers, accent/soft/strong,
91
+ on-accent, good, warning, and danger—not merely one accent. Use the actual
92
+ `--ui-*` token names from the installed UI theme.
93
+ 4. Use one wordmark, one product header, and one navigation model across public,
94
+ join, member, and admin surfaces. Do not repeat a page/product label in a
95
+ secondary strip when the active navigation already establishes context.
96
+ 5. Build public information architecture before decoration. A Chapter site
97
+ normally needs: home/value proposition, thesis or program, process,
98
+ membership, join/apply, member sign-in/area, admin, privacy, and terms.
99
+ 6. Validate at 390, 768, 1280, and 1440 CSS pixels in light and dark mode.
100
+ Require visible focus, readable contrast, no clipped labels, no horizontal
101
+ document overflow, and contained Clerk cards/forms.
102
+
103
+ The public site must look like the approved organization, not an ODLA demo or a
104
+ renamed reference site.
105
+
106
+ ## Phase 1 — Scaffold the host
107
+
108
+ `@odla-ai/chapter` has no scaffolding binary. The agent creates the host files.
109
+
110
+ Initialize npm, install the exact CLI named by this release, and install its
111
+ agent guidance:
112
+
113
+ ```sh
114
+ npm init -y
115
+ npx @odla-ai/cli@0.17.1 setup
116
+ ```
117
+
118
+ Verify every release target exists, then install the exact known-good React
119
+ host matrix. Do not copy these commands into a pre-release branch where
120
+ `@odla-ai/chapter@0.20.2` is not yet on npm.
121
+
122
+ ```sh
123
+ npm view @odla-ai/chapter@0.20.2 version
124
+ npm install --save-exact \
125
+ @odla-ai/chapter@0.20.2 @odla-ai/ui@0.10.0 \
126
+ @odla-ai/crm@0.1.4 @odla-ai/db@0.6.6 \
127
+ @odla-ai/calendar@0.2.0 @odla-ai/email@0.3.1 \
128
+ @odla-ai/auth-clerk@0.4.0 @odla-ai/o11y@2.2.2 \
129
+ jose@6.2.3 react@19.2.7 react-dom@19.2.7
130
+ npm install --save-dev --save-exact \
131
+ @odla-ai/cli@0.17.1 @odla-ai/security@0.3.1 \
132
+ @cloudflare/workers-types@4.20260702.1 \
133
+ @types/react@19.2.17 @types/react-dom@19.2.3 \
134
+ @vitejs/plugin-react@6.0.3 typescript@6.0.3 \
135
+ vite@8.1.4 vitest@4.1.10 wrangler@4.107.0
136
+ npx odla-ai capabilities --json
137
+ ```
138
+
139
+ The parent `README.md` repeats this version matrix. This runbook standardizes on
140
+ React; a Preact host needs its own exact, tested compatibility matrix. Install
141
+ auth-clerk explicitly: it is deliberately not a Chapter peer because
142
+ worker-only and member-only consumers do not need it.
143
+
144
+ Required host scripts:
145
+
146
+ ```json
147
+ {
148
+ "scripts": {
149
+ "build": "vite build",
150
+ "typecheck": "tsc --noEmit",
151
+ "test": "vitest run",
152
+ "dev": "npm run build && wrangler dev --env dev",
153
+ "deploy:dev": "npm run build && wrangler deploy --env dev"
154
+ }
155
+ }
156
+ ```
157
+
158
+ Recommended minimum tree:
159
+
160
+ ```text
161
+ .
162
+ ├── PRODUCT.md
163
+ ├── package.json
164
+ ├── index.html
165
+ ├── vite.config.mjs
166
+ ├── wrangler.jsonc
167
+ ├── odla.config.mjs
168
+ ├── src/
169
+ │ ├── chapter.config.mjs
170
+ │ ├── worker.ts
171
+ │ └── app/
172
+ │ ├── main.tsx
173
+ │ ├── public.tsx
174
+ │ ├── join.tsx
175
+ │ ├── members.tsx
176
+ │ ├── admin.tsx
177
+ │ └── app.css
178
+ └── test/
179
+ ├── chapter-contract.test.ts
180
+ ├── routes.test.ts
181
+ └── render.test.tsx
182
+ ```
183
+
184
+ Gitignore at least:
185
+
186
+ ```gitignore
187
+ node_modules/
188
+ dist/
189
+ .dev.vars
190
+ .odla/
191
+ ```
192
+
193
+ ## Phase 2 — Write the single Chapter decision record
194
+
195
+ Do not accept safety-sensitive defaults silently. A chapter-mode starting point:
196
+
197
+ ```js
198
+ // src/chapter.config.mjs
199
+ import { defineChapter } from "@odla-ai/chapter";
200
+
201
+ export const chapter = defineChapter({
202
+ id: "example-chapter",
203
+ name: "Example Chapter",
204
+ url: "https://example.org",
205
+ mode: "chapter",
206
+ services: ["db", "calendar", "o11y"],
207
+ brand: {
208
+ badge: "EX",
209
+ wordmark: "Example Chapter",
210
+ tagline: "Approved public positioning goes here.",
211
+ palette: {
212
+ "--ui-bg": "#f6f2e9",
213
+ "--ui-surface": "#fffdf8",
214
+ "--ui-surface-2": "#eee7dc",
215
+ "--ui-text": "#24342b",
216
+ "--ui-text-muted": "#66736b",
217
+ "--ui-text-faint": "#8a958e",
218
+ "--ui-border": "#d8d2c5",
219
+ "--ui-border-strong": "#b8ae9d",
220
+ "--ui-accent": "#b85c38",
221
+ "--ui-accent-strong": "#8c3f24",
222
+ "--ui-accent-soft": "rgba(184, 92, 56, 0.12)",
223
+ "--ui-on-accent": "#ffffff",
224
+ "--ui-good": "#4e765c",
225
+ "--ui-warn": "#9a732b",
226
+ "--ui-danger": "#a9443b",
227
+ },
228
+ paletteDark: {
229
+ "--ui-bg": "#15211b",
230
+ "--ui-surface": "#1d2b24",
231
+ "--ui-surface-2": "#27372e",
232
+ "--ui-text": "#f3eee4",
233
+ "--ui-text-muted": "#aab6ad",
234
+ "--ui-text-faint": "#7d8d83",
235
+ "--ui-border": "#3c4a42",
236
+ "--ui-border-strong": "#58675e",
237
+ "--ui-accent": "#ef8a63",
238
+ "--ui-accent-strong": "#ffad88",
239
+ "--ui-accent-soft": "rgba(239, 138, 99, 0.16)",
240
+ "--ui-on-accent": "#15211b",
241
+ "--ui-good": "#83b58f",
242
+ "--ui-warn": "#d5ad5d",
243
+ "--ui-danger": "#e58278",
244
+ },
245
+ },
246
+ prices: {
247
+ standardCents: 100000,
248
+ foundingDiscountCents: 10000,
249
+ currency: "usd",
250
+ interval: "year",
251
+ },
252
+ policy: {
253
+ disclaimerText: "Replace with approved consent language.",
254
+ refundPolicyText: "Replace with the approved refund policy.",
255
+ trustCopy: "Replace with approved trust and privacy copy.",
256
+ },
257
+ emails: {
258
+ notificationEmail: "owner@example.org",
259
+ replyTo: "hello@example.org",
260
+ debugEmail: "dev-mail@example.org",
261
+ },
262
+ scheduling: {
263
+ slotMinutes: 45,
264
+ days: [1, 2, 3, 4, 5],
265
+ startHour: 9,
266
+ endHour: 17,
267
+ timezone: "America/Los_Angeles",
268
+ minNoticeHours: 24,
269
+ windowDays: 14,
270
+ },
271
+ pipeline: {
272
+ stages: [
273
+ "submitted",
274
+ "paid_pending_vetting",
275
+ "call_scheduled",
276
+ "interviewed",
277
+ "approved",
278
+ "declined",
279
+ "refunded",
280
+ ],
281
+ initial: "submitted",
282
+ bookableFrom: ["submitted", "paid_pending_vetting"],
283
+ approvableFrom: ["interviewed"],
284
+ },
285
+ application: {
286
+ required: ["firstName", "lastName", "email", "whoYouAre", "message"],
287
+ optional: ["linkedin", "phone", "state"],
288
+ defaultMaxLen: 2000,
289
+ maxLen: { firstName: 100, lastName: 100, email: 320, state: 100 },
290
+ bodyCap: 32768,
291
+ requireDisclaimerAck: true,
292
+ profileFields: ["phone", "state"],
293
+ crmFields: ["phone", "state", "linkedin", "whoYouAre", "message"],
294
+ maxArrayLen: 25,
295
+ validateEmail: true,
296
+ },
297
+ auth: {
298
+ source: "claim",
299
+ claim: "role",
300
+ ladder: ["provisional", "member", "admin"],
301
+ superAdmins: true,
302
+ },
303
+ account: "none",
304
+ sends: { adminNotification: "submit" },
305
+ operations: {
306
+ onApprove: { promoteTo: "member", send: "onboardingInvite" },
307
+ refund: { allowedFrom: ["paid_pending_vetting"], cancelSubscription: true },
308
+ },
309
+ });
310
+ ```
311
+
312
+ Replace every example value before provisioning. `account: "invite"` sends a
313
+ real Clerk invitation; `account: "create"` creates an account server-side;
314
+ both require a named app-readable vault secret `clerk_secret_key`. Keep
315
+ `account: "none"` until that side effect is explicitly approved and tested.
316
+
317
+ In this release, `prices.currency` and `prices.interval` record intent but do
318
+ not control the group row, public join config, or Stripe charge. The Stripe
319
+ Price id is authoritative. Phase 6 requires a provider-side amount, currency,
320
+ and recurring-interval equality check before payment is exposed.
321
+
322
+ The built-in application schema is fixed. With stock `/api/applications`, form
323
+ string names are limited to `firstName`, `lastName`, `email`, `referral`,
324
+ `referralName`, `whoYouAre`, `linkedin`, `message`, `phone`, and `state`;
325
+ `focus` and `disclaimerAck` are special fields. Every rendered string input must
326
+ appear in `application.required` or `optional`, and every required field must be
327
+ rendered. `JoinIsland` currently keeps only one `FormData` value per name, so it
328
+ cannot submit an array-valued `focus` control without a tested adapter.
329
+
330
+ Do not put an arbitrary name in `required` or `optional`: config validation does
331
+ not reject it, but the provisioned schema cannot store it. If the approved
332
+ product requires another field, stop and either upgrade Chapter to a release
333
+ with that field or keep a reviewed host-owned schema/submit route; do not claim
334
+ the stock join surface supports it.
335
+
336
+ Snapshot these resolved values in `chapter-contract.test.ts`:
337
+
338
+ - `chapter.services`, `auth`, `pipeline`, `application`, and `operations`;
339
+ - namespace names and default-deny rules;
340
+ - `chapter.groupSeed()` exactly;
341
+ - `createChapterIntegration(chapter, { now: 1_700_000_000_000 })`
342
+ schema/rules/seeds/probes, using the fixed clock because the integration seed
343
+ carries the timestamp.
344
+
345
+ ## Phase 3 — Provisioning and Worker shell
346
+
347
+ ```js
348
+ // odla.config.mjs
349
+ import { createChapterIntegration } from "@odla-ai/chapter";
350
+ import { chapter } from "./src/chapter.config.mjs";
351
+
352
+ export default {
353
+ platformUrl: "https://odla.ai",
354
+ dbEndpoint: "https://db.odla.ai",
355
+ app: { id: chapter.id, name: chapter.name },
356
+ envs: ["dev"],
357
+ services: chapter.services,
358
+ integrations: [createChapterIntegration(chapter)],
359
+ calendar: {
360
+ google: {
361
+ availabilityCalendars: { dev: ["primary"] },
362
+ bookingCalendar: { dev: "primary" },
363
+ },
364
+ },
365
+ o11y: { service: chapter.id },
366
+ auth: { clerk: { dev: "REPLACE_WITH_APPROVED_PK_TEST_KEY" } },
367
+ links: { dev: null },
368
+ local: {
369
+ tokenFile: ".odla/dev-token.json",
370
+ credentialsFile: ".odla/credentials.local.json",
371
+ devVarsFile: ".dev.vars",
372
+ },
373
+ };
374
+ ```
375
+
376
+ ```ts
377
+ // src/worker.ts
378
+ import { chapterWorker } from "@odla-ai/chapter/worker";
379
+ import { withObservability } from "@odla-ai/o11y";
380
+ import { chapter } from "./chapter.config.mjs";
381
+
382
+ export default withObservability(chapterWorker({ chapter }));
383
+ ```
384
+
385
+ Use current Cloudflare agent documentation for syntax, but preserve this
386
+ application contract in `wrangler.jsonc`:
387
+
388
+ - `main` is `src/worker.ts`;
389
+ - `nodejs_compat` is enabled for observability;
390
+ - static assets bind as `ASSETS` from `dist`, never the repository root;
391
+ - dev Worker name and `ODLA_TENANT=<chapter.id>--dev` are explicit;
392
+ - dev vars identify `ODLA_ENDPOINT`, `ODLA_PLATFORM`, `ODLA_APP_ID`,
393
+ `ODLA_ENV=dev`, and a verified `EMAIL_FROM`;
394
+ - the dev `ASSETS` and optional `send_email` bindings are declared inside
395
+ `env.dev` because Wrangler environments do not inherit bindings;
396
+ - no production binding, route, schedule, or credential enters the dev target.
397
+
398
+ Run:
399
+
400
+ ```sh
401
+ npx odla-ai doctor
402
+ npx odla-ai provision --dry-run
403
+ ```
404
+
405
+ Show the resolved tenant, services, schema/rules, auth, links, and Worker target
406
+ to the human. Then start the email-bound device handshake:
407
+
408
+ ```sh
409
+ npx odla-ai provision --email <existing-odla-account> --write-dev-vars
410
+ ```
411
+
412
+ The human reviews the exact device code in ODLA Studio. Google Calendar adds a
413
+ separate browser-owned booking-consent checkpoint for availability plus
414
+ create/reschedule/cancel access. A grant created for the retired read-only
415
+ mirror must be reconnected. No OAuth code or token passes through the agent.
416
+
417
+ ## Phase 4 — Build the four application surfaces
418
+
419
+ Import the application theme once, before host CSS:
420
+
421
+ ```ts
422
+ import "@odla-ai/ui/themes/salt/app.css";
423
+ import "./app.css";
424
+ ```
425
+
426
+ `BrandStyle` adds brand token overrides; it does not provide the theme layer or
427
+ member layout CSS. The host must style `join-form`, fields, payment, slots,
428
+ cards, member rows, responsive navigation, and public content.
429
+
430
+ ### Public pages
431
+
432
+ Build the approved route inventory, not a generic landing page. Use semantic
433
+ ODLA UI components/classes and the same header/footer across routes. Public
434
+ routes must render without waiting for Clerk. Include deliberate loading,
435
+ empty, error, 404, and reduced-motion states.
436
+
437
+ ### Join
438
+
439
+ Fetch `GET /api/join-config`; do not hardcode live price/policy/readiness. Render
440
+ `<JoinIsland config={config}>` with labeled inputs matching the configured field
441
+ names and a required `disclaimerAck` checkbox when configured. Test:
442
+
443
+ - invalid/missing required values;
444
+ - over-cap values and malformed email;
445
+ - missing and accepted disclaimer acknowledgement;
446
+ - concurrent double-click suppression and an ambiguous lost-response retry;
447
+ - `paymentsReady: false` and `true`;
448
+ - `schedulingReady: false`, slot conflict, booking, and completion.
449
+
450
+ `JoinIsland` disables its button while one request is pending, but creates a new
451
+ `submissionId` for every submit invocation. A retry after the server committed
452
+ but the response was lost can therefore create another application. If that is
453
+ unacceptable, stop and fix/upgrade the package or use a reviewed host adapter
454
+ that keeps one stable id across logical retries. Do not label the stock behavior
455
+ fully idempotent.
456
+
457
+ ### Members
458
+
459
+ Use `@odla-ai/auth-clerk` to render signed-out/sign-in and signed-in states. In
460
+ the signed-in child, build one `api(path, init)` function that calls
461
+ `useClerkAuth().getToken()`, adds `Authorization: Bearer <token>`, checks the
462
+ HTTP status, and returns JSON. Pass that function, `signOut`, and
463
+ `applyHref="/join"` to `<MembersArea>`; its package default is `/join.html`.
464
+ Do not put a Clerk secret key or ODLA admin key in browser code.
465
+
466
+ ### Admin
467
+
468
+ ```tsx
469
+ import { ChapterAdmin } from "@odla-ai/chapter/ui/admin";
470
+ import { chapter } from "../chapter.config.mjs";
471
+
472
+ export function AdminPage() {
473
+ return <ChapterAdmin chapter={chapter} basePath="/admin" />;
474
+ }
475
+ ```
476
+
477
+ Keep default query routing (`/admin/?tab=people`) unless the host has a tested
478
+ SPA fallback for every admin subpath. Pass custom sections only when the
479
+ product truly needs additional host-owned operations.
480
+
481
+ The router must serve and reload `/`, every public page, `/join`, `/members/`,
482
+ and `/admin/` without accidentally sending API paths to the SPA.
483
+
484
+ ## Phase 5 — Configure Clerk and bootstrap authority
485
+
486
+ At this step, read Clerk's current agent-facing CLI documentation. Use one
487
+ Clerk application with development and production instances; do not create a
488
+ second application for production.
489
+
490
+ For default chapter claim auth, the session token must include both:
491
+
492
+ ```json
493
+ {
494
+ "email": "{{user.primary_email_address}}",
495
+ "role": "{{user.public_metadata.role}}"
496
+ }
497
+ ```
498
+
499
+ Put only the development publishable key in `auth.clerk.dev`. Set the owner's
500
+ Clerk `public_metadata.role` to `"admin"`. If that owner may grant/change admin
501
+ roles, add their lowercase email to `superAdmins` in ODLA Studio.
502
+
503
+ For `mode: "hub"` with table auth, add the first admin's lowercase email to the
504
+ `admins` namespace instead. A mixed-case row silently fails to match.
505
+
506
+ If account creation/invitation or role writes are enabled, transfer the
507
+ app-readable named secret—not the platform-reserved Clerk sync secret:
508
+
509
+ ```sh
510
+ npx odla-ai secrets set clerk_secret_key --env dev --stdin
511
+ ```
512
+
513
+ Supply the value through stdin or a named environment variable without echoing
514
+ it. `secrets set-clerk-key` writes the reserved `$clerk_secret`; Chapter cannot
515
+ read that key as `clerk_secret_key`. The generic named-secret command does not
516
+ validate Clerk instance prefixes: independently confirm the dev source begins
517
+ with `sk_test_`. Never put `sk_live_` in dev; production requires `sk_live_`,
518
+ the literal `prod` environment, and explicit production approval.
519
+
520
+ Test signed out, provisional, member, admin, super-admin, malformed bearer,
521
+ expired session, forbidden account, sign out, and safe return routing on the
522
+ deployed dev origin.
523
+
524
+ ## Phase 6 — Configure Stripe, calendar, and email
525
+
526
+ ### Stripe test mode
527
+
528
+ 1. Create the approved recurring test product/price.
529
+ 2. Point a Stripe test webhook to
530
+ `https://<dev-origin>/api/webhooks/stripe`.
531
+ 3. Store `stripe_secret_key` and `stripe_webhook_secret` as named dev vault
532
+ secrets through write-only `odla-ai secrets set ... --stdin`.
533
+ 4. In the guarded `groups` row, set `stripePublishableKey` and `stripePriceId`.
534
+ 5. Read that exact Stripe Price from Stripe and compare its first charge,
535
+ recurring amount, currency, and interval with the approved public contract.
536
+ 6. Send and verify a Stripe test event before opening the payment UI.
537
+
538
+ `paymentsReady` checks the publishable key, price id, and Stripe secret key; it
539
+ does not prove the webhook secret works or that the Price matches Chapter's
540
+ displayed cents. The built-in formatter is USD/dollar-specific in this release;
541
+ non-USD or unmatched pricing needs a host UI/route and cannot use the stock
542
+ payment presentation. Never accept money until the provider equality check and
543
+ webhook test prove authoritative paid/refunded state and idempotent replay.
544
+
545
+ ### Google Calendar
546
+
547
+ Complete the human Google booking consent from provisioning, then:
548
+
549
+ ```sh
550
+ npx odla-ai calendar calendars --env dev --json
551
+ npx odla-ai calendar status --env dev --json
552
+ ```
553
+
554
+ Refine the checked-in calendar ids and re-provision. Verify
555
+ `GET /api/schedule/slots` returns `schedulingReady: true` and real bounded test
556
+ slots. It returns HTTP 200 with `schedulingReady: false` when unavailable, so a
557
+ generic HTTP smoke check is insufficient. Book and rebook one synthetic
558
+ applicant and confirm the Google invitation/Meet behavior.
559
+
560
+ ### Email
561
+
562
+ Configure Cloudflare's Email Service binding and an onboarded `EMAIL_FROM`.
563
+ Keep `debugEmail` in every non-production Chapter. Send a test lifecycle email,
564
+ prove it is redirected to the debug address, and confirm the bounded
565
+ `emailLog`. If `account: "invite"` is selected, separately prove a test
566
+ applicant receives a Clerk invitation. Chapter deduplicates a replay after the
567
+ success log exists; it does not serialize two concurrent sends. If true
568
+ exactly-once delivery is required, add a serialized outbox or provider
569
+ idempotency and test the concurrent case.
570
+
571
+ ## Phase 7 — Local and deployed development acceptance
572
+
573
+ Before the first deploy:
574
+
575
+ ```sh
576
+ npm test
577
+ npm run typecheck
578
+ npm run build
579
+ npx odla-security scan . --profile odla --out .odla/security/latest --fail-on high --fail-on-candidates critical
580
+ npx wrangler dev --env dev
581
+ ```
582
+
583
+ Required local contracts:
584
+
585
+ - config/integration snapshots described above;
586
+ - full join form, members gate, admin theme, and public routes render;
587
+ - `/api/health`, `/api/config`, `/api/join-config`, static fallback, and 404;
588
+ - valid/invalid application plus `disclaimerAckAt`;
589
+ - signed-out/member/admin authorization matrix;
590
+ - CRM routes and default-deny behavior;
591
+ - no secret-shaped value in source or client bundle.
592
+
593
+ Review every security candidate; a clean deterministic scan is not proof the
594
+ application is safe. Run the optional hosted ODLA security pass only after the
595
+ human reviews its current disclosure plan and explicitly approves the required
596
+ redacted-source acknowledgement.
597
+
598
+ Deploy only the dev Worker:
599
+
600
+ ```sh
601
+ npm run deploy:dev
602
+ ```
603
+
604
+ Record the actual URL and Cloudflare version id. Put the exact URL in
605
+ `links.dev`, re-run the provision dry run, then deliberately transfer Worker
606
+ secrets:
607
+
608
+ ```sh
609
+ npx odla-ai provision --email <existing-odla-account> --write-dev-vars --push-secrets
610
+ npx odla-ai smoke --env dev
611
+ ```
612
+
613
+ Run a real deployed journey:
614
+
615
+ 1. public home and every navigation/deep link;
616
+ 2. join-config readiness, application submit, test payment, booking;
617
+ 3. Clerk account/invite behavior selected by config;
618
+ 4. provisional and member area;
619
+ 5. admin sign-in, people/CRM, application approval, meeting, billing, email;
620
+ 6. replay/idempotency and one safe error;
621
+ 7. o11y trace with no secret, email, token, or request body;
622
+ 8. visual review at the four target widths in light/dark mode.
623
+
624
+ The human reviews the actual browser result. Source inspection is not visual
625
+ acceptance.
626
+
627
+ ## Phase 8 — Production checkpoint
628
+
629
+ Production is a separate operation. Only after dev acceptance:
630
+
631
+ 1. add `prod` to `envs`, `auth.clerk.prod`,
632
+ `calendar.google.availabilityCalendars.prod`,
633
+ `calendar.google.bookingCalendar.prod`, `links.prod`, and reviewed top-level
634
+ Wrangler prod vars/bindings;
635
+ 2. use the production instance of the same Clerk app and Stripe live-mode
636
+ resources;
637
+ 3. run `doctor`, production build/tests, the installed ODLA security preflight,
638
+ and `provision --dry-run`;
639
+ 4. show the complete production plan, secrets, routes, email, billing, and
640
+ rollback to the human;
641
+ 5. after explicit approval, provision with `--yes`, transfer each prod secret
642
+ through write-only flows, deploy the off-route candidate, and run
643
+ `smoke --env prod`;
644
+ 6. repeat the entire Chapter journey with production-safe synthetic data before
645
+ attaching DNS or accepting real money/mail;
646
+ 7. record the previous Worker version and route configuration as runtime
647
+ rollback. Preserve database rows during rollback; do not reset data.
648
+
649
+ The build is complete only when the checked-in decisions, deployed dev journey,
650
+ provider readiness, responsive visual review, and production rollback evidence
651
+ all exist. A working homepage or passing ODLA smoke alone is not a Chapter
652
+ website.