@odla-ai/chapter 0.0.1
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 +72 -0
- package/dist/index.cjs +368 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +198 -0
- package/dist/index.d.ts +198 -0
- package/dist/index.js +345 -0
- package/dist/index.js.map +1 -0
- package/dist/ui/index.d.ts +48 -0
- package/dist/ui/index.js +217 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/worker/index.cjs +122 -0
- package/dist/worker/index.cjs.map +1 -0
- package/dist/worker/index.d.cts +177 -0
- package/dist/worker/index.d.ts +177 -0
- package/dist/worker/index.js +101 -0
- package/dist/worker/index.js.map +1 -0
- package/package.json +115 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/ui/admin.tsx","../../src/ui/chrome.tsx"],"sourcesContent":["// ChapterAdmin — the whole admin console for a chapter/hub, owned by the package\n// so no site re-wires (or re-bugs) it: the Clerk publishable-key fetch\n// (/api/config), the themed sign-in with the post-sign-in redirect DERIVED from\n// basePath (so it never bounces to \"/\"), the odla-db admins-allowlist check\n// (/api/me), and a TopBar shell (chrome.tsx) that renders the caller's sections.\nimport { useEffect, useMemo, useState } from \"react\";\nimport { ClerkGate, SignedIn, SignedOut, SignIn, useClerkAuth, clerkAppearanceFromTokens } from \"@odla-ai/auth-clerk\";\nimport { CrmClient } from \"@odla-ai/crm\";\nimport { S, Gate, AdminShell } from \"./chrome.js\";\nimport type { AdminSection, Brand } from \"./chrome.js\";\n\nexport type { AdminSection, AdminSectionContext } from \"./chrome.js\";\n\n/** Props for {@link ChapterAdmin}. */\nexport interface ChapterAdminProps {\n /** The admin sections to render (nav label + body). */\n sections: AdminSection[];\n /** Admin mount path. The sign-in redirect + section routing derive from it,\n * so the \"bounce to home after sign-in\" bug is impossible. Default \"/admin\". */\n basePath?: string;\n /** Wordmark shown in the gate + top bar. */\n brand?: Brand;\n /** CRM route mount. Default \"/api/crm\". */\n crmBasePath?: string;\n /** Origin for /api/config and /api/me. Default same origin. */\n apiBase?: string;\n}\n\n// After sign-in: build a token-bound CrmClient, confirm the account is an\n// allowlisted admin (/api/me), then render the shell.\nfunction Authed(props: {\n sections: AdminSection[];\n basePath: string;\n brand: Brand;\n crmBasePath: string;\n apiBase: string;\n}) {\n const { sections, basePath, brand, crmBasePath, apiBase } = props;\n const { getToken, signOut } = useClerkAuth();\n const client = useMemo(\n () =>\n new CrmClient({\n basePath: crmBasePath,\n headers: async (): Promise<Record<string, string>> => {\n const t = await getToken();\n return t ? { authorization: `Bearer ${t}` } : {};\n },\n }),\n [getToken, crmBasePath],\n );\n const [state, setState] = useState<{ status: \"checking\" | \"ok\" | \"denied\"; email?: string | null }>({\n status: \"checking\",\n });\n\n useEffect(() => {\n let live = true;\n void (async () => {\n try {\n const t = await getToken();\n const res = await fetch(`${apiBase}/api/me`, { headers: t ? { authorization: `Bearer ${t}` } : {} });\n const body = (await res.json().catch(() => ({}))) as { authorized?: boolean; email?: string | null };\n if (live) setState({ status: body.authorized ? \"ok\" : \"denied\", email: body.email ?? null });\n } catch {\n if (live) setState({ status: \"denied\" });\n }\n })();\n return () => {\n live = false;\n };\n }, [getToken, apiBase]);\n\n if (state.status === \"checking\") {\n return (\n <Gate brand={brand}>\n <p style={S.muted}>Checking access…</p>\n </Gate>\n );\n }\n if (state.status === \"denied\") {\n return (\n <Gate brand={brand}>\n <div className=\"card\" style={S.card}>\n <h2 style={{ marginTop: 0 }}>Not authorized</h2>\n <p style={S.muted}>\n {state.email ? `${state.email} isn't` : \"This account isn't\"} on the admin list. Ask an existing admin to add\n you in odla Studio.\n </p>\n <button className=\"btn secondary\" onClick={() => signOut()} style={{ marginTop: 12 }}>\n Sign out\n </button>\n </div>\n </Gate>\n );\n }\n return (\n <AdminShell\n sections={sections}\n basePath={basePath}\n brand={brand}\n client={client}\n getToken={getToken}\n signOut={signOut}\n email={state.email ?? null}\n />\n );\n}\n\n/**\n * The whole admin console for a chapter/hub: fetches the Clerk publishable key\n * (/api/config), gates on sign-in with the post-sign-in redirect derived from\n * `basePath`, checks the odla-db admins allowlist (/api/me), and renders a\n * TopBar shell over the caller's `sections`. The gate/shell/redirect are owned\n * here so no site re-wires (or re-bugs) them.\n */\nexport function ChapterAdmin(props: ChapterAdminProps) {\n const sections = props.sections;\n const basePath = props.basePath ?? \"/admin\";\n const brand = props.brand ?? { name: \"Admin\" };\n const crmBasePath = props.crmBasePath ?? \"/api/crm\";\n const apiBase = props.apiBase ?? \"\";\n\n const [pk, setPk] = useState<string | null | undefined>(undefined);\n useEffect(() => {\n let live = true;\n void (async () => {\n try {\n const res = await fetch(`${apiBase}/api/config`);\n const body = (await res.json()) as { clerkPublishableKey?: string | null };\n if (live) setPk(body.clerkPublishableKey ?? null);\n } catch {\n if (live) setPk(null);\n }\n })();\n return () => {\n live = false;\n };\n }, [apiBase]);\n\n if (pk === undefined) {\n return (\n <Gate brand={brand}>\n <p style={S.muted}>Loading…</p>\n </Gate>\n );\n }\n if (!pk) {\n return (\n <Gate brand={brand}>\n <div className=\"card\" style={S.card}>\n <h2 style={{ marginTop: 0 }}>Sign-in not configured</h2>\n <p style={S.muted}>No Clerk publishable key is set for this environment yet.</p>\n </div>\n </Gate>\n );\n }\n return (\n <ClerkGate publishableKey={pk} appearance={clerkAppearanceFromTokens()} afterSignOutUrl=\"/\">\n <SignedOut>\n <Gate brand={brand} tagline=\"Admin sign-in — invite only\">\n <SignIn routing=\"hash\" forceRedirectUrl={basePath} signUpForceRedirectUrl={basePath} />\n </Gate>\n </SignedOut>\n <SignedIn>\n <Authed sections={sections} basePath={basePath} brand={brand} crmBasePath={crmBasePath} apiBase={apiBase} />\n </SignedIn>\n </ClerkGate>\n );\n}\n","// Shared chrome for ChapterAdmin: the section contract, the centered gate\n// screen, the signed-in TopBar shell, and the inline style tokens. Split out of\n// admin.tsx to stay under the repo's per-file LOC cap. Uses only @odla-ai/ui\n// classes + --ui-* tokens, so a consumer needs nothing beyond the odla-ui sheet.\nimport { useCallback, useEffect, useState } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { TopBar, TopBarLink } from \"@odla-ai/ui/components\";\nimport type { CrmClient } from \"@odla-ai/crm\";\n\n/** What each admin section receives. `client` is a CrmClient bound to the\n * signed-in admin's bearer token; `navigate` switches sections. */\nexport interface AdminSectionContext {\n client: CrmClient;\n getToken: () => Promise<string | null>;\n /** Switch to another section by id (e.g. Overview \"jump in\" buttons). */\n navigate: (sectionId: string) => void;\n}\n\n/** One admin-console section: a nav label and a body renderer. */\nexport interface AdminSection {\n id: string;\n label: string;\n render: (ctx: AdminSectionContext) => ReactNode;\n}\n\n/** Wordmark shown in the gate and top bar. */\nexport type Brand = { name: string; badge?: string };\n\n/** The badge glyph: the explicit `badge`, else the first 3 letters of `name`. */\nexport const badgeText = (brand: Brand): string => brand.badge ?? brand.name.slice(0, 3).toUpperCase();\n\n/** Inline style tokens (keyed to --ui-* custom properties). */\nexport const S: Record<string, CSSProperties> = {\n gate: {\n minHeight: \"100vh\",\n display: \"grid\",\n placeItems: \"center\",\n padding: 24,\n background:\n \"radial-gradient(900px 480px at 50% -8%, var(--ui-accent-soft), transparent 70%),\" +\n \" radial-gradient(700px 500px at 110% 10%, var(--ui-good-soft), transparent 60%)\",\n },\n gateInner: { width: \"100%\", maxWidth: 400, display: \"flex\", flexDirection: \"column\", alignItems: \"center\" },\n brandBox: { textAlign: \"center\", marginBottom: 22 },\n badge: {\n width: 52,\n height: 52,\n margin: \"0 auto 14px\",\n display: \"grid\",\n placeItems: \"center\",\n fontSize: 15,\n fontWeight: 700,\n color: \"var(--ui-on-accent)\",\n background: \"linear-gradient(135deg, var(--ui-accent), var(--ui-accent-strong))\",\n borderRadius: 14,\n boxShadow: \"0 10px 28px var(--ui-accent-soft)\",\n },\n badgeSm: {\n width: 26,\n height: 26,\n display: \"grid\",\n placeItems: \"center\",\n fontSize: 10,\n fontWeight: 700,\n color: \"var(--ui-on-accent)\",\n background: \"linear-gradient(135deg, var(--ui-accent), var(--ui-accent-strong))\",\n borderRadius: 7,\n marginRight: 9,\n },\n h1: { margin: 0, fontSize: 28, letterSpacing: \"-0.02em\", fontWeight: 700 },\n muted: { color: \"var(--ui-text-muted)\" },\n role: {\n fontFamily: \"var(--ui-font-mono)\",\n fontSize: 11,\n textTransform: \"uppercase\",\n letterSpacing: \"0.04em\",\n padding: \"2px 8px\",\n borderRadius: 999,\n background: \"var(--ui-accent-soft)\",\n border: \"1px solid var(--ui-accent)\",\n color: \"var(--ui-accent-strong)\",\n },\n whoami: { display: \"flex\", alignItems: \"center\", gap: 8, fontSize: 12 },\n // Sections own their own width/padding (e.g. a .wrap container).\n main: { minHeight: \"calc(100vh - 61px)\" },\n card: { width: \"100%\", textAlign: \"center\" },\n};\n\n/** Centered gate screen used for loading / sign-in / denied / not-configured. */\nexport function Gate(props: { brand: Brand; tagline?: string; children: ReactNode }) {\n const { brand, tagline, children } = props;\n return (\n <div style={S.gate}>\n <div style={S.gateInner}>\n <div style={S.brandBox}>\n <div style={S.badge}>{badgeText(brand)}</div>\n <h1 style={S.h1}>{brand.name}</h1>\n {tagline ? <p style={{ ...S.muted, margin: \"8px 0 0\", fontSize: 14 }}>{tagline}</p> : null}\n </div>\n {children}\n </div>\n </div>\n );\n}\n\n/** The signed-in shell: a TopBar of the caller's sections + the active body. */\nexport function AdminShell(props: {\n sections: AdminSection[];\n basePath: string;\n brand: Brand;\n client: CrmClient;\n getToken: () => Promise<string | null>;\n signOut: () => void;\n email: string | null;\n}) {\n const { sections, basePath, brand, client, getToken, signOut, email } = props;\n\n const sectionFromPath = useCallback(() => {\n const fallback = sections[0]?.id ?? \"\";\n if (typeof window === \"undefined\") return fallback;\n const rest = window.location.pathname.slice(basePath.length).replace(/^\\//, \"\");\n const id = rest.split(\"/\")[0] ?? \"\";\n return sections.some((s) => s.id === id) ? id : fallback;\n }, [sections, basePath]);\n\n const [section, setSection] = useState(sectionFromPath);\n\n const go = useCallback(\n (id: string) => {\n window.history.pushState(null, \"\", `${basePath}/${id}`);\n setSection(id);\n },\n [basePath],\n );\n\n useEffect(() => {\n const onPop = () => setSection(sectionFromPath());\n window.addEventListener(\"popstate\", onPop);\n return () => window.removeEventListener(\"popstate\", onPop);\n }, [sectionFromPath]);\n\n const active = sections.find((s) => s.id === section) ?? sections[0];\n\n return (\n <>\n <TopBar>\n <a className=\"topbar-brand\" href=\"/\" style={{ display: \"inline-flex\", alignItems: \"center\" }}>\n <span style={S.badgeSm}>{badgeText(brand)}</span>\n {brand.name}\n </a>\n <nav className=\"topbar-nav\" aria-label=\"Admin navigation\">\n {sections.map((s) => (\n <TopBarLink key={s.id} active={section === s.id} onClick={() => go(s.id)}>\n {s.label}\n </TopBarLink>\n ))}\n </nav>\n <div className=\"topbar-spacer\" />\n <div className=\"topbar-actions\">\n <span style={S.whoami}>\n <span style={S.role}>admin</span>\n {email ? <span style={S.muted}>{email}</span> : null}\n </span>\n <button className=\"btn secondary mini\" onClick={() => signOut()}>\n Sign out\n </button>\n </div>\n </TopBar>\n <main className=\"shell-main\" style={S.main}>\n {active ? active.render({ client, getToken, navigate: go }) : null}\n </main>\n </>\n );\n}\n"],"mappings":";AAKA,SAAS,aAAAA,YAAW,SAAS,YAAAC,iBAAgB;AAC7C,SAAS,WAAW,UAAU,WAAW,QAAQ,cAAc,iCAAiC;AAChG,SAAS,iBAAiB;;;ACH1B,SAAS,aAAa,WAAW,gBAAgB;AAEjD,SAAS,QAAQ,kBAAkB;AAwF3B,SAkDJ,UAjDM,KADF;AAjED,IAAM,YAAY,CAAC,UAAyB,MAAM,SAAS,MAAM,KAAK,MAAM,GAAG,CAAC,EAAE,YAAY;AAG9F,IAAM,IAAmC;AAAA,EAC9C,MAAM;AAAA,IACJ,WAAW;AAAA,IACX,SAAS;AAAA,IACT,YAAY;AAAA,IACZ,SAAS;AAAA,IACT,YACE;AAAA,EAEJ;AAAA,EACA,WAAW,EAAE,OAAO,QAAQ,UAAU,KAAK,SAAS,QAAQ,eAAe,UAAU,YAAY,SAAS;AAAA,EAC1G,UAAU,EAAE,WAAW,UAAU,cAAc,GAAG;AAAA,EAClD,OAAO;AAAA,IACL,OAAO;AAAA,IACP,QAAQ;AAAA,IACR,QAAQ;AAAA,IACR,SAAS;AAAA,IACT,YAAY;AAAA,IACZ,UAAU;AAAA,IACV,YAAY;AAAA,IACZ,OAAO;AAAA,IACP,YAAY;AAAA,IACZ,cAAc;AAAA,IACd,WAAW;AAAA,EACb;AAAA,EACA,SAAS;AAAA,IACP,OAAO;AAAA,IACP,QAAQ;AAAA,IACR,SAAS;AAAA,IACT,YAAY;AAAA,IACZ,UAAU;AAAA,IACV,YAAY;AAAA,IACZ,OAAO;AAAA,IACP,YAAY;AAAA,IACZ,cAAc;AAAA,IACd,aAAa;AAAA,EACf;AAAA,EACA,IAAI,EAAE,QAAQ,GAAG,UAAU,IAAI,eAAe,WAAW,YAAY,IAAI;AAAA,EACzE,OAAO,EAAE,OAAO,uBAAuB;AAAA,EACvC,MAAM;AAAA,IACJ,YAAY;AAAA,IACZ,UAAU;AAAA,IACV,eAAe;AAAA,IACf,eAAe;AAAA,IACf,SAAS;AAAA,IACT,cAAc;AAAA,IACd,YAAY;AAAA,IACZ,QAAQ;AAAA,IACR,OAAO;AAAA,EACT;AAAA,EACA,QAAQ,EAAE,SAAS,QAAQ,YAAY,UAAU,KAAK,GAAG,UAAU,GAAG;AAAA;AAAA,EAEtE,MAAM,EAAE,WAAW,qBAAqB;AAAA,EACxC,MAAM,EAAE,OAAO,QAAQ,WAAW,SAAS;AAC7C;AAGO,SAAS,KAAK,OAAgE;AACnF,QAAM,EAAE,OAAO,SAAS,SAAS,IAAI;AACrC,SACE,oBAAC,SAAI,OAAO,EAAE,MACZ,+BAAC,SAAI,OAAO,EAAE,WACZ;AAAA,yBAAC,SAAI,OAAO,EAAE,UACZ;AAAA,0BAAC,SAAI,OAAO,EAAE,OAAQ,oBAAU,KAAK,GAAE;AAAA,MACvC,oBAAC,QAAG,OAAO,EAAE,IAAK,gBAAM,MAAK;AAAA,MAC5B,UAAU,oBAAC,OAAE,OAAO,EAAE,GAAG,EAAE,OAAO,QAAQ,WAAW,UAAU,GAAG,GAAI,mBAAQ,IAAO;AAAA,OACxF;AAAA,IACC;AAAA,KACH,GACF;AAEJ;AAGO,SAAS,WAAW,OAQxB;AACD,QAAM,EAAE,UAAU,UAAU,OAAO,QAAQ,UAAU,SAAS,MAAM,IAAI;AAExE,QAAM,kBAAkB,YAAY,MAAM;AACxC,UAAM,WAAW,SAAS,CAAC,GAAG,MAAM;AACpC,QAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,UAAM,OAAO,OAAO,SAAS,SAAS,MAAM,SAAS,MAAM,EAAE,QAAQ,OAAO,EAAE;AAC9E,UAAM,KAAK,KAAK,MAAM,GAAG,EAAE,CAAC,KAAK;AACjC,WAAO,SAAS,KAAK,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,KAAK;AAAA,EAClD,GAAG,CAAC,UAAU,QAAQ,CAAC;AAEvB,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,eAAe;AAEtD,QAAM,KAAK;AAAA,IACT,CAAC,OAAe;AACd,aAAO,QAAQ,UAAU,MAAM,IAAI,GAAG,QAAQ,IAAI,EAAE,EAAE;AACtD,iBAAW,EAAE;AAAA,IACf;AAAA,IACA,CAAC,QAAQ;AAAA,EACX;AAEA,YAAU,MAAM;AACd,UAAM,QAAQ,MAAM,WAAW,gBAAgB,CAAC;AAChD,WAAO,iBAAiB,YAAY,KAAK;AACzC,WAAO,MAAM,OAAO,oBAAoB,YAAY,KAAK;AAAA,EAC3D,GAAG,CAAC,eAAe,CAAC;AAEpB,QAAM,SAAS,SAAS,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO,KAAK,SAAS,CAAC;AAEnE,SACE,iCACE;AAAA,yBAAC,UACC;AAAA,2BAAC,OAAE,WAAU,gBAAe,MAAK,KAAI,OAAO,EAAE,SAAS,eAAe,YAAY,SAAS,GACzF;AAAA,4BAAC,UAAK,OAAO,EAAE,SAAU,oBAAU,KAAK,GAAE;AAAA,QACzC,MAAM;AAAA,SACT;AAAA,MACA,oBAAC,SAAI,WAAU,cAAa,cAAW,oBACpC,mBAAS,IAAI,CAAC,MACb,oBAAC,cAAsB,QAAQ,YAAY,EAAE,IAAI,SAAS,MAAM,GAAG,EAAE,EAAE,GACpE,YAAE,SADY,EAAE,EAEnB,CACD,GACH;AAAA,MACA,oBAAC,SAAI,WAAU,iBAAgB;AAAA,MAC/B,qBAAC,SAAI,WAAU,kBACb;AAAA,6BAAC,UAAK,OAAO,EAAE,QACb;AAAA,8BAAC,UAAK,OAAO,EAAE,MAAM,mBAAK;AAAA,UACzB,QAAQ,oBAAC,UAAK,OAAO,EAAE,OAAQ,iBAAM,IAAU;AAAA,WAClD;AAAA,QACA,oBAAC,YAAO,WAAU,sBAAqB,SAAS,MAAM,QAAQ,GAAG,sBAEjE;AAAA,SACF;AAAA,OACF;AAAA,IACA,oBAAC,UAAK,WAAU,cAAa,OAAO,EAAE,MACnC,mBAAS,OAAO,OAAO,EAAE,QAAQ,UAAU,UAAU,GAAG,CAAC,IAAI,MAChE;AAAA,KACF;AAEJ;;;ADnGQ,gBAAAC,MASE,QAAAC,aATF;AA5CR,SAAS,OAAO,OAMb;AACD,QAAM,EAAE,UAAU,UAAU,OAAO,aAAa,QAAQ,IAAI;AAC5D,QAAM,EAAE,UAAU,QAAQ,IAAI,aAAa;AAC3C,QAAM,SAAS;AAAA,IACb,MACE,IAAI,UAAU;AAAA,MACZ,UAAU;AAAA,MACV,SAAS,YAA6C;AACpD,cAAM,IAAI,MAAM,SAAS;AACzB,eAAO,IAAI,EAAE,eAAe,UAAU,CAAC,GAAG,IAAI,CAAC;AAAA,MACjD;AAAA,IACF,CAAC;AAAA,IACH,CAAC,UAAU,WAAW;AAAA,EACxB;AACA,QAAM,CAAC,OAAO,QAAQ,IAAIC,UAA0E;AAAA,IAClG,QAAQ;AAAA,EACV,CAAC;AAED,EAAAC,WAAU,MAAM;AACd,QAAI,OAAO;AACX,UAAM,YAAY;AAChB,UAAI;AACF,cAAM,IAAI,MAAM,SAAS;AACzB,cAAM,MAAM,MAAM,MAAM,GAAG,OAAO,WAAW,EAAE,SAAS,IAAI,EAAE,eAAe,UAAU,CAAC,GAAG,IAAI,CAAC,EAAE,CAAC;AACnG,cAAM,OAAQ,MAAM,IAAI,KAAK,EAAE,MAAM,OAAO,CAAC,EAAE;AAC/C,YAAI,KAAM,UAAS,EAAE,QAAQ,KAAK,aAAa,OAAO,UAAU,OAAO,KAAK,SAAS,KAAK,CAAC;AAAA,MAC7F,QAAQ;AACN,YAAI,KAAM,UAAS,EAAE,QAAQ,SAAS,CAAC;AAAA,MACzC;AAAA,IACF,GAAG;AACH,WAAO,MAAM;AACX,aAAO;AAAA,IACT;AAAA,EACF,GAAG,CAAC,UAAU,OAAO,CAAC;AAEtB,MAAI,MAAM,WAAW,YAAY;AAC/B,WACE,gBAAAH,KAAC,QAAK,OACJ,0BAAAA,KAAC,OAAE,OAAO,EAAE,OAAO,mCAAgB,GACrC;AAAA,EAEJ;AACA,MAAI,MAAM,WAAW,UAAU;AAC7B,WACE,gBAAAA,KAAC,QAAK,OACJ,0BAAAC,MAAC,SAAI,WAAU,QAAO,OAAO,EAAE,MAC7B;AAAA,sBAAAD,KAAC,QAAG,OAAO,EAAE,WAAW,EAAE,GAAG,4BAAc;AAAA,MAC3C,gBAAAC,MAAC,OAAE,OAAO,EAAE,OACT;AAAA,cAAM,QAAQ,GAAG,MAAM,KAAK,WAAW;AAAA,QAAqB;AAAA,SAE/D;AAAA,MACA,gBAAAD,KAAC,YAAO,WAAU,iBAAgB,SAAS,MAAM,QAAQ,GAAG,OAAO,EAAE,WAAW,GAAG,GAAG,sBAEtF;AAAA,OACF,GACF;AAAA,EAEJ;AACA,SACE,gBAAAA;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,OAAO,MAAM,SAAS;AAAA;AAAA,EACxB;AAEJ;AASO,SAAS,aAAa,OAA0B;AACrD,QAAM,WAAW,MAAM;AACvB,QAAM,WAAW,MAAM,YAAY;AACnC,QAAM,QAAQ,MAAM,SAAS,EAAE,MAAM,QAAQ;AAC7C,QAAM,cAAc,MAAM,eAAe;AACzC,QAAM,UAAU,MAAM,WAAW;AAEjC,QAAM,CAAC,IAAI,KAAK,IAAIE,UAAoC,MAAS;AACjE,EAAAC,WAAU,MAAM;AACd,QAAI,OAAO;AACX,UAAM,YAAY;AAChB,UAAI;AACF,cAAM,MAAM,MAAM,MAAM,GAAG,OAAO,aAAa;AAC/C,cAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,YAAI,KAAM,OAAM,KAAK,uBAAuB,IAAI;AAAA,MAClD,QAAQ;AACN,YAAI,KAAM,OAAM,IAAI;AAAA,MACtB;AAAA,IACF,GAAG;AACH,WAAO,MAAM;AACX,aAAO;AAAA,IACT;AAAA,EACF,GAAG,CAAC,OAAO,CAAC;AAEZ,MAAI,OAAO,QAAW;AACpB,WACE,gBAAAH,KAAC,QAAK,OACJ,0BAAAA,KAAC,OAAE,OAAO,EAAE,OAAO,2BAAQ,GAC7B;AAAA,EAEJ;AACA,MAAI,CAAC,IAAI;AACP,WACE,gBAAAA,KAAC,QAAK,OACJ,0BAAAC,MAAC,SAAI,WAAU,QAAO,OAAO,EAAE,MAC7B;AAAA,sBAAAD,KAAC,QAAG,OAAO,EAAE,WAAW,EAAE,GAAG,oCAAsB;AAAA,MACnD,gBAAAA,KAAC,OAAE,OAAO,EAAE,OAAO,uEAAyD;AAAA,OAC9E,GACF;AAAA,EAEJ;AACA,SACE,gBAAAC,MAAC,aAAU,gBAAgB,IAAI,YAAY,0BAA0B,GAAG,iBAAgB,KACtF;AAAA,oBAAAD,KAAC,aACC,0BAAAA,KAAC,QAAK,OAAc,SAAQ,oCAC1B,0BAAAA,KAAC,UAAO,SAAQ,QAAO,kBAAkB,UAAU,wBAAwB,UAAU,GACvF,GACF;AAAA,IACA,gBAAAA,KAAC,YACC,0BAAAA,KAAC,UAAO,UAAoB,UAAoB,OAAc,aAA0B,SAAkB,GAC5G;AAAA,KACF;AAEJ;","names":["useEffect","useState","jsx","jsxs","useState","useEffect"]}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/worker.ts
|
|
21
|
+
var worker_exports = {};
|
|
22
|
+
__export(worker_exports, {
|
|
23
|
+
chapterWorker: () => chapterWorker
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(worker_exports);
|
|
26
|
+
var import_db = require("@odla-ai/db");
|
|
27
|
+
var import_crm = require("@odla-ai/crm");
|
|
28
|
+
var import_o11y = require("@odla-ai/o11y");
|
|
29
|
+
var import_jose = require("jose");
|
|
30
|
+
var json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
31
|
+
function chapterWorker(options) {
|
|
32
|
+
const { chapter } = options;
|
|
33
|
+
const crmBase = options.crmBasePath ?? "/api/crm";
|
|
34
|
+
let publicConfigCache = null;
|
|
35
|
+
const jwksByIssuer = /* @__PURE__ */ new Map();
|
|
36
|
+
async function getPublicConfig(env) {
|
|
37
|
+
if (publicConfigCache && Date.now() - publicConfigCache.at < 5 * 6e4) return publicConfigCache.value;
|
|
38
|
+
const res = await fetch(
|
|
39
|
+
`${env.ODLA_PLATFORM}/registry/apps/${env.ODLA_APP_ID}/public-config?env=${env.ODLA_ENV}`
|
|
40
|
+
);
|
|
41
|
+
if (!res.ok) throw new Error(`public-config fetch failed: ${res.status}`);
|
|
42
|
+
const value = await res.json();
|
|
43
|
+
publicConfigCache = { value, at: Date.now() };
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
async function verifyUser(req, env) {
|
|
47
|
+
const header = req.headers.get("authorization") ?? "";
|
|
48
|
+
if (!header.startsWith("Bearer ")) return null;
|
|
49
|
+
const token = header.slice(7);
|
|
50
|
+
const { issuer } = await getPublicConfig(env);
|
|
51
|
+
if (!issuer) return null;
|
|
52
|
+
let jwks = jwksByIssuer.get(issuer);
|
|
53
|
+
if (!jwks) {
|
|
54
|
+
jwks = (0, import_jose.createRemoteJWKSet)(new URL(`${issuer}/.well-known/jwks.json`));
|
|
55
|
+
jwksByIssuer.set(issuer, jwks);
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, jwks, { issuer });
|
|
59
|
+
if (!payload.sub) return null;
|
|
60
|
+
return { userId: payload.sub, email: typeof payload.email === "string" ? payload.email : void 0 };
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function makeDb(env) {
|
|
66
|
+
return (0, import_db.initAdmin)({ appId: env.ODLA_TENANT, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_ENDPOINT });
|
|
67
|
+
}
|
|
68
|
+
async function isAdminEmail(db, email) {
|
|
69
|
+
if (!email) return false;
|
|
70
|
+
const { admins } = await db.query({ admins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });
|
|
71
|
+
return Array.isArray(admins) && admins.length > 0;
|
|
72
|
+
}
|
|
73
|
+
function crmSender(env) {
|
|
74
|
+
if (!env.SEND_EMAIL || !env.EMAIL_FROM) return void 0;
|
|
75
|
+
const binding = env.SEND_EMAIL;
|
|
76
|
+
return { async send(payload) {
|
|
77
|
+
return binding.send(payload);
|
|
78
|
+
} };
|
|
79
|
+
}
|
|
80
|
+
const handler = {
|
|
81
|
+
async fetch(req, env) {
|
|
82
|
+
const url = new URL(req.url);
|
|
83
|
+
if (url.pathname === "/api/config") {
|
|
84
|
+
try {
|
|
85
|
+
const { clerkPublishableKey } = await getPublicConfig(env);
|
|
86
|
+
return json({ clerkPublishableKey: clerkPublishableKey ?? null, env: env.ODLA_ENV });
|
|
87
|
+
} catch {
|
|
88
|
+
return json({ clerkPublishableKey: null, env: env.ODLA_ENV });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (url.pathname === "/api/me") {
|
|
92
|
+
const u = await verifyUser(req, env);
|
|
93
|
+
if (!u) return json({ authorized: false }, 401);
|
|
94
|
+
const authorized = await isAdminEmail(makeDb(env), u.email);
|
|
95
|
+
return json({ authorized, email: u.email ?? null });
|
|
96
|
+
}
|
|
97
|
+
if (url.pathname === crmBase || url.pathname.startsWith(crmBase + "/")) {
|
|
98
|
+
const db = makeDb(env);
|
|
99
|
+
const routes = (0, import_crm.createCrmRoutes)({
|
|
100
|
+
crm: chapter.crm,
|
|
101
|
+
db,
|
|
102
|
+
authorize: async (r) => {
|
|
103
|
+
const u = await verifyUser(r, env);
|
|
104
|
+
if (!u || !await isAdminEmail(db, u.email)) return null;
|
|
105
|
+
return u.email ? { userId: u.userId, email: u.email } : { userId: u.userId };
|
|
106
|
+
},
|
|
107
|
+
sender: crmSender(env),
|
|
108
|
+
from: env.EMAIL_FROM,
|
|
109
|
+
envName: env.ODLA_ENV,
|
|
110
|
+
baseUrl: url.origin,
|
|
111
|
+
basePath: crmBase
|
|
112
|
+
});
|
|
113
|
+
const res = await routes(req);
|
|
114
|
+
if (res) return res;
|
|
115
|
+
return json({ error: "not found" }, 404);
|
|
116
|
+
}
|
|
117
|
+
return env.ASSETS.fetch(req);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
return (0, import_o11y.withObservability)(handler);
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +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":[]}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { CrmConfig, Crm } from '@odla-ai/crm';
|
|
2
|
+
|
|
3
|
+
/** Which feature profile a site runs. `chapter` is the full public member site
|
|
4
|
+
* (join, Stripe membership, booking, member area, admin, CRM); `hub` is
|
|
5
|
+
* admin-only and CRM-focused (a directory/registry over the same CRM). */
|
|
6
|
+
type ChapterMode = "chapter" | "hub";
|
|
7
|
+
/** The scalar kinds an odla-db attribute can hold. */
|
|
8
|
+
type AttrType = "string" | "number" | "boolean" | "json";
|
|
9
|
+
/** One odla-db attribute: its type and index/uniqueness/optionality flags. */
|
|
10
|
+
interface Attr {
|
|
11
|
+
type: AttrType;
|
|
12
|
+
unique: boolean;
|
|
13
|
+
indexed: boolean;
|
|
14
|
+
optional: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** One odla-db namespace: its attribute map. */
|
|
17
|
+
interface Entity {
|
|
18
|
+
attrs: Record<string, Attr>;
|
|
19
|
+
}
|
|
20
|
+
/** A serialized odla-db schema fragment (namespaces + links). */
|
|
21
|
+
interface DbSchema {
|
|
22
|
+
entities: Record<string, Entity>;
|
|
23
|
+
links: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
/** Per-namespace CEL rule strings (deny-all is `"false"` for each action). */
|
|
26
|
+
interface Rule {
|
|
27
|
+
view: string;
|
|
28
|
+
create: string;
|
|
29
|
+
update: string;
|
|
30
|
+
delete: string;
|
|
31
|
+
}
|
|
32
|
+
/** Namespace → rule set. */
|
|
33
|
+
type DbRules = Record<string, Rule>;
|
|
34
|
+
/** Brand tokens that theme the site: palette, fonts, wordmark, nav, logos. */
|
|
35
|
+
interface ChapterBrand {
|
|
36
|
+
/** Palette overrides written into styles.css :root (e.g. `{ moss: "#2F3E34" }`
|
|
37
|
+
* or `--ui-*` token names). */
|
|
38
|
+
palette?: Record<string, string>;
|
|
39
|
+
fonts?: {
|
|
40
|
+
display?: string;
|
|
41
|
+
body?: string;
|
|
42
|
+
numeral?: string;
|
|
43
|
+
};
|
|
44
|
+
wordmark?: string;
|
|
45
|
+
tagline?: string;
|
|
46
|
+
/** Header/footer nav sections for the web-component chrome: label → entries. */
|
|
47
|
+
nav?: Record<string, ReadonlyArray<{
|
|
48
|
+
label: string;
|
|
49
|
+
href: string;
|
|
50
|
+
}>>;
|
|
51
|
+
logos?: string;
|
|
52
|
+
}
|
|
53
|
+
/** Membership pricing for the group row (chapter mode). */
|
|
54
|
+
interface ChapterPrices {
|
|
55
|
+
standardCents: number;
|
|
56
|
+
foundingDiscountCents?: number;
|
|
57
|
+
/** ISO currency, default "usd". */
|
|
58
|
+
currency?: string;
|
|
59
|
+
/** Billing interval, default "year". */
|
|
60
|
+
interval?: "year" | "month";
|
|
61
|
+
}
|
|
62
|
+
/** Membership policy + compliance copy stored on the group row. */
|
|
63
|
+
interface ChapterPolicy {
|
|
64
|
+
disclaimerText?: string;
|
|
65
|
+
refundPolicyText?: string;
|
|
66
|
+
trustCopy?: string;
|
|
67
|
+
commitmentText?: string;
|
|
68
|
+
normsText?: string;
|
|
69
|
+
}
|
|
70
|
+
/** One owner-editable transactional email template. */
|
|
71
|
+
interface EmailTemplate {
|
|
72
|
+
subject: string;
|
|
73
|
+
text: string;
|
|
74
|
+
enabled?: boolean;
|
|
75
|
+
}
|
|
76
|
+
/** Notification/reply/debug addresses + operational email templates. */
|
|
77
|
+
interface ChapterEmails {
|
|
78
|
+
notificationEmail: string;
|
|
79
|
+
replyTo?: string;
|
|
80
|
+
debugEmail?: string;
|
|
81
|
+
/** Operational lifecycle templates: adminNotification / paymentConfirmation /
|
|
82
|
+
* prepEmail / onboardingInvite, each `{ subject, text, enabled? }`. */
|
|
83
|
+
templates?: Record<string, EmailTemplate>;
|
|
84
|
+
}
|
|
85
|
+
/** Booking rules for the intro-call scheduler (stored as schedulingJson). */
|
|
86
|
+
interface ChapterScheduling {
|
|
87
|
+
slotMinutes?: number;
|
|
88
|
+
days?: readonly number[];
|
|
89
|
+
startHour?: number;
|
|
90
|
+
endHour?: number;
|
|
91
|
+
timezone?: string;
|
|
92
|
+
minNoticeHours?: number;
|
|
93
|
+
windowDays?: number;
|
|
94
|
+
summaryTemplate?: string;
|
|
95
|
+
}
|
|
96
|
+
/** The `defineChapter()` config a site fills in. */
|
|
97
|
+
interface ChapterConfig {
|
|
98
|
+
/** Slug: app id, tenant, group id, worker name. `[a-z0-9-]`. */
|
|
99
|
+
id: string;
|
|
100
|
+
name: string;
|
|
101
|
+
url?: string;
|
|
102
|
+
/** Default `"chapter"`. */
|
|
103
|
+
mode?: ChapterMode;
|
|
104
|
+
/** A `defineCrm()` config or a resolved `Crm`. Omit for the per-mode default. */
|
|
105
|
+
crm?: CrmConfig | Crm;
|
|
106
|
+
brand?: ChapterBrand;
|
|
107
|
+
thesis?: unknown;
|
|
108
|
+
/** Required in `chapter` mode. */
|
|
109
|
+
prices?: ChapterPrices;
|
|
110
|
+
policy?: ChapterPolicy;
|
|
111
|
+
/** `notificationEmail` required in `chapter` mode. */
|
|
112
|
+
emails?: ChapterEmails;
|
|
113
|
+
scheduling?: ChapterScheduling;
|
|
114
|
+
/** odla services (db implied). Default `["db","calendar","o11y"]`. */
|
|
115
|
+
services?: readonly string[];
|
|
116
|
+
}
|
|
117
|
+
/** The resolved engine `defineChapter()` returns. */
|
|
118
|
+
interface Chapter {
|
|
119
|
+
config: ChapterConfig;
|
|
120
|
+
id: string;
|
|
121
|
+
name: string;
|
|
122
|
+
url?: string;
|
|
123
|
+
mode: ChapterMode;
|
|
124
|
+
/** Resolved CRM engine (from `defineCrm`). */
|
|
125
|
+
crm: Crm;
|
|
126
|
+
/** The chapter's own odla-db namespaces (mode-dependent; excludes `crm_*`). */
|
|
127
|
+
schema: DbSchema;
|
|
128
|
+
rules: DbRules;
|
|
129
|
+
services: readonly string[];
|
|
130
|
+
/** The seed `groups` row derived from config (chapter mode), else `null`. */
|
|
131
|
+
groupSeed(): Record<string, unknown> | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface EmailPayload {
|
|
135
|
+
from: string;
|
|
136
|
+
to: string[];
|
|
137
|
+
subject: string;
|
|
138
|
+
text?: string;
|
|
139
|
+
html?: string;
|
|
140
|
+
replyTo?: string;
|
|
141
|
+
headers?: Record<string, string>;
|
|
142
|
+
}
|
|
143
|
+
/** The Worker env a chapter site provides (wrangler vars + the ODLA_API_KEY
|
|
144
|
+
* secret pushed by provision). */
|
|
145
|
+
interface ChapterEnv {
|
|
146
|
+
ASSETS: {
|
|
147
|
+
fetch(req: Request): Promise<Response>;
|
|
148
|
+
};
|
|
149
|
+
ODLA_ENDPOINT: string;
|
|
150
|
+
ODLA_TENANT: string;
|
|
151
|
+
ODLA_PLATFORM: string;
|
|
152
|
+
ODLA_APP_ID: string;
|
|
153
|
+
ODLA_ENV: string;
|
|
154
|
+
ODLA_API_KEY: string;
|
|
155
|
+
SEND_EMAIL?: {
|
|
156
|
+
send(payload: EmailPayload): Promise<{
|
|
157
|
+
messageId: string;
|
|
158
|
+
}>;
|
|
159
|
+
};
|
|
160
|
+
EMAIL_FROM?: string;
|
|
161
|
+
}
|
|
162
|
+
/** Options for {@link chapterWorker}. */
|
|
163
|
+
interface ChapterWorkerOptions {
|
|
164
|
+
chapter: Chapter;
|
|
165
|
+
/** CRM mount point. Default "/api/crm". */
|
|
166
|
+
crmBasePath?: string;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Build the Cloudflare `ExportedHandler` for a chapter/hub site: Clerk-JWT
|
|
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).
|
|
174
|
+
*/
|
|
175
|
+
declare function chapterWorker(options: ChapterWorkerOptions): ExportedHandler<E>;
|
|
176
|
+
|
|
177
|
+
export { type ChapterEnv, type ChapterWorkerOptions, chapterWorker };
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { CrmConfig, Crm } from '@odla-ai/crm';
|
|
2
|
+
|
|
3
|
+
/** Which feature profile a site runs. `chapter` is the full public member site
|
|
4
|
+
* (join, Stripe membership, booking, member area, admin, CRM); `hub` is
|
|
5
|
+
* admin-only and CRM-focused (a directory/registry over the same CRM). */
|
|
6
|
+
type ChapterMode = "chapter" | "hub";
|
|
7
|
+
/** The scalar kinds an odla-db attribute can hold. */
|
|
8
|
+
type AttrType = "string" | "number" | "boolean" | "json";
|
|
9
|
+
/** One odla-db attribute: its type and index/uniqueness/optionality flags. */
|
|
10
|
+
interface Attr {
|
|
11
|
+
type: AttrType;
|
|
12
|
+
unique: boolean;
|
|
13
|
+
indexed: boolean;
|
|
14
|
+
optional: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** One odla-db namespace: its attribute map. */
|
|
17
|
+
interface Entity {
|
|
18
|
+
attrs: Record<string, Attr>;
|
|
19
|
+
}
|
|
20
|
+
/** A serialized odla-db schema fragment (namespaces + links). */
|
|
21
|
+
interface DbSchema {
|
|
22
|
+
entities: Record<string, Entity>;
|
|
23
|
+
links: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
/** Per-namespace CEL rule strings (deny-all is `"false"` for each action). */
|
|
26
|
+
interface Rule {
|
|
27
|
+
view: string;
|
|
28
|
+
create: string;
|
|
29
|
+
update: string;
|
|
30
|
+
delete: string;
|
|
31
|
+
}
|
|
32
|
+
/** Namespace → rule set. */
|
|
33
|
+
type DbRules = Record<string, Rule>;
|
|
34
|
+
/** Brand tokens that theme the site: palette, fonts, wordmark, nav, logos. */
|
|
35
|
+
interface ChapterBrand {
|
|
36
|
+
/** Palette overrides written into styles.css :root (e.g. `{ moss: "#2F3E34" }`
|
|
37
|
+
* or `--ui-*` token names). */
|
|
38
|
+
palette?: Record<string, string>;
|
|
39
|
+
fonts?: {
|
|
40
|
+
display?: string;
|
|
41
|
+
body?: string;
|
|
42
|
+
numeral?: string;
|
|
43
|
+
};
|
|
44
|
+
wordmark?: string;
|
|
45
|
+
tagline?: string;
|
|
46
|
+
/** Header/footer nav sections for the web-component chrome: label → entries. */
|
|
47
|
+
nav?: Record<string, ReadonlyArray<{
|
|
48
|
+
label: string;
|
|
49
|
+
href: string;
|
|
50
|
+
}>>;
|
|
51
|
+
logos?: string;
|
|
52
|
+
}
|
|
53
|
+
/** Membership pricing for the group row (chapter mode). */
|
|
54
|
+
interface ChapterPrices {
|
|
55
|
+
standardCents: number;
|
|
56
|
+
foundingDiscountCents?: number;
|
|
57
|
+
/** ISO currency, default "usd". */
|
|
58
|
+
currency?: string;
|
|
59
|
+
/** Billing interval, default "year". */
|
|
60
|
+
interval?: "year" | "month";
|
|
61
|
+
}
|
|
62
|
+
/** Membership policy + compliance copy stored on the group row. */
|
|
63
|
+
interface ChapterPolicy {
|
|
64
|
+
disclaimerText?: string;
|
|
65
|
+
refundPolicyText?: string;
|
|
66
|
+
trustCopy?: string;
|
|
67
|
+
commitmentText?: string;
|
|
68
|
+
normsText?: string;
|
|
69
|
+
}
|
|
70
|
+
/** One owner-editable transactional email template. */
|
|
71
|
+
interface EmailTemplate {
|
|
72
|
+
subject: string;
|
|
73
|
+
text: string;
|
|
74
|
+
enabled?: boolean;
|
|
75
|
+
}
|
|
76
|
+
/** Notification/reply/debug addresses + operational email templates. */
|
|
77
|
+
interface ChapterEmails {
|
|
78
|
+
notificationEmail: string;
|
|
79
|
+
replyTo?: string;
|
|
80
|
+
debugEmail?: string;
|
|
81
|
+
/** Operational lifecycle templates: adminNotification / paymentConfirmation /
|
|
82
|
+
* prepEmail / onboardingInvite, each `{ subject, text, enabled? }`. */
|
|
83
|
+
templates?: Record<string, EmailTemplate>;
|
|
84
|
+
}
|
|
85
|
+
/** Booking rules for the intro-call scheduler (stored as schedulingJson). */
|
|
86
|
+
interface ChapterScheduling {
|
|
87
|
+
slotMinutes?: number;
|
|
88
|
+
days?: readonly number[];
|
|
89
|
+
startHour?: number;
|
|
90
|
+
endHour?: number;
|
|
91
|
+
timezone?: string;
|
|
92
|
+
minNoticeHours?: number;
|
|
93
|
+
windowDays?: number;
|
|
94
|
+
summaryTemplate?: string;
|
|
95
|
+
}
|
|
96
|
+
/** The `defineChapter()` config a site fills in. */
|
|
97
|
+
interface ChapterConfig {
|
|
98
|
+
/** Slug: app id, tenant, group id, worker name. `[a-z0-9-]`. */
|
|
99
|
+
id: string;
|
|
100
|
+
name: string;
|
|
101
|
+
url?: string;
|
|
102
|
+
/** Default `"chapter"`. */
|
|
103
|
+
mode?: ChapterMode;
|
|
104
|
+
/** A `defineCrm()` config or a resolved `Crm`. Omit for the per-mode default. */
|
|
105
|
+
crm?: CrmConfig | Crm;
|
|
106
|
+
brand?: ChapterBrand;
|
|
107
|
+
thesis?: unknown;
|
|
108
|
+
/** Required in `chapter` mode. */
|
|
109
|
+
prices?: ChapterPrices;
|
|
110
|
+
policy?: ChapterPolicy;
|
|
111
|
+
/** `notificationEmail` required in `chapter` mode. */
|
|
112
|
+
emails?: ChapterEmails;
|
|
113
|
+
scheduling?: ChapterScheduling;
|
|
114
|
+
/** odla services (db implied). Default `["db","calendar","o11y"]`. */
|
|
115
|
+
services?: readonly string[];
|
|
116
|
+
}
|
|
117
|
+
/** The resolved engine `defineChapter()` returns. */
|
|
118
|
+
interface Chapter {
|
|
119
|
+
config: ChapterConfig;
|
|
120
|
+
id: string;
|
|
121
|
+
name: string;
|
|
122
|
+
url?: string;
|
|
123
|
+
mode: ChapterMode;
|
|
124
|
+
/** Resolved CRM engine (from `defineCrm`). */
|
|
125
|
+
crm: Crm;
|
|
126
|
+
/** The chapter's own odla-db namespaces (mode-dependent; excludes `crm_*`). */
|
|
127
|
+
schema: DbSchema;
|
|
128
|
+
rules: DbRules;
|
|
129
|
+
services: readonly string[];
|
|
130
|
+
/** The seed `groups` row derived from config (chapter mode), else `null`. */
|
|
131
|
+
groupSeed(): Record<string, unknown> | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface EmailPayload {
|
|
135
|
+
from: string;
|
|
136
|
+
to: string[];
|
|
137
|
+
subject: string;
|
|
138
|
+
text?: string;
|
|
139
|
+
html?: string;
|
|
140
|
+
replyTo?: string;
|
|
141
|
+
headers?: Record<string, string>;
|
|
142
|
+
}
|
|
143
|
+
/** The Worker env a chapter site provides (wrangler vars + the ODLA_API_KEY
|
|
144
|
+
* secret pushed by provision). */
|
|
145
|
+
interface ChapterEnv {
|
|
146
|
+
ASSETS: {
|
|
147
|
+
fetch(req: Request): Promise<Response>;
|
|
148
|
+
};
|
|
149
|
+
ODLA_ENDPOINT: string;
|
|
150
|
+
ODLA_TENANT: string;
|
|
151
|
+
ODLA_PLATFORM: string;
|
|
152
|
+
ODLA_APP_ID: string;
|
|
153
|
+
ODLA_ENV: string;
|
|
154
|
+
ODLA_API_KEY: string;
|
|
155
|
+
SEND_EMAIL?: {
|
|
156
|
+
send(payload: EmailPayload): Promise<{
|
|
157
|
+
messageId: string;
|
|
158
|
+
}>;
|
|
159
|
+
};
|
|
160
|
+
EMAIL_FROM?: string;
|
|
161
|
+
}
|
|
162
|
+
/** Options for {@link chapterWorker}. */
|
|
163
|
+
interface ChapterWorkerOptions {
|
|
164
|
+
chapter: Chapter;
|
|
165
|
+
/** CRM mount point. Default "/api/crm". */
|
|
166
|
+
crmBasePath?: string;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Build the Cloudflare `ExportedHandler` for a chapter/hub site: Clerk-JWT
|
|
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).
|
|
174
|
+
*/
|
|
175
|
+
declare function chapterWorker(options: ChapterWorkerOptions): ExportedHandler<E>;
|
|
176
|
+
|
|
177
|
+
export { type ChapterEnv, type ChapterWorkerOptions, chapterWorker };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// src/worker.ts
|
|
2
|
+
import { initAdmin } from "@odla-ai/db";
|
|
3
|
+
import { createCrmRoutes } from "@odla-ai/crm";
|
|
4
|
+
import { withObservability } from "@odla-ai/o11y";
|
|
5
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
6
|
+
var json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
7
|
+
function chapterWorker(options) {
|
|
8
|
+
const { chapter } = options;
|
|
9
|
+
const crmBase = options.crmBasePath ?? "/api/crm";
|
|
10
|
+
let publicConfigCache = null;
|
|
11
|
+
const jwksByIssuer = /* @__PURE__ */ new Map();
|
|
12
|
+
async function getPublicConfig(env) {
|
|
13
|
+
if (publicConfigCache && Date.now() - publicConfigCache.at < 5 * 6e4) return publicConfigCache.value;
|
|
14
|
+
const res = await fetch(
|
|
15
|
+
`${env.ODLA_PLATFORM}/registry/apps/${env.ODLA_APP_ID}/public-config?env=${env.ODLA_ENV}`
|
|
16
|
+
);
|
|
17
|
+
if (!res.ok) throw new Error(`public-config fetch failed: ${res.status}`);
|
|
18
|
+
const value = await res.json();
|
|
19
|
+
publicConfigCache = { value, at: Date.now() };
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
async function verifyUser(req, env) {
|
|
23
|
+
const header = req.headers.get("authorization") ?? "";
|
|
24
|
+
if (!header.startsWith("Bearer ")) return null;
|
|
25
|
+
const token = header.slice(7);
|
|
26
|
+
const { issuer } = await getPublicConfig(env);
|
|
27
|
+
if (!issuer) return null;
|
|
28
|
+
let jwks = jwksByIssuer.get(issuer);
|
|
29
|
+
if (!jwks) {
|
|
30
|
+
jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
|
|
31
|
+
jwksByIssuer.set(issuer, jwks);
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const { payload } = await jwtVerify(token, jwks, { issuer });
|
|
35
|
+
if (!payload.sub) return null;
|
|
36
|
+
return { userId: payload.sub, email: typeof payload.email === "string" ? payload.email : void 0 };
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function makeDb(env) {
|
|
42
|
+
return initAdmin({ appId: env.ODLA_TENANT, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_ENDPOINT });
|
|
43
|
+
}
|
|
44
|
+
async function isAdminEmail(db, email) {
|
|
45
|
+
if (!email) return false;
|
|
46
|
+
const { admins } = await db.query({ admins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });
|
|
47
|
+
return Array.isArray(admins) && admins.length > 0;
|
|
48
|
+
}
|
|
49
|
+
function crmSender(env) {
|
|
50
|
+
if (!env.SEND_EMAIL || !env.EMAIL_FROM) return void 0;
|
|
51
|
+
const binding = env.SEND_EMAIL;
|
|
52
|
+
return { async send(payload) {
|
|
53
|
+
return binding.send(payload);
|
|
54
|
+
} };
|
|
55
|
+
}
|
|
56
|
+
const handler = {
|
|
57
|
+
async fetch(req, env) {
|
|
58
|
+
const url = new URL(req.url);
|
|
59
|
+
if (url.pathname === "/api/config") {
|
|
60
|
+
try {
|
|
61
|
+
const { clerkPublishableKey } = await getPublicConfig(env);
|
|
62
|
+
return json({ clerkPublishableKey: clerkPublishableKey ?? null, env: env.ODLA_ENV });
|
|
63
|
+
} catch {
|
|
64
|
+
return json({ clerkPublishableKey: null, env: env.ODLA_ENV });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (url.pathname === "/api/me") {
|
|
68
|
+
const u = await verifyUser(req, env);
|
|
69
|
+
if (!u) return json({ authorized: false }, 401);
|
|
70
|
+
const authorized = await isAdminEmail(makeDb(env), u.email);
|
|
71
|
+
return json({ authorized, email: u.email ?? null });
|
|
72
|
+
}
|
|
73
|
+
if (url.pathname === crmBase || url.pathname.startsWith(crmBase + "/")) {
|
|
74
|
+
const db = makeDb(env);
|
|
75
|
+
const routes = createCrmRoutes({
|
|
76
|
+
crm: chapter.crm,
|
|
77
|
+
db,
|
|
78
|
+
authorize: async (r) => {
|
|
79
|
+
const u = await verifyUser(r, env);
|
|
80
|
+
if (!u || !await isAdminEmail(db, u.email)) return null;
|
|
81
|
+
return u.email ? { userId: u.userId, email: u.email } : { userId: u.userId };
|
|
82
|
+
},
|
|
83
|
+
sender: crmSender(env),
|
|
84
|
+
from: env.EMAIL_FROM,
|
|
85
|
+
envName: env.ODLA_ENV,
|
|
86
|
+
baseUrl: url.origin,
|
|
87
|
+
basePath: crmBase
|
|
88
|
+
});
|
|
89
|
+
const res = await routes(req);
|
|
90
|
+
if (res) return res;
|
|
91
|
+
return json({ error: "not found" }, 404);
|
|
92
|
+
}
|
|
93
|
+
return env.ASSETS.fetch(req);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
return withObservability(handler);
|
|
97
|
+
}
|
|
98
|
+
export {
|
|
99
|
+
chapterWorker
|
|
100
|
+
};
|
|
101
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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":[]}
|