@odla-ai/chapter 0.0.2 → 0.3.0

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.
@@ -24,11 +24,100 @@ __export(worker_exports, {
24
24
  });
25
25
  module.exports = __toCommonJS(worker_exports);
26
26
  var import_db = require("@odla-ai/db");
27
- var import_crm = require("@odla-ai/crm");
27
+ var import_crm2 = require("@odla-ai/crm");
28
28
  var import_jose = require("jose");
29
+
30
+ // src/auth.ts
31
+ function roleFromClaim(payload, auth) {
32
+ const raw = payload[auth.claim];
33
+ return typeof raw === "string" && auth.ladder.includes(raw) ? raw : auth.ladder[0];
34
+ }
35
+ function isAdminRole(role, auth) {
36
+ return role === auth.adminRole;
37
+ }
38
+ async function getVaultSecret(db, name) {
39
+ try {
40
+ const value = await db.secrets.get(name);
41
+ return typeof value === "string" && value !== "" ? value : void 0;
42
+ } catch {
43
+ return void 0;
44
+ }
45
+ }
46
+
47
+ // src/member.ts
48
+ async function submitApplication(db, chapter, fields, opts) {
49
+ const app = chapter.application;
50
+ for (const f of app.required) {
51
+ const v = fields[f];
52
+ if (typeof v !== "string" || v.trim() === "") return { ok: false, error: `${f} is required` };
53
+ }
54
+ for (const f of [...app.required, ...app.optional]) {
55
+ const v = fields[f];
56
+ const cap = app.maxLen[f] ?? app.defaultMaxLen;
57
+ if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
58
+ }
59
+ const id = opts.newId();
60
+ const row = { id, status: chapter.pipeline.initial, createdAt: opts.now };
61
+ for (const f of [...app.required, ...app.optional]) {
62
+ if (typeof fields[f] === "string") row[f] = fields[f].trim();
63
+ }
64
+ if (fields.focus !== void 0) row.focus = fields.focus;
65
+ if (opts.groupId) row.groupId = opts.groupId;
66
+ const { duplicate } = await db.transact(
67
+ [{ t: "update", ns: "applications", id, attrs: row }],
68
+ opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
69
+ );
70
+ return { ok: true, id, duplicate, status: chapter.pipeline.initial };
71
+ }
72
+ function joinConfig(group, paymentsReady) {
73
+ return {
74
+ id: group.id,
75
+ name: group.name,
76
+ standardPriceCents: group.standardPriceCents ?? 0,
77
+ foundingDiscountCents: group.foundingDiscountCents ?? 0,
78
+ disclaimerText: group.disclaimerText ?? "",
79
+ refundPolicyText: group.refundPolicyText ?? "",
80
+ trustCopy: group.trustCopy ?? "",
81
+ commitmentText: group.commitmentText ?? "",
82
+ normsText: group.normsText ?? "",
83
+ paymentsReady
84
+ };
85
+ }
86
+
87
+ // src/network.ts
88
+ var import_crm = require("@odla-ai/crm");
89
+ function sharedPersonInput(person) {
90
+ const email = person.email.toLowerCase();
91
+ const fullName = [person.firstName, person.lastName].filter(Boolean).join(" ").trim();
92
+ const input = { name: person.name ?? fullName ?? email, email };
93
+ if (input.name === "") input.name = email;
94
+ if (person.firstName) input.firstName = person.firstName;
95
+ if (person.lastName) input.lastName = person.lastName;
96
+ if (person.phone) input.phone = person.phone;
97
+ if (person.linkedin) input.linkedin = person.linkedin;
98
+ return input;
99
+ }
100
+ async function projectSharedRecord(deps, person) {
101
+ const email = person.email.toLowerCase();
102
+ const input = sharedPersonInput(person);
103
+ const crmDeps = { crm: deps.crm, db: deps.db, now: deps.now, newId: deps.newId };
104
+ const { crm_record } = await deps.db.query({
105
+ crm_record: { $: { where: { type: "person", primaryEmail: email }, limit: 1 } }
106
+ });
107
+ const existing = crm_record?.[0];
108
+ if (existing && typeof existing.id === "string") {
109
+ await (0, import_crm.updateRecord)(crmDeps, { id: existing.id, input });
110
+ return { recordId: existing.id };
111
+ }
112
+ const created = await (0, import_crm.createRecord)(crmDeps, { type: "person", input, mutationId: `share:${person.hubRecordId}` });
113
+ return { recordId: created.id };
114
+ }
115
+
116
+ // src/worker.ts
29
117
  var json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
30
118
  function chapterWorker(options) {
31
119
  const { chapter } = options;
120
+ const auth = chapter.auth;
32
121
  const crmBase = options.crmBasePath ?? "/api/crm";
33
122
  let publicConfigCache = null;
34
123
  const jwksByIssuer = /* @__PURE__ */ new Map();
@@ -56,7 +145,11 @@ function chapterWorker(options) {
56
145
  try {
57
146
  const { payload } = await (0, import_jose.jwtVerify)(token, jwks, { issuer });
58
147
  if (!payload.sub) return null;
59
- return { userId: payload.sub, email: typeof payload.email === "string" ? payload.email : void 0 };
148
+ return {
149
+ userId: payload.sub,
150
+ email: typeof payload.email === "string" ? payload.email : void 0,
151
+ payload
152
+ };
60
153
  } catch {
61
154
  return null;
62
155
  }
@@ -69,6 +162,19 @@ function chapterWorker(options) {
69
162
  const { admins } = await db.query({ admins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });
70
163
  return Array.isArray(admins) && admins.length > 0;
71
164
  }
165
+ async function isSuperAdminEmail(db, email) {
166
+ if (!auth.superAdmins || !email) return false;
167
+ const { superAdmins } = await db.query({ superAdmins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });
168
+ return Array.isArray(superAdmins) && superAdmins.length > 0;
169
+ }
170
+ async function roleFor(db, u) {
171
+ if (auth.source === "claim") return roleFromClaim(u.payload, auth);
172
+ return await isAdminEmail(db, u.email) ? auth.adminRole : auth.ladder[0];
173
+ }
174
+ async function isAdmin(db, u) {
175
+ if (auth.source === "claim") return isAdminRole(roleFromClaim(u.payload, auth), auth);
176
+ return isAdminEmail(db, u.email);
177
+ }
72
178
  function crmSender(env) {
73
179
  if (!env.SEND_EMAIL || !env.EMAIL_FROM) return void 0;
74
180
  const binding = env.SEND_EMAIL;
@@ -90,17 +196,19 @@ function chapterWorker(options) {
90
196
  if (url.pathname === "/api/me") {
91
197
  const u = await verifyUser(req, env);
92
198
  if (!u) return json({ authorized: false }, 401);
93
- const authorized = await isAdminEmail(makeDb(env), u.email);
94
- return json({ authorized, email: u.email ?? null });
199
+ const db = makeDb(env);
200
+ const role = await roleFor(db, u);
201
+ const superAdmin = await isSuperAdminEmail(db, u.email);
202
+ return json({ authorized: isAdminRole(role, auth), role, superAdmin, email: u.email ?? null });
95
203
  }
96
204
  if (url.pathname === crmBase || url.pathname.startsWith(crmBase + "/")) {
97
205
  const db = makeDb(env);
98
- const routes = (0, import_crm.createCrmRoutes)({
206
+ const routes = (0, import_crm2.createCrmRoutes)({
99
207
  crm: chapter.crm,
100
208
  db,
101
209
  authorize: async (r) => {
102
210
  const u = await verifyUser(r, env);
103
- if (!u || !await isAdminEmail(db, u.email)) return null;
211
+ if (!u || !await isAdmin(db, u)) return null;
104
212
  return u.email ? { userId: u.userId, email: u.email } : { userId: u.userId };
105
213
  },
106
214
  sender: crmSender(env),
@@ -113,6 +221,59 @@ function chapterWorker(options) {
113
221
  if (res) return res;
114
222
  return json({ error: "not found" }, 404);
115
223
  }
224
+ if (req.method === "POST" && url.pathname === "/api/network/shared") {
225
+ const db = makeDb(env);
226
+ const secret = await getVaultSecret(db, "network_share_secret");
227
+ const provided = (req.headers.get("authorization") ?? "").replace(/^Bearer /, "");
228
+ if (!secret || provided.length !== secret.length || provided !== secret) {
229
+ return json({ error: "unauthorized" }, 401);
230
+ }
231
+ let person;
232
+ try {
233
+ person = JSON.parse(await req.text());
234
+ } catch {
235
+ return json({ error: "invalid JSON body" }, 400);
236
+ }
237
+ if (typeof person.email !== "string" || typeof person.hubRecordId !== "string") {
238
+ return json({ error: "email and hubRecordId are required" }, 400);
239
+ }
240
+ const { recordId } = await projectSharedRecord(
241
+ { crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
242
+ person
243
+ );
244
+ return json({ recordId });
245
+ }
246
+ if (chapter.mode === "chapter") {
247
+ if (req.method === "GET" && url.pathname === "/api/join-config") {
248
+ const db = makeDb(env);
249
+ const groupId = url.searchParams.get("group") ?? chapter.id;
250
+ const { groups } = await db.query({ groups: { $: { where: { id: groupId }, limit: 1 } } });
251
+ const group = Array.isArray(groups) ? groups[0] : void 0;
252
+ if (!group) return json({ error: "not found" }, 404);
253
+ const stripeKey = await getVaultSecret(db, "stripe_secret_key");
254
+ const paymentsReady = Boolean(group.stripePublishableKey && group.stripePriceId && stripeKey);
255
+ return json(joinConfig(group, paymentsReady));
256
+ }
257
+ if (req.method === "POST" && url.pathname === "/api/applications") {
258
+ const raw = await req.text();
259
+ if (raw.length > chapter.application.bodyCap) return json({ error: "request body too large" }, 413);
260
+ let parsed;
261
+ try {
262
+ parsed = JSON.parse(raw);
263
+ } catch {
264
+ return json({ error: "invalid JSON body" }, 400);
265
+ }
266
+ const submissionId = typeof parsed.submissionId === "string" ? parsed.submissionId : void 0;
267
+ const result = await submitApplication(makeDb(env), chapter, parsed, {
268
+ submissionId,
269
+ groupId: chapter.id,
270
+ now: Date.now(),
271
+ newId: () => crypto.randomUUID()
272
+ });
273
+ if (!result.ok) return json({ error: result.error }, 400);
274
+ return json({ id: result.id, duplicate: result.duplicate, status: result.status });
275
+ }
276
+ }
116
277
  return env.ASSETS.fetch(req);
117
278
  }
118
279
  };
@@ -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 { 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":[]}
1
+ {"version":3,"sources":["../../src/worker.ts","../../src/auth.ts","../../src/member.ts","../../src/network.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 surface: GET /api/join-config and POST\n// /api/applications (idempotent). Stripe + booking land next.\nimport { initAdmin } from \"@odla-ai/db\";\nimport { createCrmRoutes } from \"@odla-ai/crm\";\nimport { createRemoteJWKSet, jwtVerify } from \"jose\";\nimport { getVaultSecret, isAdminRole, roleFromClaim } from \"./auth\";\nimport { joinConfig, submitApplication } from \"./member\";\nimport { projectSharedRecord } from \"./network\";\nimport type { Chapter, ChapterDb } 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/** A verified session: the Clerk `sub`, optional email, and the raw JWT payload\n * (so the role claim can be read for auth source \"claim\"). */\ninterface Verified {\n userId: string;\n email?: string;\n payload: Record<string, unknown>;\n}\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 auth = chapter.auth;\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<Verified | 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 {\n userId: payload.sub,\n email: typeof payload.email === \"string\" ? payload.email : undefined,\n payload: payload as Record<string, unknown>,\n };\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 `admins` allowlist gate (auth source \"table\"): no route ever writes it, so\n // membership can only be 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 // The read-only `superAdmins` tier — queried, never written (Studio-only).\n async function isSuperAdminEmail(db: Db, email: string | undefined): Promise<boolean> {\n if (!auth.superAdmins || !email) return false;\n const { superAdmins } = await db.query({ superAdmins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });\n return Array.isArray(superAdmins) && superAdmins.length > 0;\n }\n\n // The user's role, resolved per the auth source: a JWT claim (\"claim\") or the\n // `admins` allowlist (\"table\", synthesized to the admin rung or the lowest one).\n async function roleFor(db: Db, u: Verified): Promise<string> {\n if (auth.source === \"claim\") return roleFromClaim(u.payload, auth);\n return (await isAdminEmail(db, u.email)) ? auth.adminRole : (auth.ladder[0] as string);\n }\n\n // Admin authorization — the boolean gate used by /api/me and the CRM surface.\n async function isAdmin(db: Db, u: Verified): Promise<boolean> {\n if (auth.source === \"claim\") return isAdminRole(roleFromClaim(u.payload, auth), auth);\n return isAdminEmail(db, u.email);\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: the signed-in user's role, admin authorization, and super-admin tier.\n if (url.pathname === \"/api/me\") {\n const u = await verifyUser(req, env);\n if (!u) return json({ authorized: false }, 401);\n const db = makeDb(env);\n const role = await roleFor(db, u);\n const superAdmin = await isSuperAdminEmail(db, u.email);\n return json({ authorized: isAdminRole(role, auth), role, superAdmin, 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 isAdmin(db, u))) 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 // Network share (push projection): the hub upserts a curated prospect into\n // this site's crm_record. Gated by a shared secret in the vault, so it works\n // in both modes (hub↔chapter movement, either direction).\n if (req.method === \"POST\" && url.pathname === \"/api/network/shared\") {\n const db = makeDb(env);\n const secret = await getVaultSecret(db as unknown as ChapterDb, \"network_share_secret\");\n const provided = (req.headers.get(\"authorization\") ?? \"\").replace(/^Bearer /, \"\");\n if (!secret || provided.length !== secret.length || provided !== secret) {\n return json({ error: \"unauthorized\" }, 401);\n }\n let person: Record<string, unknown>;\n try {\n person = JSON.parse(await req.text()) as Record<string, unknown>;\n } catch {\n return json({ error: \"invalid JSON body\" }, 400);\n }\n if (typeof person.email !== \"string\" || typeof person.hubRecordId !== \"string\") {\n return json({ error: \"email and hubRecordId are required\" }, 400);\n }\n const { recordId } = await projectSharedRecord(\n { crm: chapter.crm, db: db as unknown as ChapterDb, now: () => Date.now(), newId: () => crypto.randomUUID() },\n person as never,\n );\n return json({ recordId });\n }\n\n // ── chapter-mode public member surface (hub mode skips all of this) ──\n if (chapter.mode === \"chapter\") {\n // Public join config: prices + policy copy + payment readiness (B1/C2).\n if (req.method === \"GET\" && url.pathname === \"/api/join-config\") {\n const db = makeDb(env);\n const groupId = url.searchParams.get(\"group\") ?? chapter.id;\n const { groups } = await db.query({ groups: { $: { where: { id: groupId }, limit: 1 } } });\n const group = Array.isArray(groups) ? groups[0] : undefined;\n if (!group) return json({ error: \"not found\" }, 404);\n const stripeKey = await getVaultSecret(db as unknown as ChapterDb, \"stripe_secret_key\");\n const paymentsReady = Boolean(group.stripePublishableKey && group.stripePriceId && stripeKey);\n return json(joinConfig(group as never, paymentsReady));\n }\n\n // Public application submit — validated, body-capped, idempotent (B2/B3).\n if (req.method === \"POST\" && url.pathname === \"/api/applications\") {\n const raw = await req.text();\n if (raw.length > chapter.application.bodyCap) return json({ error: \"request body too large\" }, 413);\n let parsed: Record<string, unknown>;\n try {\n parsed = JSON.parse(raw) as Record<string, unknown>;\n } catch {\n return json({ error: \"invalid JSON body\" }, 400);\n }\n const submissionId = typeof parsed.submissionId === \"string\" ? parsed.submissionId : undefined;\n const result = await submitApplication(makeDb(env) as unknown as ChapterDb, chapter, parsed, {\n submissionId,\n groupId: chapter.id,\n now: Date.now(),\n newId: () => crypto.randomUUID(),\n });\n if (!result.ok) return json({ error: result.error }, 400);\n return json({ id: result.id, duplicate: result.duplicate, status: result.status });\n }\n }\n\n // Everything else is the static site.\n return env.ASSETS.fetch(req);\n },\n };\n\n return handler;\n}\n","// Identity + authorization for a chapter/hub site — the pieces every membership\n// site needs and none should re-derive: a resolved role policy, role resolution\n// from a JWT claim, the privilege-escalation guard, and a tenant-vault read.\n// Everything here is pure or structural (no runtime @odla-ai/db import), so it is\n// trivially testable and the worker stays the only thing that talks to odla-db.\nimport type { ChapterAuth, ChapterMode, ResolvedAuth } from \"./types\";\n\n/**\n * Apply defaults + validate the auth config into a {@link ResolvedAuth}. Defaults\n * by mode: `chapter` → the `provisional/member/admin` claim ladder with the\n * `superAdmins` tier (Silver & Salt); `hub` → the `admins` allowlist table, no\n * super tier (Built Not Found). Throws at import on a bad policy.\n */\nexport function resolveAuth(mode: ChapterMode, auth: ChapterAuth | undefined): ResolvedAuth {\n const a = auth ?? {};\n const source = a.source ?? (mode === \"hub\" ? \"table\" : \"claim\");\n if (source !== \"claim\" && source !== \"table\") {\n throw new Error(`defineChapter.auth.source: must be \"claim\" or \"table\" — got ${JSON.stringify(a.source)}`);\n }\n const claim = a.claim ?? \"role\";\n if (typeof claim !== \"string\" || claim === \"\") {\n throw new Error(\"defineChapter.auth.claim: must be a non-empty string\");\n }\n const ladder = a.ladder ?? [\"provisional\", \"member\", \"admin\"];\n if (!Array.isArray(ladder) || ladder.length === 0 || !ladder.every((r) => typeof r === \"string\" && r !== \"\")) {\n throw new Error(\"defineChapter.auth.ladder: must be a non-empty array of role strings\");\n }\n const adminRole = ladder[ladder.length - 1] as string;\n const superAdmins = a.superAdmins ?? source === \"claim\";\n return { source, claim, ladder, adminRole, superAdmins };\n}\n\n/** The role from a verified JWT payload, per the resolved policy. An unknown or\n * missing claim falls back to the lowest ladder rung (fail safe, never admin). */\nexport function roleFromClaim(payload: Record<string, unknown>, auth: ResolvedAuth): string {\n const raw = payload[auth.claim];\n return typeof raw === \"string\" && auth.ladder.includes(raw) ? raw : (auth.ladder[0] as string);\n}\n\n/** Does a role meet the admin bar (the highest ladder rung)? */\nexport function isAdminRole(role: string, auth: ResolvedAuth): boolean {\n return role === auth.adminRole;\n}\n\n/** Inputs to the role-change guard — resolved by the caller (route) from the\n * identity provider + the read-only `superAdmins` table. */\nexport interface RoleChangeContext {\n actorId: string;\n actorIsSuper: boolean;\n targetId: string;\n targetCurrentRole: string;\n targetIsSuper: boolean;\n newRole: string;\n auth: ResolvedAuth;\n}\n\n/** The result of {@link canChangeRole}: allow, or deny with the HTTP status +\n * message the route should return. */\nexport type GuardResult = { ok: true } | { ok: false; status: number; error: string };\n\n/**\n * The privilege-escalation guard — package-enforced so every site gets it and\n * none re-derives it. Denies: an out-of-ladder role; changing your own role;\n * touching a super-admin unless you are one; and (when a `superAdmins` tier\n * exists) creating or altering an admin unless you are a super-admin. Note the\n * super-admin tier itself is never writable here — it lives in the read-only\n * `superAdmins` table, set only in odla Studio.\n */\nexport function canChangeRole(ctx: RoleChangeContext): GuardResult {\n const { auth } = ctx;\n if (!auth.ladder.includes(ctx.newRole)) {\n return { ok: false, status: 400, error: `role must be one of: ${auth.ladder.join(\", \")}` };\n }\n if (ctx.actorId === ctx.targetId) {\n return { ok: false, status: 400, error: \"you cannot change your own role\" };\n }\n if (ctx.targetIsSuper && !ctx.actorIsSuper) {\n return { ok: false, status: 403, error: \"this person is a super-admin; their access is managed in odla Studio\" };\n }\n const touchesAdmin = ctx.newRole === auth.adminRole || ctx.targetCurrentRole === auth.adminRole;\n if (auth.superAdmins && touchesAdmin && !ctx.actorIsSuper) {\n return { ok: false, status: 403, error: `only super-admins can create or change an ${auth.adminRole}` };\n }\n return { ok: true };\n}\n\n/** Structural view of odla-db's tenant-vault read, so chapter takes no runtime\n * dependency on @odla-ai/db. The worker's admin client satisfies this. */\nexport interface SecretStore {\n secrets: { get(name: string): Promise<string> };\n}\n\n/**\n * Read a tenant-vault secret by name; `undefined` when it is absent or the vault\n * errors, so callers degrade gracefully (e.g. `paymentsReady: false`) rather than\n * throwing. Never logs the value.\n */\nexport async function getVaultSecret(db: SecretStore, name: string): Promise<string | undefined> {\n try {\n const value = await db.secrets.get(name);\n return typeof value === \"string\" && value !== \"\" ? value : undefined;\n } catch {\n return undefined;\n }\n}\n","// The public member surface logic: the join config a site's join page reads (B1)\n// and the idempotent application submit (B2 validation + B3 exactly-once). Both\n// take the structural ChapterDb, so they're tested against an in-memory fake and\n// carry no runtime @odla-ai/db import. The worker builds the real db client, does\n// Clerk verification, enforces the body cap, and mounts these on chapter routes.\nimport type { Chapter, ChapterApplication, ChapterDb, ResolvedApplication } from \"./types\";\n\n// Silver & Salt's join form. `focus` (a json field) is always accepted.\nconst DEFAULT_REQUIRED = [\"firstName\", \"lastName\", \"email\", \"referral\", \"whoYouAre\", \"message\"];\nconst DEFAULT_OPTIONAL = [\"referralName\", \"linkedin\", \"phone\", \"state\"];\n\n/** Apply defaults + validate the application config. Throws at import on bad shape. */\nexport function resolveApplication(a: ChapterApplication | undefined): ResolvedApplication {\n const required = a?.required ?? DEFAULT_REQUIRED;\n const optional = a?.optional ?? DEFAULT_OPTIONAL;\n for (const [name, arr] of [[\"required\", required], [\"optional\", optional]] as const) {\n if (!Array.isArray(arr) || !arr.every((f) => typeof f === \"string\" && f !== \"\")) {\n throw new Error(`defineChapter.application.${name}: must be an array of field-name strings`);\n }\n }\n return {\n required,\n optional,\n maxLen: a?.maxLen ?? {},\n defaultMaxLen: a?.defaultMaxLen ?? 2000,\n bodyCap: a?.bodyCap ?? 32768,\n };\n}\n\n/** A validated submission, or a 400-worthy validation error the route returns. */\nexport type SubmitResult =\n | { ok: true; id: string; duplicate: boolean; status: string }\n | { ok: false; error: string };\n\n/**\n * Submit a membership application (B2 + B3). Validates the configured required\n * fields + max lengths, writes the `applications` row at the pipeline's initial\n * status, and — when the client supplies a `submissionId` — stamps it as the\n * transaction's mutationId (`join:${submissionId}`) so a double-tap can never\n * create two applications (the second returns `duplicate: true`). Idempotency is\n * package-enforced. `now`/`newId` are injected (deterministic in tests).\n */\nexport async function submitApplication(\n db: ChapterDb,\n chapter: Chapter,\n fields: Record<string, unknown>,\n opts: { submissionId?: string; groupId?: string; now: number; newId: () => string },\n): Promise<SubmitResult> {\n const app = chapter.application;\n for (const f of app.required) {\n const v = fields[f];\n if (typeof v !== \"string\" || v.trim() === \"\") return { ok: false, error: `${f} is required` };\n }\n for (const f of [...app.required, ...app.optional]) {\n const v = fields[f];\n const cap = app.maxLen[f] ?? app.defaultMaxLen;\n if (typeof v === \"string\" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };\n }\n\n const id = opts.newId();\n const row: Record<string, unknown> = { id, status: chapter.pipeline.initial, createdAt: opts.now };\n for (const f of [...app.required, ...app.optional]) {\n if (typeof fields[f] === \"string\") row[f] = (fields[f] as string).trim();\n }\n if (fields.focus !== undefined) row.focus = fields.focus;\n if (opts.groupId) row.groupId = opts.groupId;\n\n const { duplicate } = await db.transact(\n [{ t: \"update\", ns: \"applications\", id, attrs: row }],\n opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : undefined,\n );\n return { ok: true, id, duplicate, status: chapter.pipeline.initial };\n}\n\n/** The `groups`-row fields the join config exposes. */\nexport interface JoinConfigGroup {\n id: string;\n name: string;\n standardPriceCents?: number;\n foundingDiscountCents?: number;\n disclaimerText?: string;\n refundPolicyText?: string;\n trustCopy?: string;\n commitmentText?: string;\n normsText?: string;\n}\n\n/**\n * The public join config (B1) a site's join page reads: copy + prices from the\n * group row plus `paymentsReady`. When payments aren't wired the join flow drops\n * the payment step (C2) — the worker computes `paymentsReady` from the group's\n * Stripe keys + vault secret. Pure.\n */\nexport function joinConfig(group: JoinConfigGroup, paymentsReady: boolean): Record<string, unknown> {\n return {\n id: group.id,\n name: group.name,\n standardPriceCents: group.standardPriceCents ?? 0,\n foundingDiscountCents: group.foundingDiscountCents ?? 0,\n disclaimerText: group.disclaimerText ?? \"\",\n refundPolicyText: group.refundPolicyText ?? \"\",\n trustCopy: group.trustCopy ?? \"\",\n commitmentText: group.commitmentText ?? \"\",\n normsText: group.normsText ?? \"\",\n paymentsReady,\n };\n}\n","// The hub → chapter people projection (push model). The network hub curates\n// prospects and pushes a person's contact data into THIS chapter's own\n// crm_record, so a chapter admin sees network prospects beside their applicants.\n//\n// Invariants (package-enforced so no site re-derives them):\n// - One-way: the chapter never writes back to the hub through this path.\n// - Idempotent: keyed by the hub's record id (a re-share updates, never\n// duplicates) AND unified by primaryEmail — a shared prospect who later\n// submits an application lands on the SAME crm_record, so the two projections\n// compose instead of forking the person.\n// - A person may be shared with many chapters; that fan-out is hub-side, so each\n// chapter's projection here is independent.\n//\n// Reuses @odla-ai/crm's record ops (full validation via crm.prepare), driven by\n// the resolved chapter CRM engine + the structural ChapterDb.\nimport { createRecord, updateRecord } from \"@odla-ai/crm\";\nimport type { Crm } from \"@odla-ai/crm\";\nimport type { ChapterDb } from \"./types\";\n\n/** The contact data the hub shares for a prospect. `hubRecordId` is the stable\n * idempotency key (the hub's crm_record id). */\nexport interface SharedPerson {\n email: string;\n name?: string;\n firstName?: string;\n lastName?: string;\n phone?: string;\n linkedin?: string;\n hubRecordId: string;\n}\n\n/** Map a shared prospect to a crm `person` input (only the fields the default\n * person type accepts). Name falls back to first+last, then the email. */\nexport function sharedPersonInput(person: SharedPerson): Record<string, unknown> {\n const email = person.email.toLowerCase();\n const fullName = [person.firstName, person.lastName].filter(Boolean).join(\" \").trim();\n const input: Record<string, unknown> = { name: person.name ?? fullName ?? email, email };\n if (input.name === \"\") input.name = email;\n if (person.firstName) input.firstName = person.firstName;\n if (person.lastName) input.lastName = person.lastName;\n if (person.phone) input.phone = person.phone;\n if (person.linkedin) input.linkedin = person.linkedin;\n return input;\n}\n\n/** Deps for the projection — the resolved CRM engine, the structural db, and\n * injected clock/id (deterministic in tests). */\nexport interface ProjectionDeps {\n crm: Crm;\n db: ChapterDb;\n now: () => number;\n newId: () => string;\n}\n\n/**\n * Upsert a hub-shared prospect into this chapter's `crm_record` (push\n * projection). Resolves an existing person by lowercased `primaryEmail` and\n * updates it, else creates one with a `share:${hubRecordId}` mutationId. Returns\n * the chapter-side record id. Callers wrap this in `.catch` so a projection\n * failure never fails the hub's share request.\n */\nexport async function projectSharedRecord(deps: ProjectionDeps, person: SharedPerson): Promise<{ recordId: string }> {\n const email = person.email.toLowerCase();\n const input = sharedPersonInput(person);\n const crmDeps = { crm: deps.crm, db: deps.db as never, now: deps.now, newId: deps.newId };\n const { crm_record } = await deps.db.query({\n crm_record: { $: { where: { type: \"person\", primaryEmail: email }, limit: 1 } },\n });\n const existing = crm_record?.[0];\n if (existing && typeof existing.id === \"string\") {\n await updateRecord(crmDeps, { id: existing.id, input });\n return { recordId: existing.id };\n }\n const created = await createRecord(crmDeps, { type: \"person\", input, mutationId: `share:${person.hubRecordId}` });\n return { recordId: created.id };\n}\n"],"mappings":"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","names":["import_crm"]}
@@ -93,6 +93,75 @@ interface ChapterScheduling {
93
93
  windowDays?: number;
94
94
  summaryTemplate?: string;
95
95
  }
96
+ /** How a signed-in user's role is determined.
97
+ *
98
+ * - `"claim"` reads a role ladder from a JWT claim (Silver & Salt's model:
99
+ * `provisional → member → admin`). Admin is the last (highest) rung; the
100
+ * read-only `superAdmins` tier sits above it.
101
+ * - `"table"` gates on the deny-all `admins` allowlist (Built Not Found's model):
102
+ * a binary "email is an admin or isn't", set only in odla Studio.
103
+ *
104
+ * Defaults: `"claim"` in `chapter` mode, `"table"` in `hub` mode. */
105
+ interface ChapterAuth {
106
+ source?: "claim" | "table";
107
+ /** JWT claim holding the role, for `source: "claim"`. Default `"role"`. */
108
+ claim?: string;
109
+ /** Role ladder low→high, for `source: "claim"`. The last entry is admin.
110
+ * Default `["provisional", "member", "admin"]`. */
111
+ ladder?: readonly string[];
112
+ /** Provision the read-only `superAdmins` tier table — the only tier that may
113
+ * create/modify admins, and (like every namespace) deny-all + written only in
114
+ * odla Studio. Default: `true` for `"claim"`, `false` for `"table"`. */
115
+ superAdmins?: boolean;
116
+ }
117
+ /** The fully-resolved auth policy (defaults applied) carried on the {@link Chapter}. */
118
+ interface ResolvedAuth {
119
+ source: "claim" | "table";
120
+ claim: string;
121
+ ladder: readonly string[];
122
+ /** The highest ladder rung (last entry) — the "admin" gate. */
123
+ adminRole: string;
124
+ superAdmins: boolean;
125
+ }
126
+ /** The application status pipeline. Which statuses exist, and the subsets a site
127
+ * allows a call to be booked from / an application approved from. Defaults to
128
+ * Silver & Salt's pipeline. Status never moves backwards (package-enforced). */
129
+ interface ChapterPipeline {
130
+ stages?: readonly string[];
131
+ bookableFrom?: readonly string[];
132
+ approvableFrom?: readonly string[];
133
+ /** The status a new application starts at. Default: the first stage. */
134
+ initial?: string;
135
+ }
136
+ /** The fully-resolved pipeline (defaults applied) carried on the {@link Chapter}. */
137
+ interface ResolvedPipeline {
138
+ stages: readonly string[];
139
+ bookableFrom: readonly string[];
140
+ approvableFrom: readonly string[];
141
+ initial: string;
142
+ }
143
+ /** The application (join form) validation surface — which string fields are
144
+ * required vs accepted, their max lengths, and the request body cap. Drives
145
+ * submit validation + the CRM slot projection; defaults to Silver & Salt's form.
146
+ * The `applications` schema attrs stay fixed (byte-equal to S&S); this is
147
+ * validation config, not schema generation. */
148
+ interface ChapterApplication {
149
+ required?: readonly string[];
150
+ optional?: readonly string[];
151
+ /** Per-field character cap. Fields not listed use `defaultMaxLen`. */
152
+ maxLen?: Record<string, number>;
153
+ defaultMaxLen?: number;
154
+ /** Max JSON request body in bytes. Default 32768. */
155
+ bodyCap?: number;
156
+ }
157
+ /** The fully-resolved application config carried on the {@link Chapter}. */
158
+ interface ResolvedApplication {
159
+ required: readonly string[];
160
+ optional: readonly string[];
161
+ maxLen: Record<string, number>;
162
+ defaultMaxLen: number;
163
+ bodyCap: number;
164
+ }
96
165
  /** The `defineChapter()` config a site fills in. */
97
166
  interface ChapterConfig {
98
167
  /** Slug: app id, tenant, group id, worker name. `[a-z0-9-]`. */
@@ -111,6 +180,12 @@ interface ChapterConfig {
111
180
  /** `notificationEmail` required in `chapter` mode. */
112
181
  emails?: ChapterEmails;
113
182
  scheduling?: ChapterScheduling;
183
+ /** Application status pipeline (stages + bookable/approvable subsets). Defaults to S&S's. */
184
+ pipeline?: ChapterPipeline;
185
+ /** Join-form validation (required/optional fields, max lengths, body cap). */
186
+ application?: ChapterApplication;
187
+ /** Role source + ladder + super-admin tier. Defaults by mode (see {@link ChapterAuth}). */
188
+ auth?: ChapterAuth;
114
189
  /** odla services (db implied). Default `["db","calendar","o11y"]`. */
115
190
  services?: readonly string[];
116
191
  }
@@ -123,6 +198,12 @@ interface Chapter {
123
198
  mode: ChapterMode;
124
199
  /** Resolved CRM engine (from `defineCrm`). */
125
200
  crm: Crm;
201
+ /** Resolved auth policy (source, claim, ladder, super-admin tier). */
202
+ auth: ResolvedAuth;
203
+ /** Resolved application status pipeline (stages + bookable/approvable subsets). */
204
+ pipeline: ResolvedPipeline;
205
+ /** Resolved join-form validation config. */
206
+ application: ResolvedApplication;
126
207
  /** The chapter's own odla-db namespaces (mode-dependent; excludes `crm_*`). */
127
208
  schema: DbSchema;
128
209
  rules: DbRules;
@@ -93,6 +93,75 @@ interface ChapterScheduling {
93
93
  windowDays?: number;
94
94
  summaryTemplate?: string;
95
95
  }
96
+ /** How a signed-in user's role is determined.
97
+ *
98
+ * - `"claim"` reads a role ladder from a JWT claim (Silver & Salt's model:
99
+ * `provisional → member → admin`). Admin is the last (highest) rung; the
100
+ * read-only `superAdmins` tier sits above it.
101
+ * - `"table"` gates on the deny-all `admins` allowlist (Built Not Found's model):
102
+ * a binary "email is an admin or isn't", set only in odla Studio.
103
+ *
104
+ * Defaults: `"claim"` in `chapter` mode, `"table"` in `hub` mode. */
105
+ interface ChapterAuth {
106
+ source?: "claim" | "table";
107
+ /** JWT claim holding the role, for `source: "claim"`. Default `"role"`. */
108
+ claim?: string;
109
+ /** Role ladder low→high, for `source: "claim"`. The last entry is admin.
110
+ * Default `["provisional", "member", "admin"]`. */
111
+ ladder?: readonly string[];
112
+ /** Provision the read-only `superAdmins` tier table — the only tier that may
113
+ * create/modify admins, and (like every namespace) deny-all + written only in
114
+ * odla Studio. Default: `true` for `"claim"`, `false` for `"table"`. */
115
+ superAdmins?: boolean;
116
+ }
117
+ /** The fully-resolved auth policy (defaults applied) carried on the {@link Chapter}. */
118
+ interface ResolvedAuth {
119
+ source: "claim" | "table";
120
+ claim: string;
121
+ ladder: readonly string[];
122
+ /** The highest ladder rung (last entry) — the "admin" gate. */
123
+ adminRole: string;
124
+ superAdmins: boolean;
125
+ }
126
+ /** The application status pipeline. Which statuses exist, and the subsets a site
127
+ * allows a call to be booked from / an application approved from. Defaults to
128
+ * Silver & Salt's pipeline. Status never moves backwards (package-enforced). */
129
+ interface ChapterPipeline {
130
+ stages?: readonly string[];
131
+ bookableFrom?: readonly string[];
132
+ approvableFrom?: readonly string[];
133
+ /** The status a new application starts at. Default: the first stage. */
134
+ initial?: string;
135
+ }
136
+ /** The fully-resolved pipeline (defaults applied) carried on the {@link Chapter}. */
137
+ interface ResolvedPipeline {
138
+ stages: readonly string[];
139
+ bookableFrom: readonly string[];
140
+ approvableFrom: readonly string[];
141
+ initial: string;
142
+ }
143
+ /** The application (join form) validation surface — which string fields are
144
+ * required vs accepted, their max lengths, and the request body cap. Drives
145
+ * submit validation + the CRM slot projection; defaults to Silver & Salt's form.
146
+ * The `applications` schema attrs stay fixed (byte-equal to S&S); this is
147
+ * validation config, not schema generation. */
148
+ interface ChapterApplication {
149
+ required?: readonly string[];
150
+ optional?: readonly string[];
151
+ /** Per-field character cap. Fields not listed use `defaultMaxLen`. */
152
+ maxLen?: Record<string, number>;
153
+ defaultMaxLen?: number;
154
+ /** Max JSON request body in bytes. Default 32768. */
155
+ bodyCap?: number;
156
+ }
157
+ /** The fully-resolved application config carried on the {@link Chapter}. */
158
+ interface ResolvedApplication {
159
+ required: readonly string[];
160
+ optional: readonly string[];
161
+ maxLen: Record<string, number>;
162
+ defaultMaxLen: number;
163
+ bodyCap: number;
164
+ }
96
165
  /** The `defineChapter()` config a site fills in. */
97
166
  interface ChapterConfig {
98
167
  /** Slug: app id, tenant, group id, worker name. `[a-z0-9-]`. */
@@ -111,6 +180,12 @@ interface ChapterConfig {
111
180
  /** `notificationEmail` required in `chapter` mode. */
112
181
  emails?: ChapterEmails;
113
182
  scheduling?: ChapterScheduling;
183
+ /** Application status pipeline (stages + bookable/approvable subsets). Defaults to S&S's. */
184
+ pipeline?: ChapterPipeline;
185
+ /** Join-form validation (required/optional fields, max lengths, body cap). */
186
+ application?: ChapterApplication;
187
+ /** Role source + ladder + super-admin tier. Defaults by mode (see {@link ChapterAuth}). */
188
+ auth?: ChapterAuth;
114
189
  /** odla services (db implied). Default `["db","calendar","o11y"]`. */
115
190
  services?: readonly string[];
116
191
  }
@@ -123,6 +198,12 @@ interface Chapter {
123
198
  mode: ChapterMode;
124
199
  /** Resolved CRM engine (from `defineCrm`). */
125
200
  crm: Crm;
201
+ /** Resolved auth policy (source, claim, ladder, super-admin tier). */
202
+ auth: ResolvedAuth;
203
+ /** Resolved application status pipeline (stages + bookable/approvable subsets). */
204
+ pipeline: ResolvedPipeline;
205
+ /** Resolved join-form validation config. */
206
+ application: ResolvedApplication;
126
207
  /** The chapter's own odla-db namespaces (mode-dependent; excludes `crm_*`). */
127
208
  schema: DbSchema;
128
209
  rules: DbRules;