@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.
package/dist/index.d.cts CHANGED
@@ -4,6 +4,28 @@ import { CrmConfig, Crm } from '@odla-ai/crm';
4
4
  * (join, Stripe membership, booking, member area, admin, CRM); `hub` is
5
5
  * admin-only and CRM-focused (a directory/registry over the same CRM). */
6
6
  type ChapterMode = "chapter" | "hub";
7
+ /** One odla-db transaction op — the wire shape both the real admin client and a
8
+ * test double (`FakeDb`) accept. Built as a plain object, never via a `tx` proxy. */
9
+ interface DbOp {
10
+ t: "update" | "merge" | "retract" | "delete";
11
+ ns: string;
12
+ id: string;
13
+ attrs?: Record<string, unknown>;
14
+ }
15
+ /** The structural view of the odla-db admin client the worker builds and the
16
+ * member routes consume — so route logic is testable against an in-memory fake. */
17
+ interface ChapterDb {
18
+ query(q: Record<string, unknown>): Promise<Record<string, Array<Record<string, unknown>>>>;
19
+ transact(ops: DbOp[], opts?: {
20
+ mutationId?: string;
21
+ }): Promise<{
22
+ txId: number;
23
+ duplicate: boolean;
24
+ }>;
25
+ secrets: {
26
+ get(name: string): Promise<string>;
27
+ };
28
+ }
7
29
  /** The scalar kinds an odla-db attribute can hold. */
8
30
  type AttrType = "string" | "number" | "boolean" | "json";
9
31
  /** One odla-db attribute: its type and index/uniqueness/optionality flags. */
@@ -93,6 +115,75 @@ interface ChapterScheduling {
93
115
  windowDays?: number;
94
116
  summaryTemplate?: string;
95
117
  }
118
+ /** How a signed-in user's role is determined.
119
+ *
120
+ * - `"claim"` reads a role ladder from a JWT claim (Silver & Salt's model:
121
+ * `provisional → member → admin`). Admin is the last (highest) rung; the
122
+ * read-only `superAdmins` tier sits above it.
123
+ * - `"table"` gates on the deny-all `admins` allowlist (Built Not Found's model):
124
+ * a binary "email is an admin or isn't", set only in odla Studio.
125
+ *
126
+ * Defaults: `"claim"` in `chapter` mode, `"table"` in `hub` mode. */
127
+ interface ChapterAuth {
128
+ source?: "claim" | "table";
129
+ /** JWT claim holding the role, for `source: "claim"`. Default `"role"`. */
130
+ claim?: string;
131
+ /** Role ladder low→high, for `source: "claim"`. The last entry is admin.
132
+ * Default `["provisional", "member", "admin"]`. */
133
+ ladder?: readonly string[];
134
+ /** Provision the read-only `superAdmins` tier table — the only tier that may
135
+ * create/modify admins, and (like every namespace) deny-all + written only in
136
+ * odla Studio. Default: `true` for `"claim"`, `false` for `"table"`. */
137
+ superAdmins?: boolean;
138
+ }
139
+ /** The fully-resolved auth policy (defaults applied) carried on the {@link Chapter}. */
140
+ interface ResolvedAuth {
141
+ source: "claim" | "table";
142
+ claim: string;
143
+ ladder: readonly string[];
144
+ /** The highest ladder rung (last entry) — the "admin" gate. */
145
+ adminRole: string;
146
+ superAdmins: boolean;
147
+ }
148
+ /** The application status pipeline. Which statuses exist, and the subsets a site
149
+ * allows a call to be booked from / an application approved from. Defaults to
150
+ * Silver & Salt's pipeline. Status never moves backwards (package-enforced). */
151
+ interface ChapterPipeline {
152
+ stages?: readonly string[];
153
+ bookableFrom?: readonly string[];
154
+ approvableFrom?: readonly string[];
155
+ /** The status a new application starts at. Default: the first stage. */
156
+ initial?: string;
157
+ }
158
+ /** The fully-resolved pipeline (defaults applied) carried on the {@link Chapter}. */
159
+ interface ResolvedPipeline {
160
+ stages: readonly string[];
161
+ bookableFrom: readonly string[];
162
+ approvableFrom: readonly string[];
163
+ initial: string;
164
+ }
165
+ /** The application (join form) validation surface — which string fields are
166
+ * required vs accepted, their max lengths, and the request body cap. Drives
167
+ * submit validation + the CRM slot projection; defaults to Silver & Salt's form.
168
+ * The `applications` schema attrs stay fixed (byte-equal to S&S); this is
169
+ * validation config, not schema generation. */
170
+ interface ChapterApplication {
171
+ required?: readonly string[];
172
+ optional?: readonly string[];
173
+ /** Per-field character cap. Fields not listed use `defaultMaxLen`. */
174
+ maxLen?: Record<string, number>;
175
+ defaultMaxLen?: number;
176
+ /** Max JSON request body in bytes. Default 32768. */
177
+ bodyCap?: number;
178
+ }
179
+ /** The fully-resolved application config carried on the {@link Chapter}. */
180
+ interface ResolvedApplication {
181
+ required: readonly string[];
182
+ optional: readonly string[];
183
+ maxLen: Record<string, number>;
184
+ defaultMaxLen: number;
185
+ bodyCap: number;
186
+ }
96
187
  /** The `defineChapter()` config a site fills in. */
97
188
  interface ChapterConfig {
98
189
  /** Slug: app id, tenant, group id, worker name. `[a-z0-9-]`. */
@@ -111,6 +202,12 @@ interface ChapterConfig {
111
202
  /** `notificationEmail` required in `chapter` mode. */
112
203
  emails?: ChapterEmails;
113
204
  scheduling?: ChapterScheduling;
205
+ /** Application status pipeline (stages + bookable/approvable subsets). Defaults to S&S's. */
206
+ pipeline?: ChapterPipeline;
207
+ /** Join-form validation (required/optional fields, max lengths, body cap). */
208
+ application?: ChapterApplication;
209
+ /** Role source + ladder + super-admin tier. Defaults by mode (see {@link ChapterAuth}). */
210
+ auth?: ChapterAuth;
114
211
  /** odla services (db implied). Default `["db","calendar","o11y"]`. */
115
212
  services?: readonly string[];
116
213
  }
@@ -123,6 +220,12 @@ interface Chapter {
123
220
  mode: ChapterMode;
124
221
  /** Resolved CRM engine (from `defineCrm`). */
125
222
  crm: Crm;
223
+ /** Resolved auth policy (source, claim, ladder, super-admin tier). */
224
+ auth: ResolvedAuth;
225
+ /** Resolved application status pipeline (stages + bookable/approvable subsets). */
226
+ pipeline: ResolvedPipeline;
227
+ /** Resolved join-form validation config. */
228
+ application: ResolvedApplication;
126
229
  /** The chapter's own odla-db namespaces (mode-dependent; excludes `crm_*`). */
127
230
  schema: DbSchema;
128
231
  rules: DbRules;
@@ -179,10 +282,15 @@ interface ChapterIntegrationDescriptor {
179
282
  */
180
283
  declare function createChapterIntegration(chapter: Chapter, options?: ChapterIntegrationOptions): ChapterIntegrationDescriptor;
181
284
 
182
- /** The chapter's own schema + deny-all rules for a mode. `hub` needs only the
183
- * `admins` allowlist (its records live in `crm_*`); `chapter` adds the
184
- * operational membership tables. */
185
- declare function chapterDb(mode: ChapterMode): {
285
+ /** The chapter's own schema + deny-all rules for a mode + auth policy.
286
+ *
287
+ * Operational tables (`applications`/`groups`/`meetings`/`emailLog`) are added in
288
+ * `chapter` mode only. The auth tables follow {@link ResolvedAuth}: `source:
289
+ * "table"` adds the `admins` allowlist (hub/BNF); `superAdmins` adds the
290
+ * read-only super-admin tier (default on for the `"claim"` ladder). A `"claim"`
291
+ * chapter therefore emits exactly Silver & Salt's namespace set — `applications`,
292
+ * `groups`, `meetings`, `emailLog`, `superAdmins` — with no `admins` table. */
293
+ declare function chapterDb(mode: ChapterMode, auth: ResolvedAuth): {
186
294
  schema: DbSchema;
187
295
  rules: DbRules;
188
296
  };
@@ -195,4 +303,247 @@ declare function defaultCrm(mode: ChapterMode): CrmConfig;
195
303
  * empty copy / defaults, so a minimal config still provisions cleanly. */
196
304
  declare function buildGroupSeed(config: ChapterConfig): Record<string, unknown>;
197
305
 
198
- export { type Attr, type AttrType, type Chapter, type ChapterBrand, type ChapterConfig, type ChapterEmails, type ChapterIntegrationDescriptor, type ChapterIntegrationOptions, type ChapterMode, type ChapterPolicy, type ChapterPrices, type ChapterScheduling, type DbRules, type DbSchema, type EmailTemplate, type Entity, type Rule, buildGroupSeed, chapterDb, createChapterIntegration, defaultCrm, defineChapter };
306
+ /**
307
+ * Apply defaults + validate the auth config into a {@link ResolvedAuth}. Defaults
308
+ * by mode: `chapter` → the `provisional/member/admin` claim ladder with the
309
+ * `superAdmins` tier (Silver & Salt); `hub` → the `admins` allowlist table, no
310
+ * super tier (Built Not Found). Throws at import on a bad policy.
311
+ */
312
+ declare function resolveAuth(mode: ChapterMode, auth: ChapterAuth | undefined): ResolvedAuth;
313
+ /** The role from a verified JWT payload, per the resolved policy. An unknown or
314
+ * missing claim falls back to the lowest ladder rung (fail safe, never admin). */
315
+ declare function roleFromClaim(payload: Record<string, unknown>, auth: ResolvedAuth): string;
316
+ /** Does a role meet the admin bar (the highest ladder rung)? */
317
+ declare function isAdminRole(role: string, auth: ResolvedAuth): boolean;
318
+ /** Inputs to the role-change guard — resolved by the caller (route) from the
319
+ * identity provider + the read-only `superAdmins` table. */
320
+ interface RoleChangeContext {
321
+ actorId: string;
322
+ actorIsSuper: boolean;
323
+ targetId: string;
324
+ targetCurrentRole: string;
325
+ targetIsSuper: boolean;
326
+ newRole: string;
327
+ auth: ResolvedAuth;
328
+ }
329
+ /** The result of {@link canChangeRole}: allow, or deny with the HTTP status +
330
+ * message the route should return. */
331
+ type GuardResult = {
332
+ ok: true;
333
+ } | {
334
+ ok: false;
335
+ status: number;
336
+ error: string;
337
+ };
338
+ /**
339
+ * The privilege-escalation guard — package-enforced so every site gets it and
340
+ * none re-derives it. Denies: an out-of-ladder role; changing your own role;
341
+ * touching a super-admin unless you are one; and (when a `superAdmins` tier
342
+ * exists) creating or altering an admin unless you are a super-admin. Note the
343
+ * super-admin tier itself is never writable here — it lives in the read-only
344
+ * `superAdmins` table, set only in odla Studio.
345
+ */
346
+ declare function canChangeRole(ctx: RoleChangeContext): GuardResult;
347
+ /** Structural view of odla-db's tenant-vault read, so chapter takes no runtime
348
+ * dependency on @odla-ai/db. The worker's admin client satisfies this. */
349
+ interface SecretStore {
350
+ secrets: {
351
+ get(name: string): Promise<string>;
352
+ };
353
+ }
354
+ /**
355
+ * Read a tenant-vault secret by name; `undefined` when it is absent or the vault
356
+ * errors, so callers degrade gracefully (e.g. `paymentsReady: false`) rather than
357
+ * throwing. Never logs the value.
358
+ */
359
+ declare function getVaultSecret(db: SecretStore, name: string): Promise<string | undefined>;
360
+
361
+ /** One owner-editable template row on the group. `enabled` absent = enabled. */
362
+ interface EmailTemplateRow {
363
+ subject: string;
364
+ text: string;
365
+ enabled?: boolean;
366
+ }
367
+ /** The `groups`-row fields the email pipeline reads. */
368
+ interface EmailGroup {
369
+ id: string;
370
+ name: string;
371
+ replyTo: string;
372
+ /** Non-prod debug inbox: all mail redirects here outside prod (E2). */
373
+ debugEmail?: string;
374
+ refundPolicyText?: string;
375
+ commitmentText?: string;
376
+ normsText?: string;
377
+ emailTemplates: Record<string, EmailTemplateRow>;
378
+ }
379
+ /** `{{placeholder}}` substitution; unknown placeholders render empty. */
380
+ declare function render(template: string, vars: Record<string, string>): string;
381
+ /**
382
+ * Re-render a template's body for history/preview (E4): the CRM comms history
383
+ * reads back emails whose body predates `emailLog.body` by rendering the current
384
+ * template with the recipient's vars. Same substitution + group vars as the send
385
+ * path. `null` for an unknown template. Reflects the copy as it reads today, not
386
+ * necessarily the exact bytes originally sent (only `emailLog.body` is byte-exact).
387
+ */
388
+ declare function renderTemplateBody(group: EmailGroup, template: string, vars: Record<string, string>): string | null;
389
+ /**
390
+ * E1 (exactly-once): given the prior `emailLog` rows for a `dedupeKey`, has the
391
+ * mail already been delivered? A prior row with **no error** means yes — the
392
+ * caller short-circuits the resend. Failure rows (which carry an `error` and are
393
+ * written without the dedupe mutationId) do not count, so a retry after a failure
394
+ * can still succeed.
395
+ */
396
+ declare function isAlreadySent(priorRows: ReadonlyArray<{
397
+ error?: unknown;
398
+ }>): boolean;
399
+ /** The pure delivery decision produced by {@link planDelivery}. */
400
+ type DeliveryDecision = {
401
+ deliver: false;
402
+ reason: "template-missing" | "disabled";
403
+ } | {
404
+ deliver: true;
405
+ /** Which transport to use — `log-only` records the send but delivers nothing. */
406
+ transport: "cloudflare" | "log-only";
407
+ to: string;
408
+ subject: string;
409
+ text: string;
410
+ /** True when redirected to the non-prod debug inbox. */
411
+ redirected: boolean;
412
+ };
413
+ /**
414
+ * The pure delivery decision (E2 fail-safe + E3 enabled). Given the env, group,
415
+ * template, recipient, and whether a real Cloudflare transport is wired:
416
+ * - missing template → not delivered (`template-missing`);
417
+ * - disabled template and not forced → not delivered (`disabled`);
418
+ * - **non-prod with a debug inbox** → REDIRECT to it, `"[dev] "` subject prefix,
419
+ * a dev-redirect note in the body, so test applicants never receive real mail;
420
+ * - **non-prod with NO debug inbox** → force `log-only` (deliver nothing) — the
421
+ * fail-safe that protects every site's test data;
422
+ * - prod → deliver via the real transport (`cloudflare` if wired, else `log-only`).
423
+ */
424
+ declare function planDelivery(input: {
425
+ envName: string;
426
+ group: EmailGroup;
427
+ template: string;
428
+ to: string;
429
+ vars: Record<string, string>;
430
+ /** Whether a Cloudflare Email Service transport (binding + verified from) is wired. */
431
+ cloudflareReady: boolean;
432
+ /** The admin test route may send a disabled template. */
433
+ force?: boolean;
434
+ }): DeliveryDecision;
435
+
436
+ /**
437
+ * Apply defaults + validate the pipeline config. With no config, the full Silver
438
+ * & Salt pipeline. With `stages` given but the subsets omitted, the subsets
439
+ * default to empty (a site opts in to bookable/approvable states explicitly).
440
+ * Throws at import on a bad pipeline (empty/duplicate stages, an initial or a
441
+ * subset entry not on the ladder).
442
+ */
443
+ declare function resolvePipeline(p: ChapterPipeline | undefined): ResolvedPipeline;
444
+ /** The ordinal of a status in the ladder, or -1 if unknown. */
445
+ declare function stageIndex(status: string, p: ResolvedPipeline): number;
446
+ /**
447
+ * The status-never-moves-backwards invariant: a transition is allowed only when
448
+ * both statuses are on the ladder and `to` is at or ahead of `from`. The worker
449
+ * calls this before every status write; a violation is a 409, never a silent
450
+ * downgrade.
451
+ */
452
+ declare function canTransition(from: string, to: string, p: ResolvedPipeline): boolean;
453
+ /** May an intro call be booked from this status? */
454
+ declare function canBook(status: string, p: ResolvedPipeline): boolean;
455
+ /** May an application be approved (→ member) from this status? */
456
+ declare function canApprove(status: string, p: ResolvedPipeline): boolean;
457
+
458
+ /**
459
+ * Verify a Stripe webhook signature (C3): HMAC-SHA256 over `` `${t}.${payload}` ``
460
+ * with the endpoint signing secret, a replay window (default 5 minutes), and a
461
+ * constant-time compare. Package-enforced — never left to a site. Returns `false`
462
+ * (never throws) on a malformed header, a non-numeric or stale timestamp, or a
463
+ * signature mismatch. `now`/`toleranceSec` are injectable for tests.
464
+ */
465
+ declare function verifyStripeSignature(payload: string, header: string, secret: string, opts?: {
466
+ now?: number;
467
+ toleranceSec?: number;
468
+ }): Promise<boolean>;
469
+
470
+ /** Apply defaults + validate the application config. Throws at import on bad shape. */
471
+ declare function resolveApplication(a: ChapterApplication | undefined): ResolvedApplication;
472
+ /** A validated submission, or a 400-worthy validation error the route returns. */
473
+ type SubmitResult = {
474
+ ok: true;
475
+ id: string;
476
+ duplicate: boolean;
477
+ status: string;
478
+ } | {
479
+ ok: false;
480
+ error: string;
481
+ };
482
+ /**
483
+ * Submit a membership application (B2 + B3). Validates the configured required
484
+ * fields + max lengths, writes the `applications` row at the pipeline's initial
485
+ * status, and — when the client supplies a `submissionId` — stamps it as the
486
+ * transaction's mutationId (`join:${submissionId}`) so a double-tap can never
487
+ * create two applications (the second returns `duplicate: true`). Idempotency is
488
+ * package-enforced. `now`/`newId` are injected (deterministic in tests).
489
+ */
490
+ declare function submitApplication(db: ChapterDb, chapter: Chapter, fields: Record<string, unknown>, opts: {
491
+ submissionId?: string;
492
+ groupId?: string;
493
+ now: number;
494
+ newId: () => string;
495
+ }): Promise<SubmitResult>;
496
+ /** The `groups`-row fields the join config exposes. */
497
+ interface JoinConfigGroup {
498
+ id: string;
499
+ name: string;
500
+ standardPriceCents?: number;
501
+ foundingDiscountCents?: number;
502
+ disclaimerText?: string;
503
+ refundPolicyText?: string;
504
+ trustCopy?: string;
505
+ commitmentText?: string;
506
+ normsText?: string;
507
+ }
508
+ /**
509
+ * The public join config (B1) a site's join page reads: copy + prices from the
510
+ * group row plus `paymentsReady`. When payments aren't wired the join flow drops
511
+ * the payment step (C2) — the worker computes `paymentsReady` from the group's
512
+ * Stripe keys + vault secret. Pure.
513
+ */
514
+ declare function joinConfig(group: JoinConfigGroup, paymentsReady: boolean): Record<string, unknown>;
515
+
516
+ /** The contact data the hub shares for a prospect. `hubRecordId` is the stable
517
+ * idempotency key (the hub's crm_record id). */
518
+ interface SharedPerson {
519
+ email: string;
520
+ name?: string;
521
+ firstName?: string;
522
+ lastName?: string;
523
+ phone?: string;
524
+ linkedin?: string;
525
+ hubRecordId: string;
526
+ }
527
+ /** Map a shared prospect to a crm `person` input (only the fields the default
528
+ * person type accepts). Name falls back to first+last, then the email. */
529
+ declare function sharedPersonInput(person: SharedPerson): Record<string, unknown>;
530
+ /** Deps for the projection — the resolved CRM engine, the structural db, and
531
+ * injected clock/id (deterministic in tests). */
532
+ interface ProjectionDeps {
533
+ crm: Crm;
534
+ db: ChapterDb;
535
+ now: () => number;
536
+ newId: () => string;
537
+ }
538
+ /**
539
+ * Upsert a hub-shared prospect into this chapter's `crm_record` (push
540
+ * projection). Resolves an existing person by lowercased `primaryEmail` and
541
+ * updates it, else creates one with a `share:${hubRecordId}` mutationId. Returns
542
+ * the chapter-side record id. Callers wrap this in `.catch` so a projection
543
+ * failure never fails the hub's share request.
544
+ */
545
+ declare function projectSharedRecord(deps: ProjectionDeps, person: SharedPerson): Promise<{
546
+ recordId: string;
547
+ }>;
548
+
549
+ export { type Attr, type AttrType, type Chapter, type ChapterApplication, type ChapterAuth, type ChapterBrand, type ChapterConfig, type ChapterDb, type ChapterEmails, type ChapterIntegrationDescriptor, type ChapterIntegrationOptions, type ChapterMode, type ChapterPipeline, type ChapterPolicy, type ChapterPrices, type ChapterScheduling, type DbOp, type DbRules, type DbSchema, type DeliveryDecision, type EmailGroup, type EmailTemplate, type EmailTemplateRow, type Entity, type GuardResult, type JoinConfigGroup, type ProjectionDeps, type ResolvedApplication, type ResolvedAuth, type ResolvedPipeline, type RoleChangeContext, type Rule, type SecretStore, type SharedPerson, type SubmitResult, buildGroupSeed, canApprove, canBook, canChangeRole, canTransition, chapterDb, createChapterIntegration, defaultCrm, defineChapter, getVaultSecret, isAdminRole, isAlreadySent, joinConfig, planDelivery, projectSharedRecord, render, renderTemplateBody, resolveApplication, resolveAuth, resolvePipeline, roleFromClaim, sharedPersonInput, stageIndex, submitApplication, verifyStripeSignature };