@teamscala/service-boot 0.23.481 → 0.23.492

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.
Files changed (45) hide show
  1. package/package.json +1 -261
  2. package/src/agreement-gate/signing-session.ts +1 -1
  3. package/src/build-client-assets/styles-build.ts +11 -1
  4. package/src/mount-orpc-router.ts +1 -1
  5. package/src/register-page-server-client.ts +1 -15
  6. package/src/register-ws-routes.ts +1 -1
  7. package/src/fn-routers/agreement-certificate.ts +0 -224
  8. package/src/fn-routers/agreement-completion-email.ts +0 -114
  9. package/src/fn-routers/agreement-send-router.ts +0 -303
  10. package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
  11. package/src/fn-routers/agreement-signing-evidence.ts +0 -105
  12. package/src/fn-routers/assessment-report-data.test.ts +0 -404
  13. package/src/fn-routers/assessment-report-data.ts +0 -485
  14. package/src/fn-routers/assessment-report-email.ts +0 -256
  15. package/src/fn-routers/assessment-report-pair.test.ts +0 -136
  16. package/src/fn-routers/assessment-report-pair.ts +0 -155
  17. package/src/fn-routers/assessment-research-report-data.test.ts +0 -233
  18. package/src/fn-routers/assessment-research-report-data.ts +0 -424
  19. package/src/fn-routers/assessment-scoring.ts +0 -105
  20. package/src/fn-routers/auth-router.ts +0 -65
  21. package/src/fn-routers/chat-session-router-shared.ts +0 -122
  22. package/src/fn-routers/chat-session-router.ts +0 -356
  23. package/src/fn-routers/conversation-ownership.ts +0 -24
  24. package/src/fn-routers/email-defaults.ts +0 -28
  25. package/src/fn-routers/email-link-ttl.ts +0 -28
  26. package/src/fn-routers/index.ts +0 -76
  27. package/src/fn-routers/linkedin-identity.test.ts +0 -27
  28. package/src/fn-routers/linkedin-identity.ts +0 -36
  29. package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
  30. package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
  31. package/src/fn-routers/panel-data-router.ts +0 -104
  32. package/src/fn-routers/persist-chat-messages.test.ts +0 -86
  33. package/src/fn-routers/persist-chat-messages.ts +0 -98
  34. package/src/fn-routers/proposal-send-router.ts +0 -207
  35. package/src/fn-routers/public-agreement-router.ts +0 -407
  36. package/src/fn-routers/public-assessment-router.test.ts +0 -437
  37. package/src/fn-routers/public-assessment-router.ts +0 -268
  38. package/src/fn-routers/public-gpt-session-router.ts +0 -378
  39. package/src/fn-routers/resend-client.ts +0 -39
  40. package/src/fn-routers/sign-in-flow-router.ts +0 -196
  41. package/src/fn-routers/user-preferences-router.ts +0 -130
  42. package/src/fn-routers/work-items-router.ts +0 -342
  43. package/src/page-server-orpc/audit-revert-router.ts +0 -102
  44. package/src/page-server-orpc/puck-pages-router.ts +0 -170
  45. package/src/page-server-orpc/user-preferences-router.ts +0 -132
@@ -1,105 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Signing-evidence utilities for public agreement signing: the signer's
7
- * origin/IP extraction from request headers, the canonical JSON form, the
8
- * tamper-evident document hash, and the evidence-seal parser.
9
- *
10
- * Split out of fn-routers/public-agreement-router.ts (853 lines) —
11
- * byte-for-byte move except createHash → Bun.CryptoHasher
12
- * (bun-native-crypto guard; identical digest output).
13
- */
14
-
15
- import type { AgreementParty } from "@teamscala/document-signing/agreement-parties";
16
-
17
- /** The signer's own origin, from the request headers (sign[-dev].scala.business),
18
- * so Checkout returns them to the same site they signed on. */
19
- export function originFrom(headers: Headers): string {
20
- const host = headers.get("x-forwarded-host") ?? headers.get("host");
21
- const proto = headers.get("x-forwarded-proto") ?? "https";
22
- return host ? `${proto}://${host}` : "https://sign.scala.business";
23
- }
24
-
25
- /** The signer's real client IP at the moment of signing, for the audit trail.
26
- * Behind Cloudflare/Railway the original IP is the FIRST hop of
27
- * x-forwarded-for (the chain is client, proxy1, proxy2…); cf-connecting-ip and
28
- * x-real-ip are the per-proxy fallbacks. Returns null if none are present. */
29
- export function clientIp(headers: Headers): string | null {
30
- const fwd = headers.get("x-forwarded-for");
31
- const first = fwd?.split(",")[0]?.trim();
32
- return (
33
- first ||
34
- headers.get("cf-connecting-ip") ||
35
- headers.get("x-real-ip") ||
36
- null
37
- );
38
- }
39
-
40
- /** Deterministic JSON (recursively sorted object keys) so a hash over the same
41
- * logical content is reproducible regardless of property insertion order —
42
- * required for tamper-verification (re-hash later, compare). */
43
- function canonicalJson(value: unknown): string {
44
- return JSON.stringify(value, (_key, val) =>
45
- val && typeof val === "object" && !Array.isArray(val)
46
- ? Object.fromEntries(
47
- Object.entries(val as Record<string, unknown>).sort(([a], [b]) =>
48
- a.localeCompare(b),
49
- ),
50
- )
51
- : val,
52
- );
53
- }
54
-
55
- /** Tamper-evident seal: SHA-256 over the executed document's content + the
56
- * full signed roster. Storing this at completion lets the verification surface
57
- * re-hash the stored document later and prove nothing changed after signing
58
- * (the marketplace "tamper-evident" bar, short of a qualified-CA PAdES seal). */
59
- export function computeDocumentHash(
60
- documentContent: unknown,
61
- parties: AgreementParty[],
62
- ): string {
63
- return new Bun.CryptoHasher("sha256")
64
- .update(
65
- canonicalJson({
66
- document: documentContent ?? null,
67
- signatures: parties.map((p) => ({
68
- key: p.key,
69
- email: p.email,
70
- name: p.name,
71
- title: p.title,
72
- company: p.company,
73
- signature: p.signature,
74
- signedAt: p.signedAt,
75
- })),
76
- }),
77
- "utf8",
78
- )
79
- .digest("hex");
80
- }
81
-
82
- /** Parse the optional evidence seal (document hash) from signature_data, which
83
- * has the shape { parties, evidence? }. Returns null when no seal is present. */
84
- export function parseEvidence(sigData: unknown): {
85
- documentHash?: string;
86
- hashAlgo?: string;
87
- hashedAt?: string;
88
- } | null {
89
- if (typeof sigData !== "string") return null;
90
- const t = sigData.trim();
91
- if (!t.startsWith("{")) return null;
92
- try {
93
- const o = JSON.parse(t) as { evidence?: Record<string, unknown> };
94
- const e = o.evidence;
95
- if (!e || typeof e !== "object") return null;
96
- return {
97
- documentHash:
98
- typeof e.documentHash === "string" ? e.documentHash : undefined,
99
- hashAlgo: typeof e.hashAlgo === "string" ? e.hashAlgo : undefined,
100
- hashedAt: typeof e.hashedAt === "string" ? e.hashedAt : undefined,
101
- };
102
- } catch {
103
- return null;
104
- }
105
- }
@@ -1,404 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * report-data.test.ts — proves the report generation logic.
7
- *
8
- * The band table used to live in this file's subject as four hardcoded score
9
- * ranges with their advice strings. It now lives on `assessments.stage_bands`
10
- * (migration 560/561), so these tests assert the SELECTION and the FALLBACK
11
- * rather than any particular wording — copy changes are a row edit and must
12
- * never break the suite.
13
- */
14
-
15
- import { describe, expect, test } from "bun:test";
16
- import {
17
- computeReportData,
18
- generateNarrative,
19
- orderRoadmap,
20
- readStageBands,
21
- selectStageBand,
22
- pillarNote,
23
- numberItems,
24
- type StageBand,
25
- } from "./assessment-report-data.ts";
26
-
27
- /** Two bands plus a catch-all — the minimum shape that exercises selection. */
28
- const BANDS: StageBand[] = [
29
- {
30
- name: "Hidden",
31
- max: 20,
32
- tagline: "Early days.",
33
- executiveSummary: "You scored {{score}} — you are at the Hidden stage.",
34
- strongestAreas: ["Existing expertise"],
35
- priorityActions: [{ title: "Clarify positioning", description: "Define one clear position." }],
36
- strategicFocus: "Clarity before scale.",
37
- strengths: ["Expertise"],
38
- opportunities: ["LinkedIn"],
39
- twelveMonthTarget: "A clear authority position.",
40
- roadmap: {
41
- month2: { focus: "Publish", actions: ["Publish weekly"] },
42
- month1: { focus: "Position", actions: ["Define niche"] },
43
- },
44
- pillarNotes: { "be seen": "Your visibility is limited." },
45
- },
46
- { name: "Emerging", max: 40, tagline: "Momentum is building." },
47
- { name: "Influential", tagline: "Shaping the category." },
48
- ];
49
-
50
- const withBands = (score: number, categories: Record<string, number> = {}) =>
51
- computeReportData({
52
- input: {
53
- response: { total_score: score, category_scores: categories },
54
- assessment: { title: "The Launcha Authority Index", stage_bands: { bands: BANDS } },
55
- },
56
- });
57
-
58
- describe("computeReportData — shaping", () => {
59
- test("shapes total_score + category_scores into gauge + pillar bars", () => {
60
- // Resolver call shape: { input: { response, assessment } } — locks in that
61
- // computeReportData reads input.response.total_score (not the whole ctx).
62
- const r = computeReportData({
63
- input: {
64
- response: { total_score: 78, category_scores: { Strategy: 82, Execution: 70 } },
65
- assessment: { title: "Ops Maturity" },
66
- },
67
- });
68
- expect(r.totalScore).toBe(78);
69
- expect(r.reportScore).toEqual([{ label: "Score", value: 78 }]);
70
- expect(r.reportPillars).toEqual([
71
- { label: "Strategy", value: 82 },
72
- { label: "Execution", value: 70 },
73
- ]);
74
- });
75
-
76
- test("derives strongest/opportunity/gap for 3 pillars (Launcha shape)", () => {
77
- const r = withBands(48, { "Be Seen": 46, "Be Heard": 54, "Be Chosen": 42 });
78
- expect(r.strongest?.label).toBe("Be Heard");
79
- expect(r.opportunity?.label).toBe("Be Seen");
80
- expect(r.authorityGap?.label).toBe("Be Chosen");
81
- });
82
-
83
- test("rounds + clamps non-numeric / negative scores", () => {
84
- const r = computeReportData({
85
- input: {
86
- response: {
87
- total_score: -5,
88
- category_scores: { A: 12.4, B: "not-a-number" as unknown as number },
89
- },
90
- },
91
- });
92
- expect(r.totalScore).toBe(0);
93
- expect(r.reportPillars).toEqual([{ label: "A", value: 12 }]);
94
- });
95
- });
96
-
97
- describe("computeReportData — the stage model comes from the row", () => {
98
- test("selects the band the score falls in and returns its whole section set", () => {
99
- const r = withBands(15);
100
- expect(r.stage).toBe("Hidden");
101
- expect(r.stageSummary).toBe("Early days.");
102
- expect(r.strongestAreas).toEqual(["Existing expertise"]);
103
- expect(r.priorityActions).toEqual([
104
- { title: "Clarify positioning", description: "Define one clear position." },
105
- ]);
106
- expect(r.strategicFocus).toBe("Clarity before scale.");
107
- expect(r.strengths).toEqual(["Expertise"]);
108
- expect(r.opportunities).toEqual(["LinkedIn"]);
109
- expect(r.twelveMonthTarget).toBe("A clear authority position.");
110
- });
111
-
112
- test("a score on a band boundary stays in the lower band", () => {
113
- expect(withBands(20).stage).toBe("Hidden");
114
- expect(withBands(21).stage).toBe("Emerging");
115
- });
116
-
117
- test("the band without a max catches every score above the last bound", () => {
118
- expect(withBands(41).stage).toBe("Influential");
119
- expect(withBands(100).stage).toBe("Influential");
120
- });
121
-
122
- test("{{score}} in the authored summary is replaced with this person's score", () => {
123
- expect(withBands(15).narrative).toBe("You scored 15 — you are at the Hidden stage.");
124
- });
125
-
126
- test("roadmap renders in month order regardless of authored key order", () => {
127
- // BANDS authors month2 before month1 deliberately.
128
- expect(withBands(15).roadmap.map((p) => p.focus)).toEqual(["Position", "Publish"]);
129
- });
130
-
131
- test("an authored pillar note beats the generic column line", () => {
132
- const r = withBands(15, { "Be Seen": 20, "Be Heard": 15 });
133
- expect(r.strongest).toEqual({ label: "Be Seen", summary: "Your visibility is limited." });
134
- // No note authored for "Be Heard" — the column keeps its generic role line.
135
- expect(r.authorityGap?.summary).toContain("single biggest lever");
136
- });
137
- });
138
-
139
- describe("computeReportData — degrades rather than throws", () => {
140
- test("no stage_bands column: score still renders, stage sections come back empty", () => {
141
- const r = computeReportData({
142
- input: {
143
- response: { total_score: 48, category_scores: { A: 10 } },
144
- assessment: { title: "Ops Maturity" },
145
- },
146
- });
147
- expect(r.totalScore).toBe(48);
148
- expect(r.stage).toBe("");
149
- expect(r.narrative).toBe("You scored 48 on Ops Maturity.");
150
- expect(r.priorityActions).toEqual([]);
151
- expect(r.roadmap).toEqual([]);
152
- });
153
-
154
- test("malformed stage_bands is ignored, not fatal", () => {
155
- for (const stage_bands of [null, {}, { bands: "nope" }, { bands: [{ noName: true }] }]) {
156
- const r = computeReportData({
157
- input: { response: { total_score: 30 }, assessment: { stage_bands } },
158
- });
159
- expect(r.stage).toBe("");
160
- expect(r.totalScore).toBe(30);
161
- }
162
- });
163
-
164
- test("handles null response", () => {
165
- const r = computeReportData({ input: { response: null } });
166
- expect(r.totalScore).toBe(0);
167
- expect(r.reportScore).toEqual([{ label: "Score", value: 0 }]);
168
- expect(r.reportPillars).toEqual([]);
169
- expect(r.strongest).toBeNull();
170
- expect(r.opportunity).toBeNull();
171
- expect(r.authorityGap).toBeNull();
172
- });
173
- });
174
-
175
- describe("readStageBands", () => {
176
- test("returns [] for anything that is not a bands array of named objects", () => {
177
- expect(readStageBands(null)).toEqual([]);
178
- expect(readStageBands({ stage_bands: undefined })).toEqual([]);
179
- expect(readStageBands({ stage_bands: { bands: [{ max: 20 }] } })).toEqual([]);
180
- });
181
-
182
- test("keeps only the entries carrying a name", () => {
183
- const bands = readStageBands({
184
- stage_bands: { bands: [{ name: "Hidden", max: 20 }, { max: 40 }] },
185
- });
186
- expect(bands.map((b) => b.name)).toEqual(["Hidden"]);
187
- });
188
- });
189
-
190
- describe("selectStageBand", () => {
191
- test("returns null when there are no bands", () => {
192
- expect(selectStageBand([], 50)).toBeNull();
193
- });
194
-
195
- test("falls back to the last band when every band is bounded and none matched", () => {
196
- const bounded: StageBand[] = [
197
- { name: "Low", max: 10 },
198
- { name: "High", max: 20 },
199
- ];
200
- expect(selectStageBand(bounded, 99)?.name).toBe("High");
201
- });
202
- });
203
-
204
- describe("generateNarrative", () => {
205
- test("uses the authored summary when the band has one", () => {
206
- expect(generateNarrative(30, { name: "Emerging", executiveSummary: "At {{score}}." })).toBe(
207
- "At 30.",
208
- );
209
- });
210
-
211
- test("states only the score when no band and no title are available", () => {
212
- expect(generateNarrative(30, null)).toBe("You scored 30 on your assessment.");
213
- });
214
- });
215
-
216
- describe("orderRoadmap", () => {
217
- test("empty for a missing or non-object roadmap", () => {
218
- expect(orderRoadmap(undefined)).toEqual([]);
219
- });
220
-
221
- test("drops a phase with no actions array", () => {
222
- const phases = orderRoadmap({
223
- month1: { focus: "Position", actions: ["A"] },
224
- month2: { focus: "Broken" } as never,
225
- });
226
- expect(phases.map((p) => p.focus)).toEqual(["Position"]);
227
- });
228
- });
229
-
230
- describe("pillar cards", () => {
231
- const assessment = {
232
- title: "The Launcha Authority Index",
233
- categories: ["Be Seen", "Be Heard", "Be Chosen"],
234
- category_descriptions: {
235
- "Be Seen": { subtitle: "Visibility + Positioning", color: "#2563eb" },
236
- "Be Heard": { subtitle: "Credibility + Trust", color: "#FF4500" },
237
- "Be Chosen": { subtitle: "Leads + Conversion", color: "#16a34a" },
238
- },
239
- stage_bands: {
240
- bands: [
241
- {
242
- name: "Hidden",
243
- pillarNotes: { seen: "Your visibility is limited.", "be chosen": "No conversion system." },
244
- },
245
- ],
246
- },
247
- };
248
-
249
- test("holds the authored order, not score order", () => {
250
- const r = computeReportData({
251
- input: {
252
- response: { total_score: 15, category_scores: { "Be Chosen": 90, "Be Seen": 10 } },
253
- assessment,
254
- },
255
- });
256
- expect(r.pillarCards.map((c) => c.label)).toEqual(["Be Seen", "Be Heard", "Be Chosen"]);
257
- // reportPillars stays score-sorted — the two serve different sections.
258
- expect(r.reportPillars[0]?.label).toBe("Be Chosen");
259
- });
260
-
261
- test("carries subtitle, colour and this person's score", () => {
262
- const r = computeReportData({
263
- input: { response: { total_score: 15, category_scores: { "Be Seen": 20 } }, assessment },
264
- });
265
- const seen = r.pillarCards[0];
266
- expect(seen?.subtitle).toBe("Visibility + Positioning");
267
- expect(seen?.color).toBe("#2563eb");
268
- expect(seen?.value).toBe(20);
269
- expect(seen?.max).toBe(100);
270
- });
271
-
272
- test("a pillar the person did not score reads zero rather than vanishing", () => {
273
- const r = computeReportData({
274
- input: { response: { total_score: 15, category_scores: {} }, assessment },
275
- });
276
- expect(r.pillarCards.map((c) => c.value)).toEqual([0, 0, 0]);
277
- });
278
-
279
- test("the chart bars take the same colours as the cards", () => {
280
- const r = computeReportData({
281
- input: {
282
- response: { total_score: 15, category_scores: { "Be Seen": 20, "Be Heard": 30 } },
283
- assessment,
284
- },
285
- });
286
- const byLabel = Object.fromEntries(r.reportPillars.map((p) => [p.label, p.color]));
287
- expect(byLabel["Be Seen"]).toBe("#2563eb");
288
- expect(byLabel["Be Heard"]).toBe("#FF4500");
289
- });
290
- });
291
-
292
- describe("pillarNote", () => {
293
- const band = {
294
- name: "Hidden",
295
- pillarNotes: { seen: "Short key.", "be chosen": "Full label key." },
296
- };
297
-
298
- test("matches a note keyed by the distinctive word alone", () => {
299
- // The designs key notes as seen/heard/chosen while the assessment names
300
- // pillars "Be Seen" — a plain lowercase lookup missed every one.
301
- expect(pillarNote(band, "Be Seen")).toBe("Short key.");
302
- });
303
-
304
- test("matches a note keyed by the whole label", () => {
305
- expect(pillarNote(band, "Be Chosen")).toBe("Full label key.");
306
- });
307
-
308
- test("empty when the band authors no note for that pillar", () => {
309
- expect(pillarNote(band, "Be Heard")).toBe("");
310
- expect(pillarNote(null, "Be Seen")).toBe("");
311
- });
312
- });
313
-
314
- describe("numberItems", () => {
315
- test("pairs each item with its 1-based ordinal", () => {
316
- expect(numberItems(["a", "b"])).toEqual([
317
- { index: "1", text: "a" },
318
- { index: "2", text: "b" },
319
- ]);
320
- });
321
-
322
- test("an absent item carries no number, so a fixed slot renders nothing", () => {
323
- // The stray-numeral defect: six slots, four authored items. Slots five and
324
- // six resolve to undefined for BOTH index and text rather than printing a
325
- // bare "5" beside empty space to a client.
326
- const list = numberItems(["a", "b", "c", "d"]);
327
- expect(list.length).toBe(4);
328
- expect(list[4]).toBeUndefined();
329
- });
330
-
331
- test("blank entries are dropped and do not consume an ordinal", () => {
332
- expect(numberItems(["a", " ", "b"])).toEqual([
333
- { index: "1", text: "a" },
334
- { index: "2", text: "b" },
335
- ]);
336
- });
337
-
338
- test("empty for a missing list", () => {
339
- expect(numberItems(undefined)).toEqual([]);
340
- });
341
- });
342
-
343
- describe("Anna's page-5 block", () => {
344
- test("empty when the stage authors no copy, so the page can hide the section", () => {
345
- const r = computeReportData({
346
- input: { response: { total_score: 48 }, assessment: { stage_bands: { bands: [{ name: "X" }] } } },
347
- });
348
- expect(r.recommendedTier).toBe("");
349
- expect(r.finalWord).toBe("");
350
- });
351
-
352
- test("carries the authored tier and closing word when a stage supplies them", () => {
353
- const r = computeReportData({
354
- input: {
355
- response: { total_score: 48 },
356
- assessment: {
357
- stage_bands: {
358
- bands: [
359
- {
360
- name: "Recognised",
361
- recommendedTier: "Authority Accelerator",
362
- tierSummary: "A 90-day programme.",
363
- whyItFits: "You have the foundations.",
364
- whatItSolves: "Inconsistent conversion.",
365
- implementationFocus: "Systems before scale.",
366
- expectedOutcome: "Predictable pipeline.",
367
- finalWord: "Authority compounds.",
368
- },
369
- ],
370
- },
371
- },
372
- },
373
- });
374
- expect(r.recommendedTier).toBe("Authority Accelerator");
375
- expect(r.whyItFits).toBe("You have the foundations.");
376
- expect(r.finalWord).toBe("Authority compounds.");
377
- });
378
- });
379
-
380
- describe("every remaining template element is a stage row, not a build task", () => {
381
- const ALL = [
382
- "whoYouHelp", "knownFor", "northStar", "contentRhythm",
383
- "priorityFocus", "priorityFocusSummary", "recommendedTier", "finalWord",
384
- ] as const;
385
-
386
- test("all absent as empty strings when unauthored, so sections stay hidden", () => {
387
- const r = computeReportData({
388
- input: { response: { total_score: 48 }, assessment: { stage_bands: { bands: [{ name: "X" }] } } },
389
- });
390
- for (const key of ALL) expect(r[key]).toBe("");
391
- expect(r.insights).toEqual([]);
392
- expect(r.contentThemes).toEqual([]);
393
- });
394
-
395
- test("each surfaces verbatim once a stage authors it", () => {
396
- const band: Record<string, unknown> = { name: "Recognised", insights: ["a", "b"] };
397
- for (const key of ALL) band[key] = `copy:${key}`;
398
- const r = computeReportData({
399
- input: { response: { total_score: 48 }, assessment: { stage_bands: { bands: [band] } } },
400
- });
401
- for (const key of ALL) expect(r[key]).toBe(`copy:${key}`);
402
- expect(r.insights).toEqual(["a", "b"]);
403
- });
404
- });