@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.
- package/README.md +195 -39
- package/package.json +3 -2
- package/runbooks/adopt-existing.md +505 -0
- package/runbooks/greenfield.md +652 -0
|
@@ -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.
|