@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 +17 -10
- package/dist/worker/index.cjs +1 -2
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +11 -4
- package/dist/worker/index.d.ts +11 -4
- package/dist/worker/index.js +1 -2
- package/dist/worker/index.js.map +1 -1
- package/package.json +5 -20
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
|
|
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`
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
- **
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
package/dist/worker/index.cjs
CHANGED
|
@@ -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
|
|
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":[]}
|
package/dist/worker/index.d.cts
CHANGED
|
@@ -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
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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):
|
|
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 };
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -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
|
|
172
|
-
*
|
|
173
|
-
*
|
|
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):
|
|
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 };
|
package/dist/worker/index.js
CHANGED
|
@@ -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
|
|
95
|
+
return handler;
|
|
97
96
|
}
|
|
98
97
|
export {
|
|
99
98
|
chapterWorker
|
package/dist/worker/index.js.map
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|
|
69
|
-
"@odla-ai/
|
|
70
|
-
"@odla-ai/
|
|
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",
|