@odla-ai/chapter 0.10.1 → 0.12.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/README.md +48 -8
- package/dist/index.cjs +50 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +63 -8
- package/dist/index.d.ts +63 -8
- package/dist/index.js +50 -9
- package/dist/index.js.map +1 -1
- package/dist/worker/index.cjs +48 -10
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +32 -0
- package/dist/worker/index.d.ts +32 -0
- package/dist/worker/index.js +48 -10
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -156,18 +156,46 @@ These bite silently — a smoke test won't catch them:
|
|
|
156
156
|
ready), `"none"` skips it — all need `clerk_secret_key` in the tenant vault. A
|
|
157
157
|
site that wants server-side create must set `account: "create"`; inheriting the
|
|
158
158
|
default silently changes the model.
|
|
159
|
-
- **What lands on the Clerk account
|
|
160
|
-
|
|
159
|
+
- **What lands on the Clerk account (and its `public_metadata` is
|
|
160
|
+
client-readable).** Both models write `public_metadata` as
|
|
161
|
+
`{ applicationId, profile }`. By default `profile` is every configured
|
|
161
162
|
`application.required`/`optional` field Clerk doesn't already carry natively
|
|
162
|
-
(so *not* email/firstName/lastName), plus `focus
|
|
163
|
-
|
|
164
|
-
|
|
163
|
+
(so *not* email/firstName/lastName), plus `focus` — **which means free-text or
|
|
164
|
+
third-party fields like `message` or `referral` are exposed to the browser
|
|
165
|
+
unless you curate.** Set `application: { profileFields: ["phone", "state",
|
|
166
|
+
"focus", ...] }` to an allowlist and everything else stays db-only. Array fields
|
|
167
|
+
(`focus`) are clamped to `maxArrayLen` (default 100) and non-primitive elements
|
|
168
|
+
dropped, so a client can't post an unbounded array into metadata.
|
|
169
|
+
`applicantProfile(chapter, fields)` is exported to assert the exact shape in a
|
|
170
|
+
test before deleting a local override. (Default is back-compat today; expected
|
|
171
|
+
to tighten at 1.0.)
|
|
172
|
+
- **Email + input validation.** The field literally named `email` is checked
|
|
173
|
+
against a permissive email shape (400 on `"notanemail"`, so it fails cleanly
|
|
174
|
+
here rather than at the downstream Clerk create) — set
|
|
175
|
+
`application: { validateEmail: false }` to opt out; `isValidEmail` is exported.
|
|
176
|
+
A valid application is never newly rejected.
|
|
177
|
+
- **CRM enrichment.** `projectApplicant` writes the base identity/contact person.
|
|
178
|
+
To carry more of the application into the CRM, list `application: { crmFields:
|
|
179
|
+
[...] }` — each must be a field on your crm person type, else that field is
|
|
180
|
+
dropped and the base person still projects (it is never lost). Stage mirroring,
|
|
181
|
+
billing snapshots and Clerk-identity linking stay yours as host routes.
|
|
165
182
|
- **Disclaimer acknowledgement.** A truthy `disclaimerAck` on the submit body
|
|
166
183
|
(boolean or the string a plain form posts) stamps `disclaimerAckAt` from the
|
|
167
184
|
*server* clock; no ack leaves the attr absent, and a client-supplied
|
|
168
|
-
`disclaimerAckAt` is ignored.
|
|
169
|
-
|
|
170
|
-
|
|
185
|
+
`disclaimerAckAt` is ignored.
|
|
186
|
+
|
|
187
|
+
Unlike most data loss this is **unreconstructible** — you cannot later
|
|
188
|
+
determine whether someone checked a box — so the submit response always echoes
|
|
189
|
+
`disclaimerAckAt` (a number, or `null` when nothing was recorded). Assert it in
|
|
190
|
+
one integration test and a join page that quietly stops posting the flag can't
|
|
191
|
+
go unnoticed.
|
|
192
|
+
|
|
193
|
+
**If the disclaimer is a compliance record, set
|
|
194
|
+
`application: { requireDisclaimerAck: true }`** and a submit with no ack is a
|
|
195
|
+
400 instead of a row with no consent. It defaults to `false` so an upgrade
|
|
196
|
+
never starts rejecting traffic; enabling it fails *deterministically* on your
|
|
197
|
+
first test submit, not intermittently in production. Expect this to default to
|
|
198
|
+
`true` at 1.0.
|
|
171
199
|
- **CRM projection points.** chapter projects the person on application submit
|
|
172
200
|
(`projectApplicant`), not on booking or on webhook status change. If you mirror
|
|
173
201
|
pipeline stage into the CRM, keep those routes.
|
|
@@ -177,6 +205,13 @@ These bite silently — a smoke test won't catch them:
|
|
|
177
205
|
|
|
178
206
|
### Install + scope notes
|
|
179
207
|
|
|
208
|
+
- **Your first admin is seeded in odla Studio, by hand — and the email must be
|
|
209
|
+
lowercased.** Neither `admins` nor `superAdmins` is ever written by a worker
|
|
210
|
+
route or a provisioning seed; that is deliberate, so nothing running in the app
|
|
211
|
+
(or injected into a page) can grant admin. The gate looks the email up
|
|
212
|
+
lowercased, so a row saved as `Ada@Example.com` matches nothing and the account
|
|
213
|
+
silently isn't an admin — with no error to tell you. Save it lowercase, then
|
|
214
|
+
confirm by signing in, not by looking at the row.
|
|
180
215
|
- **`@odla-ai/auth-clerk` is not a chapter peer.** It is deliberately absent from
|
|
181
216
|
this package's manifest, because only one entry imports it: the worker verifies
|
|
182
217
|
JWTs with `jose` via `ctx.verifyUser`, and `@odla-ai/chapter/ui/member` is
|
|
@@ -201,6 +236,11 @@ These bite silently — a smoke test won't catch them:
|
|
|
201
236
|
per-person comms, people list, role changes, approve and refund are out of
|
|
202
237
|
scope** — they are yours as host routes via the seam. Don't plan around them
|
|
203
238
|
shipping.
|
|
239
|
+
- **`onboardingInvite` is a template with no built-in caller.** It is seeded into
|
|
240
|
+
the group's email settings (so you can edit its copy) but chapter never fires
|
|
241
|
+
it, because the moment it would fire — approval — is a host route (approval is
|
|
242
|
+
out of scope, above). Send it from your own approve handler via `sendTemplated`;
|
|
243
|
+
the seed existing does not mean the built-ins send it.
|
|
204
244
|
|
|
205
245
|
### Verify from the types, not this file
|
|
206
246
|
|
package/dist/index.cjs
CHANGED
|
@@ -33,6 +33,7 @@ __export(index_exports, {
|
|
|
33
33
|
canTransition: () => canTransition,
|
|
34
34
|
canceledPatch: () => canceledPatch,
|
|
35
35
|
chapterDb: () => chapterDb,
|
|
36
|
+
clampArray: () => clampArray,
|
|
36
37
|
clerkInviteRequest: () => clerkInviteRequest,
|
|
37
38
|
clerkUserRequest: () => clerkUserRequest,
|
|
38
39
|
createChapterIntegration: () => createChapterIntegration,
|
|
@@ -45,11 +46,13 @@ __export(index_exports, {
|
|
|
45
46
|
findApplicationRef: () => findApplicationRef,
|
|
46
47
|
firstPaymentPatch: () => firstPaymentPatch,
|
|
47
48
|
getVaultSecret: () => getVaultSecret,
|
|
49
|
+
hasDisclaimerAck: () => hasDisclaimerAck,
|
|
48
50
|
introIdempotencyKey: () => introIdempotencyKey,
|
|
49
51
|
isAdminRole: () => isAdminRole,
|
|
50
52
|
isAlreadySent: () => isAlreadySent,
|
|
51
53
|
isReconcilable: () => isReconcilable,
|
|
52
54
|
isSlotAvailable: () => isSlotAvailable,
|
|
55
|
+
isValidEmail: () => isValidEmail,
|
|
53
56
|
joinConfig: () => joinConfig,
|
|
54
57
|
meetingCreateRow: () => meetingCreateRow,
|
|
55
58
|
meetingRescheduleUpdate: () => meetingRescheduleUpdate,
|
|
@@ -481,23 +484,47 @@ function resolveApplication(a) {
|
|
|
481
484
|
throw new Error(`defineChapter.application.${name}: must be an array of field-name strings`);
|
|
482
485
|
}
|
|
483
486
|
}
|
|
487
|
+
if (a?.profileFields !== void 0 && (!Array.isArray(a.profileFields) || !a.profileFields.every((f) => typeof f === "string" && f !== ""))) {
|
|
488
|
+
throw new Error("defineChapter.application.profileFields: must be an array of field-name strings");
|
|
489
|
+
}
|
|
490
|
+
if (a?.crmFields !== void 0 && (!Array.isArray(a.crmFields) || !a.crmFields.every((f) => typeof f === "string" && f !== ""))) {
|
|
491
|
+
throw new Error("defineChapter.application.crmFields: must be an array of field-name strings");
|
|
492
|
+
}
|
|
484
493
|
return {
|
|
485
494
|
required,
|
|
486
495
|
optional,
|
|
487
496
|
maxLen: a?.maxLen ?? {},
|
|
488
497
|
defaultMaxLen: a?.defaultMaxLen ?? 2e3,
|
|
489
|
-
bodyCap: a?.bodyCap ?? 32768
|
|
498
|
+
bodyCap: a?.bodyCap ?? 32768,
|
|
499
|
+
requireDisclaimerAck: a?.requireDisclaimerAck ?? false,
|
|
500
|
+
profileFields: a?.profileFields ?? null,
|
|
501
|
+
crmFields: a?.crmFields ?? [],
|
|
502
|
+
maxArrayLen: a?.maxArrayLen ?? 100,
|
|
503
|
+
validateEmail: a?.validateEmail ?? true
|
|
490
504
|
};
|
|
491
505
|
}
|
|
506
|
+
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
507
|
+
function isValidEmail(value) {
|
|
508
|
+
return typeof value === "string" && EMAIL_RE.test(value);
|
|
509
|
+
}
|
|
510
|
+
function clampArray(value, max) {
|
|
511
|
+
if (!Array.isArray(value)) return value;
|
|
512
|
+
return value.filter((x) => typeof x === "string" || typeof x === "number" || typeof x === "boolean").slice(0, max);
|
|
513
|
+
}
|
|
514
|
+
function hasDisclaimerAck(fields) {
|
|
515
|
+
return fields.disclaimerAck === true || fields.disclaimerAck === "true";
|
|
516
|
+
}
|
|
492
517
|
var IDENTITY_FIELDS = /* @__PURE__ */ new Set(["email", "firstName", "lastName"]);
|
|
493
518
|
function applicantProfile(chapter, fields) {
|
|
519
|
+
const app = chapter.application;
|
|
520
|
+
const allowed = (f) => app.profileFields === null || app.profileFields.includes(f);
|
|
494
521
|
const profile = {};
|
|
495
|
-
for (const f of [...
|
|
496
|
-
if (IDENTITY_FIELDS.has(f)) continue;
|
|
522
|
+
for (const f of [...app.required, ...app.optional]) {
|
|
523
|
+
if (IDENTITY_FIELDS.has(f) || !allowed(f)) continue;
|
|
497
524
|
const v = fields[f];
|
|
498
525
|
if (typeof v === "string" && v.trim() !== "") profile[f] = v.trim();
|
|
499
526
|
}
|
|
500
|
-
if (fields.focus !== void 0) profile.focus = fields.focus;
|
|
527
|
+
if (fields.focus !== void 0 && allowed("focus")) profile.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
501
528
|
return Object.keys(profile).length > 0 ? profile : void 0;
|
|
502
529
|
}
|
|
503
530
|
async function submitApplication(db, chapter, fields, opts) {
|
|
@@ -511,19 +538,26 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
511
538
|
const cap = app.maxLen[f] ?? app.defaultMaxLen;
|
|
512
539
|
if (typeof v === "string" && v.length > cap) return { ok: false, error: `${f} exceeds ${cap} characters` };
|
|
513
540
|
}
|
|
541
|
+
if (app.validateEmail && typeof fields.email === "string" && !isValidEmail(fields.email)) {
|
|
542
|
+
return { ok: false, error: "email must be a valid email address" };
|
|
543
|
+
}
|
|
544
|
+
const acked = hasDisclaimerAck(fields);
|
|
545
|
+
if (app.requireDisclaimerAck && !acked) {
|
|
546
|
+
return { ok: false, error: "disclaimerAck is required" };
|
|
547
|
+
}
|
|
514
548
|
const id2 = opts.newId();
|
|
515
549
|
const row = { id: id2, status: chapter.pipeline.initial, createdAt: opts.now };
|
|
516
550
|
for (const f of [...app.required, ...app.optional]) {
|
|
517
551
|
if (typeof fields[f] === "string") row[f] = fields[f].trim();
|
|
518
552
|
}
|
|
519
|
-
if (fields.focus !== void 0) row.focus = fields.focus;
|
|
553
|
+
if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
520
554
|
if (opts.groupId) row.groupId = opts.groupId;
|
|
521
|
-
if (
|
|
555
|
+
if (acked) row.disclaimerAckAt = opts.now;
|
|
522
556
|
const { duplicate } = await db.transact(
|
|
523
557
|
[{ t: "update", ns: "applications", id: id2, attrs: row }],
|
|
524
558
|
opts.submissionId ? { mutationId: `join:${opts.submissionId}` } : void 0
|
|
525
559
|
);
|
|
526
|
-
return { ok: true, id: id2, duplicate, status: chapter.pipeline.initial };
|
|
560
|
+
return { ok: true, id: id2, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
|
|
527
561
|
}
|
|
528
562
|
function joinConfig(group, paymentsReady2) {
|
|
529
563
|
return {
|
|
@@ -858,7 +892,7 @@ async function projectSharedRecord(deps, person) {
|
|
|
858
892
|
return upsertPerson(deps, { email: person.email, input: sharedPersonInput(person), mutationId: `share:${person.hubRecordId}` });
|
|
859
893
|
}
|
|
860
894
|
async function projectApplicant(deps, applicant) {
|
|
861
|
-
const
|
|
895
|
+
const base = sharedPersonInput({
|
|
862
896
|
email: applicant.email,
|
|
863
897
|
firstName: applicant.firstName,
|
|
864
898
|
lastName: applicant.lastName,
|
|
@@ -866,7 +900,14 @@ async function projectApplicant(deps, applicant) {
|
|
|
866
900
|
linkedin: applicant.linkedin,
|
|
867
901
|
hubRecordId: applicant.applicationId
|
|
868
902
|
});
|
|
869
|
-
|
|
903
|
+
const mutationId = `apply:${applicant.applicationId}`;
|
|
904
|
+
const extra = applicant.extra ?? {};
|
|
905
|
+
if (Object.keys(extra).length === 0) return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
906
|
+
try {
|
|
907
|
+
return await upsertPerson(deps, { email: applicant.email, input: { ...base, ...extra }, mutationId });
|
|
908
|
+
} catch {
|
|
909
|
+
return upsertPerson(deps, { email: applicant.email, input: base, mutationId });
|
|
910
|
+
}
|
|
870
911
|
}
|
|
871
912
|
|
|
872
913
|
// src/clerk.ts
|