@odla-ai/chapter 0.0.1 → 0.0.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 CHANGED
@@ -3,8 +3,7 @@
3
3
  A foundation for **membership sites**. One `defineChapter({...})` config
4
4
  stands up a full member site — join/apply, Stripe membership, Google booking, a
5
5
  member area, an admin console, and a CRM — or an **admin-only hub**, on odla-db +
6
- Clerk + [@odla-ai/crm](https://odla.ai/docs/packages/crm) + calendar + email, with
7
- a themeable web-component chrome engine.
6
+ Clerk + [@odla-ai/crm](https://odla.ai/docs/packages/crm) + calendar + email.
8
7
 
9
8
  ```sh
10
9
  npm i @odla-ai/chapter
@@ -13,6 +12,10 @@ npm i @odla-ai/chapter
13
12
  > **Agentic experiment.** Built and maintained by AI agents from bounded runbooks
14
13
  > with human review. Review the documented guarantees before relying on it.
15
14
 
15
+ > **Early access (pre-1.0).** Hub mode — the admin console, CRM, and provisioning
16
+ > — is complete. The public member surface (join/apply, Stripe membership, Google
17
+ > booking, member area) is being ported release by release.
18
+
16
19
  ## The shape
17
20
 
18
21
  - **One config, two profiles.** `defineChapter()` validates at import and returns
@@ -21,18 +24,22 @@ npm i @odla-ai/chapter
21
24
  Same worker shell, auth, CRM, chrome, and provisioning — the mode gates the
22
25
  member/join/payment route surface.
23
26
  - **The worker is the package.** `chapterWorker({ chapter })` is the whole
24
- Cloudflare `ExportedHandler` (auth verify, CRM mount, Stripe, calendar, email,
25
- webhooks, admin), wrapped in observability. A site's `src/worker.ts` is ~3
26
- lines. Everything brand-specific (prices, policy copy, email templates,
27
- scheduling) is read at runtime from a single odla-db `groups` row, never
28
- hardcoded.
27
+ Cloudflare `ExportedHandler` today: Clerk-JWT verify, the odla-db
28
+ admins-allowlist gate, the mounted `@odla-ai/crm` routes, and the static-asset
29
+ fallback (the member/join/Stripe/booking routes are being added). A site's
30
+ `src/worker.ts` is ~3 lines. Observability is a host concern wrap it with
31
+ `withObservability` from `@odla-ai/o11y` in your entry if you run it. Everything
32
+ brand-specific (prices, policy copy, email templates, scheduling) is read at
33
+ runtime from a single odla-db `groups` row, never hardcoded.
29
34
  - **Provisioning is declarative.** `createChapterIntegration(chapter, opts)` is a
30
35
  CLI-consumable descriptor that composes the crm namespaces + the chapter
31
36
  namespaces (`applications`, `groups`, `meetings`, `emailLog`, `admins`) + a
32
37
  guarded group-row seed. Drop it in `odla.config.mjs` `integrations: [...]`.
33
- - **Chrome, not content.** `./ui` ships the Preact islands (admin/member/join)
34
- and a themeable `<site-header>`/`<site-footer>` engine over `@odla-ai/ui`; each
35
- site authors its own marketing page bodies from its brandbook.
38
+ - **The admin UI.** `./ui` ships `<ChapterAdmin>` — the console shell (config
39
+ fetch, themed Clerk sign-in, the allowlist gate, and a TopBar that renders the
40
+ sections you pass), authored against React and rendered as Preact via
41
+ `preact/compat` in the reference sites. The member islands (join, members,
42
+ slot-picker) and brand-token theming are on the roadmap.
36
43
 
37
44
  ## Quick start
38
45
 
@@ -25,7 +25,6 @@ __export(worker_exports, {
25
25
  module.exports = __toCommonJS(worker_exports);
26
26
  var import_db = require("@odla-ai/db");
27
27
  var import_crm = require("@odla-ai/crm");
28
- var import_o11y = require("@odla-ai/o11y");
29
28
  var import_jose = require("jose");
30
29
  var json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
31
30
  function chapterWorker(options) {
@@ -117,6 +116,6 @@ function chapterWorker(options) {
117
116
  return env.ASSETS.fetch(req);
118
117
  }
119
118
  };
120
- return (0, import_o11y.withObservability)(handler);
119
+ return handler;
121
120
  }
122
121
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/worker.ts"],"sourcesContent":["// chapterWorker — the Cloudflare Worker for a chapter/hub site. This entry\n// (@odla-ai/chapter/worker) is separate from the core so the CLI can load\n// odla.config.mjs without pulling in worker-runtime deps.\n//\n// The worker is the ONLY thing that talks to odla-db, using the app key\n// (ODLA_API_KEY), which bypasses the deny-all rules. Browsers never receive a\n// db credential. Access is admin-only: a request is authorized when its Clerk\n// session JWT verifies AND the user's lowercased email has a row in the\n// Studio-seeded `admins` allowlist.\n//\n// hub mode routes: GET /api/config, GET /api/me, /api/crm/*, else ASSETS.\n// chapter mode adds the public member/join/Stripe/booking surface (ported next).\nimport { initAdmin } from \"@odla-ai/db\";\nimport { createCrmRoutes } from \"@odla-ai/crm\";\nimport { withObservability } from \"@odla-ai/o11y\";\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport type { Chapter } from \"./types\";\n\ninterface EmailPayload {\n from: string;\n to: string[];\n subject: string;\n text?: string;\n html?: string;\n replyTo?: string;\n headers?: Record<string, string>;\n}\n\n/** The Worker env a chapter site provides (wrangler vars + the ODLA_API_KEY\n * secret pushed by provision). */\nexport interface ChapterEnv {\n ASSETS: { fetch(req: Request): Promise<Response> };\n ODLA_ENDPOINT: string;\n ODLA_TENANT: string;\n ODLA_PLATFORM: string;\n ODLA_APP_ID: string;\n ODLA_ENV: string;\n ODLA_API_KEY: string;\n SEND_EMAIL?: { send(payload: EmailPayload): Promise<{ messageId: string }> };\n EMAIL_FROM?: string;\n}\n\n/** Options for {@link chapterWorker}. */\nexport interface ChapterWorkerOptions {\n chapter: Chapter;\n /** CRM mount point. Default \"/api/crm\". */\n crmBasePath?: string;\n}\n\ntype PublicConfig = { env?: string; clerkPublishableKey?: string | null; issuer?: string | null };\ntype Db = ReturnType<typeof initAdmin>;\n\nconst json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), { status, headers: { \"content-type\": \"application/json\" } });\n\n/**\n * Build the Cloudflare `ExportedHandler` for a chapter/hub site: Clerk-JWT\n * verification, the odla-db admins-allowlist gate, the mounted @odla-ai/crm\n * routes, and the static-asset fallback, wrapped in observability. In hub mode\n * it serves /api/config, /api/me, /api/crm/*; chapter mode adds the public\n * member surface (join/Stripe/booking — ported next).\n */\nexport function chapterWorker(options: ChapterWorkerOptions) {\n const { chapter } = options;\n const crmBase = options.crmBasePath ?? \"/api/crm\";\n\n let publicConfigCache: { value: PublicConfig; at: number } | null = null;\n const jwksByIssuer = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\n\n async function getPublicConfig(env: ChapterEnv): Promise<PublicConfig> {\n if (publicConfigCache && Date.now() - publicConfigCache.at < 5 * 60_000) return publicConfigCache.value;\n const res = await fetch(\n `${env.ODLA_PLATFORM}/registry/apps/${env.ODLA_APP_ID}/public-config?env=${env.ODLA_ENV}`,\n );\n if (!res.ok) throw new Error(`public-config fetch failed: ${res.status}`);\n const value = (await res.json()) as PublicConfig;\n publicConfigCache = { value, at: Date.now() };\n return value;\n }\n\n async function verifyUser(req: Request, env: ChapterEnv): Promise<{ userId: string; email?: string } | null> {\n const header = req.headers.get(\"authorization\") ?? \"\";\n if (!header.startsWith(\"Bearer \")) return null;\n const token = header.slice(7);\n const { issuer } = await getPublicConfig(env);\n if (!issuer) return null;\n let jwks = jwksByIssuer.get(issuer);\n if (!jwks) {\n jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));\n jwksByIssuer.set(issuer, jwks);\n }\n try {\n const { payload } = await jwtVerify(token, jwks, { issuer });\n if (!payload.sub) return null;\n return { userId: payload.sub, email: typeof payload.email === \"string\" ? payload.email : undefined };\n } catch {\n return null;\n }\n }\n\n function makeDb(env: ChapterEnv): Db {\n return initAdmin({ appId: env.ODLA_TENANT, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_ENDPOINT });\n }\n\n // The allowlist gate: no route ever writes `admins`, so membership can only be\n // granted by a human in odla Studio.\n async function isAdminEmail(db: Db, email: string | undefined): Promise<boolean> {\n if (!email) return false;\n const { admins } = await db.query({ admins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });\n return Array.isArray(admins) && admins.length > 0;\n }\n\n function crmSender(env: ChapterEnv) {\n if (!env.SEND_EMAIL || !env.EMAIL_FROM) return undefined;\n const binding = env.SEND_EMAIL;\n return { async send(payload: EmailPayload): Promise<{ messageId: string }> { return binding.send(payload); } };\n }\n\n const handler = {\n async fetch(req: Request, env: ChapterEnv): Promise<Response> {\n const url = new URL(req.url);\n\n // Public: the SPA reads the Clerk publishable key to boot sign-in.\n if (url.pathname === \"/api/config\") {\n try {\n const { clerkPublishableKey } = await getPublicConfig(env);\n return json({ clerkPublishableKey: clerkPublishableKey ?? null, env: env.ODLA_ENV });\n } catch {\n return json({ clerkPublishableKey: null, env: env.ODLA_ENV });\n }\n }\n\n // Auth: is the signed-in user an allowlisted admin?\n if (url.pathname === \"/api/me\") {\n const u = await verifyUser(req, env);\n if (!u) return json({ authorized: false }, 401);\n const authorized = await isAdminEmail(makeDb(env), u.email);\n return json({ authorized, email: u.email ?? null });\n }\n\n // CRM admin surface.\n if (url.pathname === crmBase || url.pathname.startsWith(crmBase + \"/\")) {\n const db = makeDb(env);\n const routes = createCrmRoutes({\n crm: chapter.crm,\n db: db as never,\n authorize: async (r: Request) => {\n const u = await verifyUser(r, env);\n if (!u || !(await isAdminEmail(db, u.email))) return null;\n return u.email ? { userId: u.userId, email: u.email } : { userId: u.userId };\n },\n sender: crmSender(env),\n from: env.EMAIL_FROM,\n envName: env.ODLA_ENV,\n baseUrl: url.origin,\n basePath: crmBase,\n });\n const res = await routes(req);\n if (res) return res;\n return json({ error: \"not found\" }, 404);\n }\n\n // chapter mode adds the public member/join/Stripe/booking routes here.\n\n // Everything else is the static site.\n return env.ASSETS.fetch(req);\n },\n };\n\n return withObservability(handler as never);\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/worker.ts"],"sourcesContent":["// chapterWorker — the Cloudflare Worker for a chapter/hub site. This entry\n// (@odla-ai/chapter/worker) is separate from the core so the CLI can load\n// odla.config.mjs without pulling in worker-runtime deps.\n//\n// The worker is the ONLY thing that talks to odla-db, using the app key\n// (ODLA_API_KEY), which bypasses the deny-all rules. Browsers never receive a\n// db credential. Access is admin-only: a request is authorized when its Clerk\n// session JWT verifies AND the user's lowercased email has a row in the\n// Studio-seeded `admins` allowlist.\n//\n// hub mode routes: GET /api/config, GET /api/me, /api/crm/*, else ASSETS.\n// chapter mode adds the public member/join/Stripe/booking surface (ported next).\nimport { initAdmin } from \"@odla-ai/db\";\nimport { createCrmRoutes } from \"@odla-ai/crm\";\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport type { Chapter } from \"./types\";\n\ninterface EmailPayload {\n from: string;\n to: string[];\n subject: string;\n text?: string;\n html?: string;\n replyTo?: string;\n headers?: Record<string, string>;\n}\n\n/** The Worker env a chapter site provides (wrangler vars + the ODLA_API_KEY\n * secret pushed by provision). */\nexport interface ChapterEnv {\n ASSETS: { fetch(req: Request): Promise<Response> };\n ODLA_ENDPOINT: string;\n ODLA_TENANT: string;\n ODLA_PLATFORM: string;\n ODLA_APP_ID: string;\n ODLA_ENV: string;\n ODLA_API_KEY: string;\n SEND_EMAIL?: { send(payload: EmailPayload): Promise<{ messageId: string }> };\n EMAIL_FROM?: string;\n}\n\n/** Options for {@link chapterWorker}. */\nexport interface ChapterWorkerOptions {\n chapter: Chapter;\n /** CRM mount point. Default \"/api/crm\". */\n crmBasePath?: string;\n}\n\ntype PublicConfig = { env?: string; clerkPublishableKey?: string | null; issuer?: string | null };\ntype Db = ReturnType<typeof initAdmin>;\n\nconst json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), { status, headers: { \"content-type\": \"application/json\" } });\n\n/**\n * Build the Cloudflare `ExportedHandler` for a chapter/hub site: Clerk-JWT\n * verification, the odla-db admins-allowlist gate, the mounted @odla-ai/crm\n * routes, and the static-asset fallback. In hub mode it serves /api/config,\n * /api/me, /api/crm/*; chapter mode adds the public member surface\n * (join/Stripe/booking — ported next).\n *\n * Observability is a host concern, not a chapter dependency. To trace, wrap the\n * result in your worker entry — `export default withObservability(chapterWorker(\n * { chapter }))` — with `withObservability` from `@odla-ai/o11y`. Sites that\n * don't run o11y bundle `@odla-ai/chapter/worker` without installing it.\n */\nexport function chapterWorker(options: ChapterWorkerOptions) {\n const { chapter } = options;\n const crmBase = options.crmBasePath ?? \"/api/crm\";\n\n let publicConfigCache: { value: PublicConfig; at: number } | null = null;\n const jwksByIssuer = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\n\n async function getPublicConfig(env: ChapterEnv): Promise<PublicConfig> {\n if (publicConfigCache && Date.now() - publicConfigCache.at < 5 * 60_000) return publicConfigCache.value;\n const res = await fetch(\n `${env.ODLA_PLATFORM}/registry/apps/${env.ODLA_APP_ID}/public-config?env=${env.ODLA_ENV}`,\n );\n if (!res.ok) throw new Error(`public-config fetch failed: ${res.status}`);\n const value = (await res.json()) as PublicConfig;\n publicConfigCache = { value, at: Date.now() };\n return value;\n }\n\n async function verifyUser(req: Request, env: ChapterEnv): Promise<{ userId: string; email?: string } | null> {\n const header = req.headers.get(\"authorization\") ?? \"\";\n if (!header.startsWith(\"Bearer \")) return null;\n const token = header.slice(7);\n const { issuer } = await getPublicConfig(env);\n if (!issuer) return null;\n let jwks = jwksByIssuer.get(issuer);\n if (!jwks) {\n jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));\n jwksByIssuer.set(issuer, jwks);\n }\n try {\n const { payload } = await jwtVerify(token, jwks, { issuer });\n if (!payload.sub) return null;\n return { userId: payload.sub, email: typeof payload.email === \"string\" ? payload.email : undefined };\n } catch {\n return null;\n }\n }\n\n function makeDb(env: ChapterEnv): Db {\n return initAdmin({ appId: env.ODLA_TENANT, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_ENDPOINT });\n }\n\n // The allowlist gate: no route ever writes `admins`, so membership can only be\n // granted by a human in odla Studio.\n async function isAdminEmail(db: Db, email: string | undefined): Promise<boolean> {\n if (!email) return false;\n const { admins } = await db.query({ admins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });\n return Array.isArray(admins) && admins.length > 0;\n }\n\n function crmSender(env: ChapterEnv) {\n if (!env.SEND_EMAIL || !env.EMAIL_FROM) return undefined;\n const binding = env.SEND_EMAIL;\n return { async send(payload: EmailPayload): Promise<{ messageId: string }> { return binding.send(payload); } };\n }\n\n const handler = {\n async fetch(req: Request, env: ChapterEnv): Promise<Response> {\n const url = new URL(req.url);\n\n // Public: the SPA reads the Clerk publishable key to boot sign-in.\n if (url.pathname === \"/api/config\") {\n try {\n const { clerkPublishableKey } = await getPublicConfig(env);\n return json({ clerkPublishableKey: clerkPublishableKey ?? null, env: env.ODLA_ENV });\n } catch {\n return json({ clerkPublishableKey: null, env: env.ODLA_ENV });\n }\n }\n\n // Auth: is the signed-in user an allowlisted admin?\n if (url.pathname === \"/api/me\") {\n const u = await verifyUser(req, env);\n if (!u) return json({ authorized: false }, 401);\n const authorized = await isAdminEmail(makeDb(env), u.email);\n return json({ authorized, email: u.email ?? null });\n }\n\n // CRM admin surface.\n if (url.pathname === crmBase || url.pathname.startsWith(crmBase + \"/\")) {\n const db = makeDb(env);\n const routes = createCrmRoutes({\n crm: chapter.crm,\n db: db as never,\n authorize: async (r: Request) => {\n const u = await verifyUser(r, env);\n if (!u || !(await isAdminEmail(db, u.email))) return null;\n return u.email ? { userId: u.userId, email: u.email } : { userId: u.userId };\n },\n sender: crmSender(env),\n from: env.EMAIL_FROM,\n envName: env.ODLA_ENV,\n baseUrl: url.origin,\n basePath: crmBase,\n });\n const res = await routes(req);\n if (res) return res;\n return json({ error: \"not found\" }, 404);\n }\n\n // chapter mode adds the public member/join/Stripe/booking routes here.\n\n // Everything else is the static site.\n return env.ASSETS.fetch(req);\n },\n };\n\n return handler;\n}\n"],"mappings":"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","names":[]}
@@ -168,10 +168,17 @@ interface ChapterWorkerOptions {
168
168
  /**
169
169
  * Build the Cloudflare `ExportedHandler` for a chapter/hub site: Clerk-JWT
170
170
  * verification, the odla-db admins-allowlist gate, the mounted @odla-ai/crm
171
- * routes, and the static-asset fallback, wrapped in observability. In hub mode
172
- * it serves /api/config, /api/me, /api/crm/*; chapter mode adds the public
173
- * member surface (join/Stripe/booking — ported next).
171
+ * routes, and the static-asset fallback. In hub mode it serves /api/config,
172
+ * /api/me, /api/crm/*; chapter mode adds the public member surface
173
+ * (join/Stripe/booking — ported next).
174
+ *
175
+ * Observability is a host concern, not a chapter dependency. To trace, wrap the
176
+ * result in your worker entry — `export default withObservability(chapterWorker(
177
+ * { chapter }))` — with `withObservability` from `@odla-ai/o11y`. Sites that
178
+ * don't run o11y bundle `@odla-ai/chapter/worker` without installing it.
174
179
  */
175
- declare function chapterWorker(options: ChapterWorkerOptions): ExportedHandler<E>;
180
+ declare function chapterWorker(options: ChapterWorkerOptions): {
181
+ fetch(req: Request, env: ChapterEnv): Promise<Response>;
182
+ };
176
183
 
177
184
  export { type ChapterEnv, type ChapterWorkerOptions, chapterWorker };
@@ -168,10 +168,17 @@ interface ChapterWorkerOptions {
168
168
  /**
169
169
  * Build the Cloudflare `ExportedHandler` for a chapter/hub site: Clerk-JWT
170
170
  * verification, the odla-db admins-allowlist gate, the mounted @odla-ai/crm
171
- * routes, and the static-asset fallback, wrapped in observability. In hub mode
172
- * it serves /api/config, /api/me, /api/crm/*; chapter mode adds the public
173
- * member surface (join/Stripe/booking — ported next).
171
+ * routes, and the static-asset fallback. In hub mode it serves /api/config,
172
+ * /api/me, /api/crm/*; chapter mode adds the public member surface
173
+ * (join/Stripe/booking — ported next).
174
+ *
175
+ * Observability is a host concern, not a chapter dependency. To trace, wrap the
176
+ * result in your worker entry — `export default withObservability(chapterWorker(
177
+ * { chapter }))` — with `withObservability` from `@odla-ai/o11y`. Sites that
178
+ * don't run o11y bundle `@odla-ai/chapter/worker` without installing it.
174
179
  */
175
- declare function chapterWorker(options: ChapterWorkerOptions): ExportedHandler<E>;
180
+ declare function chapterWorker(options: ChapterWorkerOptions): {
181
+ fetch(req: Request, env: ChapterEnv): Promise<Response>;
182
+ };
176
183
 
177
184
  export { type ChapterEnv, type ChapterWorkerOptions, chapterWorker };
@@ -1,7 +1,6 @@
1
1
  // src/worker.ts
2
2
  import { initAdmin } from "@odla-ai/db";
3
3
  import { createCrmRoutes } from "@odla-ai/crm";
4
- import { withObservability } from "@odla-ai/o11y";
5
4
  import { createRemoteJWKSet, jwtVerify } from "jose";
6
5
  var json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
7
6
  function chapterWorker(options) {
@@ -93,7 +92,7 @@ function chapterWorker(options) {
93
92
  return env.ASSETS.fetch(req);
94
93
  }
95
94
  };
96
- return withObservability(handler);
95
+ return handler;
97
96
  }
98
97
  export {
99
98
  chapterWorker
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/worker.ts"],"sourcesContent":["// chapterWorker — the Cloudflare Worker for a chapter/hub site. This entry\n// (@odla-ai/chapter/worker) is separate from the core so the CLI can load\n// odla.config.mjs without pulling in worker-runtime deps.\n//\n// The worker is the ONLY thing that talks to odla-db, using the app key\n// (ODLA_API_KEY), which bypasses the deny-all rules. Browsers never receive a\n// db credential. Access is admin-only: a request is authorized when its Clerk\n// session JWT verifies AND the user's lowercased email has a row in the\n// Studio-seeded `admins` allowlist.\n//\n// hub mode routes: GET /api/config, GET /api/me, /api/crm/*, else ASSETS.\n// chapter mode adds the public member/join/Stripe/booking surface (ported next).\nimport { initAdmin } from \"@odla-ai/db\";\nimport { createCrmRoutes } from \"@odla-ai/crm\";\nimport { withObservability } from \"@odla-ai/o11y\";\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport type { Chapter } from \"./types\";\n\ninterface EmailPayload {\n from: string;\n to: string[];\n subject: string;\n text?: string;\n html?: string;\n replyTo?: string;\n headers?: Record<string, string>;\n}\n\n/** The Worker env a chapter site provides (wrangler vars + the ODLA_API_KEY\n * secret pushed by provision). */\nexport interface ChapterEnv {\n ASSETS: { fetch(req: Request): Promise<Response> };\n ODLA_ENDPOINT: string;\n ODLA_TENANT: string;\n ODLA_PLATFORM: string;\n ODLA_APP_ID: string;\n ODLA_ENV: string;\n ODLA_API_KEY: string;\n SEND_EMAIL?: { send(payload: EmailPayload): Promise<{ messageId: string }> };\n EMAIL_FROM?: string;\n}\n\n/** Options for {@link chapterWorker}. */\nexport interface ChapterWorkerOptions {\n chapter: Chapter;\n /** CRM mount point. Default \"/api/crm\". */\n crmBasePath?: string;\n}\n\ntype PublicConfig = { env?: string; clerkPublishableKey?: string | null; issuer?: string | null };\ntype Db = ReturnType<typeof initAdmin>;\n\nconst json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), { status, headers: { \"content-type\": \"application/json\" } });\n\n/**\n * Build the Cloudflare `ExportedHandler` for a chapter/hub site: Clerk-JWT\n * verification, the odla-db admins-allowlist gate, the mounted @odla-ai/crm\n * routes, and the static-asset fallback, wrapped in observability. In hub mode\n * it serves /api/config, /api/me, /api/crm/*; chapter mode adds the public\n * member surface (join/Stripe/booking — ported next).\n */\nexport function chapterWorker(options: ChapterWorkerOptions) {\n const { chapter } = options;\n const crmBase = options.crmBasePath ?? \"/api/crm\";\n\n let publicConfigCache: { value: PublicConfig; at: number } | null = null;\n const jwksByIssuer = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\n\n async function getPublicConfig(env: ChapterEnv): Promise<PublicConfig> {\n if (publicConfigCache && Date.now() - publicConfigCache.at < 5 * 60_000) return publicConfigCache.value;\n const res = await fetch(\n `${env.ODLA_PLATFORM}/registry/apps/${env.ODLA_APP_ID}/public-config?env=${env.ODLA_ENV}`,\n );\n if (!res.ok) throw new Error(`public-config fetch failed: ${res.status}`);\n const value = (await res.json()) as PublicConfig;\n publicConfigCache = { value, at: Date.now() };\n return value;\n }\n\n async function verifyUser(req: Request, env: ChapterEnv): Promise<{ userId: string; email?: string } | null> {\n const header = req.headers.get(\"authorization\") ?? \"\";\n if (!header.startsWith(\"Bearer \")) return null;\n const token = header.slice(7);\n const { issuer } = await getPublicConfig(env);\n if (!issuer) return null;\n let jwks = jwksByIssuer.get(issuer);\n if (!jwks) {\n jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));\n jwksByIssuer.set(issuer, jwks);\n }\n try {\n const { payload } = await jwtVerify(token, jwks, { issuer });\n if (!payload.sub) return null;\n return { userId: payload.sub, email: typeof payload.email === \"string\" ? payload.email : undefined };\n } catch {\n return null;\n }\n }\n\n function makeDb(env: ChapterEnv): Db {\n return initAdmin({ appId: env.ODLA_TENANT, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_ENDPOINT });\n }\n\n // The allowlist gate: no route ever writes `admins`, so membership can only be\n // granted by a human in odla Studio.\n async function isAdminEmail(db: Db, email: string | undefined): Promise<boolean> {\n if (!email) return false;\n const { admins } = await db.query({ admins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });\n return Array.isArray(admins) && admins.length > 0;\n }\n\n function crmSender(env: ChapterEnv) {\n if (!env.SEND_EMAIL || !env.EMAIL_FROM) return undefined;\n const binding = env.SEND_EMAIL;\n return { async send(payload: EmailPayload): Promise<{ messageId: string }> { return binding.send(payload); } };\n }\n\n const handler = {\n async fetch(req: Request, env: ChapterEnv): Promise<Response> {\n const url = new URL(req.url);\n\n // Public: the SPA reads the Clerk publishable key to boot sign-in.\n if (url.pathname === \"/api/config\") {\n try {\n const { clerkPublishableKey } = await getPublicConfig(env);\n return json({ clerkPublishableKey: clerkPublishableKey ?? null, env: env.ODLA_ENV });\n } catch {\n return json({ clerkPublishableKey: null, env: env.ODLA_ENV });\n }\n }\n\n // Auth: is the signed-in user an allowlisted admin?\n if (url.pathname === \"/api/me\") {\n const u = await verifyUser(req, env);\n if (!u) return json({ authorized: false }, 401);\n const authorized = await isAdminEmail(makeDb(env), u.email);\n return json({ authorized, email: u.email ?? null });\n }\n\n // CRM admin surface.\n if (url.pathname === crmBase || url.pathname.startsWith(crmBase + \"/\")) {\n const db = makeDb(env);\n const routes = createCrmRoutes({\n crm: chapter.crm,\n db: db as never,\n authorize: async (r: Request) => {\n const u = await verifyUser(r, env);\n if (!u || !(await isAdminEmail(db, u.email))) return null;\n return u.email ? { userId: u.userId, email: u.email } : { userId: u.userId };\n },\n sender: crmSender(env),\n from: env.EMAIL_FROM,\n envName: env.ODLA_ENV,\n baseUrl: url.origin,\n basePath: crmBase,\n });\n const res = await routes(req);\n if (res) return res;\n return json({ error: \"not found\" }, 404);\n }\n\n // chapter mode adds the public member/join/Stripe/booking routes here.\n\n // Everything else is the static site.\n return env.ASSETS.fetch(req);\n },\n };\n\n return withObservability(handler as never);\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/worker.ts"],"sourcesContent":["// chapterWorker — the Cloudflare Worker for a chapter/hub site. This entry\n// (@odla-ai/chapter/worker) is separate from the core so the CLI can load\n// odla.config.mjs without pulling in worker-runtime deps.\n//\n// The worker is the ONLY thing that talks to odla-db, using the app key\n// (ODLA_API_KEY), which bypasses the deny-all rules. Browsers never receive a\n// db credential. Access is admin-only: a request is authorized when its Clerk\n// session JWT verifies AND the user's lowercased email has a row in the\n// Studio-seeded `admins` allowlist.\n//\n// hub mode routes: GET /api/config, GET /api/me, /api/crm/*, else ASSETS.\n// chapter mode adds the public member/join/Stripe/booking surface (ported next).\nimport { initAdmin } from \"@odla-ai/db\";\nimport { createCrmRoutes } from \"@odla-ai/crm\";\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport type { Chapter } from \"./types\";\n\ninterface EmailPayload {\n from: string;\n to: string[];\n subject: string;\n text?: string;\n html?: string;\n replyTo?: string;\n headers?: Record<string, string>;\n}\n\n/** The Worker env a chapter site provides (wrangler vars + the ODLA_API_KEY\n * secret pushed by provision). */\nexport interface ChapterEnv {\n ASSETS: { fetch(req: Request): Promise<Response> };\n ODLA_ENDPOINT: string;\n ODLA_TENANT: string;\n ODLA_PLATFORM: string;\n ODLA_APP_ID: string;\n ODLA_ENV: string;\n ODLA_API_KEY: string;\n SEND_EMAIL?: { send(payload: EmailPayload): Promise<{ messageId: string }> };\n EMAIL_FROM?: string;\n}\n\n/** Options for {@link chapterWorker}. */\nexport interface ChapterWorkerOptions {\n chapter: Chapter;\n /** CRM mount point. Default \"/api/crm\". */\n crmBasePath?: string;\n}\n\ntype PublicConfig = { env?: string; clerkPublishableKey?: string | null; issuer?: string | null };\ntype Db = ReturnType<typeof initAdmin>;\n\nconst json = (body: unknown, status = 200): Response =>\n new Response(JSON.stringify(body), { status, headers: { \"content-type\": \"application/json\" } });\n\n/**\n * Build the Cloudflare `ExportedHandler` for a chapter/hub site: Clerk-JWT\n * verification, the odla-db admins-allowlist gate, the mounted @odla-ai/crm\n * routes, and the static-asset fallback. In hub mode it serves /api/config,\n * /api/me, /api/crm/*; chapter mode adds the public member surface\n * (join/Stripe/booking — ported next).\n *\n * Observability is a host concern, not a chapter dependency. To trace, wrap the\n * result in your worker entry — `export default withObservability(chapterWorker(\n * { chapter }))` — with `withObservability` from `@odla-ai/o11y`. Sites that\n * don't run o11y bundle `@odla-ai/chapter/worker` without installing it.\n */\nexport function chapterWorker(options: ChapterWorkerOptions) {\n const { chapter } = options;\n const crmBase = options.crmBasePath ?? \"/api/crm\";\n\n let publicConfigCache: { value: PublicConfig; at: number } | null = null;\n const jwksByIssuer = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\n\n async function getPublicConfig(env: ChapterEnv): Promise<PublicConfig> {\n if (publicConfigCache && Date.now() - publicConfigCache.at < 5 * 60_000) return publicConfigCache.value;\n const res = await fetch(\n `${env.ODLA_PLATFORM}/registry/apps/${env.ODLA_APP_ID}/public-config?env=${env.ODLA_ENV}`,\n );\n if (!res.ok) throw new Error(`public-config fetch failed: ${res.status}`);\n const value = (await res.json()) as PublicConfig;\n publicConfigCache = { value, at: Date.now() };\n return value;\n }\n\n async function verifyUser(req: Request, env: ChapterEnv): Promise<{ userId: string; email?: string } | null> {\n const header = req.headers.get(\"authorization\") ?? \"\";\n if (!header.startsWith(\"Bearer \")) return null;\n const token = header.slice(7);\n const { issuer } = await getPublicConfig(env);\n if (!issuer) return null;\n let jwks = jwksByIssuer.get(issuer);\n if (!jwks) {\n jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));\n jwksByIssuer.set(issuer, jwks);\n }\n try {\n const { payload } = await jwtVerify(token, jwks, { issuer });\n if (!payload.sub) return null;\n return { userId: payload.sub, email: typeof payload.email === \"string\" ? payload.email : undefined };\n } catch {\n return null;\n }\n }\n\n function makeDb(env: ChapterEnv): Db {\n return initAdmin({ appId: env.ODLA_TENANT, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_ENDPOINT });\n }\n\n // The allowlist gate: no route ever writes `admins`, so membership can only be\n // granted by a human in odla Studio.\n async function isAdminEmail(db: Db, email: string | undefined): Promise<boolean> {\n if (!email) return false;\n const { admins } = await db.query({ admins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });\n return Array.isArray(admins) && admins.length > 0;\n }\n\n function crmSender(env: ChapterEnv) {\n if (!env.SEND_EMAIL || !env.EMAIL_FROM) return undefined;\n const binding = env.SEND_EMAIL;\n return { async send(payload: EmailPayload): Promise<{ messageId: string }> { return binding.send(payload); } };\n }\n\n const handler = {\n async fetch(req: Request, env: ChapterEnv): Promise<Response> {\n const url = new URL(req.url);\n\n // Public: the SPA reads the Clerk publishable key to boot sign-in.\n if (url.pathname === \"/api/config\") {\n try {\n const { clerkPublishableKey } = await getPublicConfig(env);\n return json({ clerkPublishableKey: clerkPublishableKey ?? null, env: env.ODLA_ENV });\n } catch {\n return json({ clerkPublishableKey: null, env: env.ODLA_ENV });\n }\n }\n\n // Auth: is the signed-in user an allowlisted admin?\n if (url.pathname === \"/api/me\") {\n const u = await verifyUser(req, env);\n if (!u) return json({ authorized: false }, 401);\n const authorized = await isAdminEmail(makeDb(env), u.email);\n return json({ authorized, email: u.email ?? null });\n }\n\n // CRM admin surface.\n if (url.pathname === crmBase || url.pathname.startsWith(crmBase + \"/\")) {\n const db = makeDb(env);\n const routes = createCrmRoutes({\n crm: chapter.crm,\n db: db as never,\n authorize: async (r: Request) => {\n const u = await verifyUser(r, env);\n if (!u || !(await isAdminEmail(db, u.email))) return null;\n return u.email ? { userId: u.userId, email: u.email } : { userId: u.userId };\n },\n sender: crmSender(env),\n from: env.EMAIL_FROM,\n envName: env.ODLA_ENV,\n baseUrl: url.origin,\n basePath: crmBase,\n });\n const res = await routes(req);\n if (res) return res;\n return json({ error: \"not found\" }, 404);\n }\n\n // chapter mode adds the public member/join/Stripe/booking routes here.\n\n // Everything else is the static site.\n return env.ASSETS.fetch(req);\n },\n };\n\n return handler;\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@odla-ai/chapter",
3
- "version": "0.0.1",
4
- "description": "A foundation for membership sites — one config (defineChapter) stands up a full member site (join, Stripe membership, Google booking, member area, admin, CRM) or an admin-only hub, on odla-db + Clerk + @odla-ai/crm + calendar + email, with a themeable web-component chrome engine.",
3
+ "version": "0.0.2",
4
+ "description": "A foundation for membership sites — one config (defineChapter) stands up a full member site (join, Stripe membership, Google booking, member area, admin, CRM) or an admin-only hub, on odla-db + Clerk + @odla-ai/crm + calendar + email.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://odla.ai/docs/packages/chapter",
7
7
  "repository": {
@@ -65,12 +65,9 @@
65
65
  "jose": "^6.2.3"
66
66
  },
67
67
  "peerDependencies": {
68
- "@odla-ai/auth-clerk": ">=0.2.0 <0.3.0",
69
- "@odla-ai/calendar": ">=0.2.0 <0.3.0",
70
- "@odla-ai/db": ">=0.6.0 <0.7.0",
71
- "@odla-ai/email": ">=0.3.0 <0.4.0",
72
- "@odla-ai/o11y": ">=2.0.0 <3.0.0",
73
- "@odla-ai/ui": ">=0.7.0 <0.9.0",
68
+ "@odla-ai/auth-clerk": ">=0.2.0 <1.0.0",
69
+ "@odla-ai/db": ">=0.6.0 <1.0.0",
70
+ "@odla-ai/ui": ">=0.7.0 <1.0.0",
74
71
  "react": ">=18"
75
72
  },
76
73
  "peerDependenciesMeta": {
@@ -80,15 +77,6 @@
80
77
  "@odla-ai/db": {
81
78
  "optional": true
82
79
  },
83
- "@odla-ai/calendar": {
84
- "optional": true
85
- },
86
- "@odla-ai/email": {
87
- "optional": true
88
- },
89
- "@odla-ai/o11y": {
90
- "optional": true
91
- },
92
80
  "@odla-ai/ui": {
93
81
  "optional": true
94
82
  },
@@ -98,10 +86,7 @@
98
86
  },
99
87
  "devDependencies": {
100
88
  "@odla-ai/auth-clerk": "*",
101
- "@odla-ai/calendar": "*",
102
89
  "@odla-ai/db": "*",
103
- "@odla-ai/email": "*",
104
- "@odla-ai/o11y": "*",
105
90
  "@odla-ai/ui": "*",
106
91
  "@types/node": "^26.1.0",
107
92
  "@types/react": "^19.2.7",