@teamscala/service-boot 0.23.284

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 (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. package/src/ws-boot-self-test.ts +114 -0
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Unit proof of the /roadmap fix's core decision (independent of the publish /
7
+ * deploy — verify-before-done). Exercises the pure floor decision + the floor
8
+ * render for every case: dead link (no row → 404), empty page (zero blocks →
9
+ * 200), normal page (renders), resource_type route (handled elsewhere), and
10
+ * region-slot shell (no page source — untouched).
11
+ */
12
+ import { test, expect } from "bun:test";
13
+ import {
14
+ decideContentPageFloor,
15
+ pageBodyIsEmpty,
16
+ } from "./content-page-floor.ts";
17
+ import {
18
+ PrimaryResourceMissingError,
19
+ buildResourceNotFoundResponse,
20
+ } from "./resource-not-found.ts";
21
+
22
+ test("decideContentPageFloor: no page row → dead-link 404 floor (the /roadmap case)", () => {
23
+ const e = decideContentPageFloor({
24
+ hasResourceType: false,
25
+ hasPageContentSource: true,
26
+ page: null,
27
+ });
28
+ expect(e).toBeInstanceOf(PrimaryResourceMissingError);
29
+ expect(e!.resourceType).toBe("page");
30
+ expect(e!.httpStatus).toBe(404);
31
+ expect(e!.empty).toBe(false);
32
+ });
33
+
34
+ test("decideContentPageFloor: page row with zero blocks → empty-page 200 floor", () => {
35
+ const e = decideContentPageFloor({
36
+ hasResourceType: false,
37
+ hasPageContentSource: true,
38
+ page: { id: "p1", puck_data: { content: [], root: { props: {} } } },
39
+ });
40
+ expect(e).toBeInstanceOf(PrimaryResourceMissingError);
41
+ expect(e!.httpStatus).toBe(200);
42
+ expect(e!.empty).toBe(true);
43
+ });
44
+
45
+ test("decideContentPageFloor: page row with blocks → null (renders normally)", () => {
46
+ const e = decideContentPageFloor({
47
+ hasResourceType: false,
48
+ hasPageContentSource: true,
49
+ page: { id: "p1", puck_data: { content: [{ type: "Text", props: {} }] } },
50
+ });
51
+ expect(e).toBeNull();
52
+ });
53
+
54
+ test("decideContentPageFloor: resource_type route → null (handled by the resource-type check)", () => {
55
+ const e = decideContentPageFloor({
56
+ hasResourceType: true,
57
+ hasPageContentSource: true,
58
+ page: null,
59
+ });
60
+ expect(e).toBeNull();
61
+ });
62
+
63
+ test("decideContentPageFloor: no page source (region-slot shell) → null (untouched)", () => {
64
+ const e = decideContentPageFloor({
65
+ hasResourceType: false,
66
+ hasPageContentSource: false,
67
+ page: null,
68
+ });
69
+ expect(e).toBeNull();
70
+ });
71
+
72
+ test("pageBodyIsEmpty: absent page → false (not the empty-page case)", () => {
73
+ expect(pageBodyIsEmpty(null)).toBe(false);
74
+ expect(pageBodyIsEmpty(undefined)).toBe(false);
75
+ });
76
+
77
+ test("pageBodyIsEmpty: row with no puck_data → empty", () => {
78
+ expect(pageBodyIsEmpty({ id: "p1" })).toBe(true);
79
+ });
80
+
81
+ test("pageBodyIsEmpty: row with empty content array → empty", () => {
82
+ expect(pageBodyIsEmpty({ id: "p1", puck_data: { content: [] } })).toBe(true);
83
+ });
84
+
85
+ test("pageBodyIsEmpty: row with content blocks → not empty", () => {
86
+ expect(
87
+ pageBodyIsEmpty({ id: "p1", puck_data: { content: [{ type: "Hero" }] } }),
88
+ ).toBe(false);
89
+ });
90
+
91
+ test("pageBodyIsEmpty: row with string puck_data (raw) — empty", () => {
92
+ expect(pageBodyIsEmpty({ id: "p1", puck_data: '{"content":[]}' })).toBe(true);
93
+ });
94
+
95
+ test("pageBodyIsEmpty: row with string puck_data (raw) — non-empty", () => {
96
+ expect(
97
+ pageBodyIsEmpty({ id: "p1", puck_data: '{"content":[{"type":"Text"}]}' }),
98
+ ).toBe(false);
99
+ });
100
+
101
+ test("floor render: dead link → 404 with 'Take me home' + doesn't-exist copy", async () => {
102
+ const r = buildResourceNotFoundResponse("page", null, 404, { homeUrl: "/" });
103
+ expect(r.status).toBe(404);
104
+ const html = await r.text();
105
+ expect(html).toContain("Take me home");
106
+ expect(html).toContain("doesn");
107
+ expect(html).toContain('href="/"');
108
+ expect(html).toContain("noindex");
109
+ });
110
+
111
+ test("floor render: empty page → 200 with 'This page is empty' + Take me home", async () => {
112
+ const r = buildResourceNotFoundResponse("page", null, 200, {
113
+ homeUrl: "/",
114
+ empty: true,
115
+ });
116
+ expect(r.status).toBe(200);
117
+ const html = await r.text();
118
+ expect(html).toContain("This page is empty");
119
+ expect(html).toContain("Take me home");
120
+ });
121
+
122
+ test("floor render: bare-root landing → no Take-me-home (it IS home)", async () => {
123
+ const r = buildResourceNotFoundResponse("page", null, 200);
124
+ const html = await r.text();
125
+ expect(html).not.toContain("Take me home");
126
+ expect(html).toContain("no page at this link");
127
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Pure decision: does a CONTENT route (no resource_type) whose loader resolved
7
+ * a `page` body source warrant the branded not-found floor? Extracted from
8
+ * register-registry-page-route so the rule is unit-testable independent of the
9
+ * Hono/DB/graph machinery that wraps it (the fix's correctness does not depend
10
+ * on the publish — verify-before-done).
11
+ *
12
+ * Three outcomes:
13
+ * - no page row (deleted/unmatched path, e.g. /roadmap) → dead-link 404 floor
14
+ * - page row exists but zero content blocks → empty-page 200 floor
15
+ * - otherwise → null (render normally)
16
+ *
17
+ * The "Vision Toolbar" leak this closes: a content catchall synthesized an empty
18
+ * page for an unmatched path and served a cacheable 200; resource-not-found-is-
19
+ * the-root-floor only fired for resource_type routes, so dead content links fell
20
+ * through. See resource-not-found.ts for the floor + operator ruling 2026-07-21.
21
+ */
22
+ import { PrimaryResourceMissingError } from "./resource-not-found.ts";
23
+
24
+ /**
25
+ * A page ROW exists but its body has zero content blocks — a content gap, not a
26
+ * dead link. True only when the row is present yet `puck_data` is absent or its
27
+ * `content` array is empty. Handles string (raw) and object (normalized) puck_data.
28
+ */
29
+ export function pageBodyIsEmpty(page: unknown): boolean {
30
+ if (!page || typeof page !== "object") return false;
31
+ const obj = page as Record<string, unknown>;
32
+ const raw = obj.puck_data ?? obj.puckData;
33
+ if (raw == null) return true; // row exists, no puck_data → empty
34
+ let data: { content?: unknown } | null;
35
+ if (typeof raw === "string") {
36
+ try {
37
+ data = JSON.parse(raw) as { content?: unknown };
38
+ } catch {
39
+ return false;
40
+ }
41
+ } else {
42
+ data = raw as { content?: unknown };
43
+ }
44
+ return !Array.isArray(data?.content) || (data.content as unknown[]).length === 0;
45
+ }
46
+
47
+ /**
48
+ * Decide whether a content route should fall to the branded not-found floor.
49
+ * Returns the error to throw (mapped to the floor by bind-page-handler), or null
50
+ * to render normally. `hasResourceType` routes are handled by the existing
51
+ * resource_type missing check (caller-side) — they return null here.
52
+ */
53
+ export function decideContentPageFloor(opts: {
54
+ hasResourceType: boolean;
55
+ hasPageContentSource: boolean;
56
+ page: unknown;
57
+ }): PrimaryResourceMissingError | null {
58
+ // Only content routes (no resource_type) with a `page` body source. Region-
59
+ // slot shells (no `page` source) and resource_type routes are unaffected.
60
+ if (opts.hasResourceType || !opts.hasPageContentSource) return null;
61
+ if (opts.page == null) {
62
+ // No page row → dead link (deleted/unmatched path).
63
+ return new PrimaryResourceMissingError("page", 404);
64
+ }
65
+ if (pageBodyIsEmpty(opts.page)) {
66
+ // Row exists, zero blocks → content gap, not a dead link.
67
+ return new PrimaryResourceMissingError("page", 200, { empty: true });
68
+ }
69
+ return null;
70
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Surgical block discovery: at boot, queries every block_schema row in the
7
+ * registry whose componentPath starts with one of the service's declared
8
+ * block_packages, then dynamic-imports each one. Per-block try/catch — one
9
+ * dead componentPath logs and skips, the rest still register and the page
10
+ * renders the surviving blocks. This replaces the static
11
+ * `.scala-install/page-manifest.ts` codegen output, which had the failure
12
+ * mode of a single dead block_schema row crashing the whole manifest at
13
+ * compile time.
14
+ *
15
+ * Empty block_packages is NOT fatal here: it falls back to scanning all
16
+ * active block_schema (uninstalled packages skip via the per-block try/catch),
17
+ * so a service whose row is unpopulated but whose blocks are installed still
18
+ * renders. The real "booted with zero blocks" fail-closed is at the call site
19
+ * (loaded.length === 0 → BootFatalError). A DB-unreachable error still throws —
20
+ * no fallback to a package-side puckBlocks scan, which masked blockless boots.
21
+ */
22
+
23
+ import { existsSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { BlockSchemaConfigSchema } from "@teamscala/db-validation/registry-schemas/block-schema";
26
+ import { getServerClient } from "@teamscala/orpc/server-client-registry";
27
+ import * as v from "valibot";
28
+ import type { RendererComponentEntry } from "./types.ts";
29
+
30
+ export interface DiscoveredBlocks {
31
+ components: Record<string, RendererComponentEntry>;
32
+ loaded: string[];
33
+ skipped: string[];
34
+ }
35
+
36
+ interface BlockSchemaConfig {
37
+ componentPath?: string;
38
+ componentExport?: string;
39
+ fields?: Record<string, unknown>;
40
+ defaults?: Record<string, unknown>;
41
+ }
42
+
43
+ interface RegistryEntriesModel {
44
+ findMany: (args: {
45
+ where: Record<string, unknown>;
46
+ select: Record<string, unknown>;
47
+ orderBy: Record<string, unknown>;
48
+ }) => Promise<Array<{ slug: string; config: unknown }>>;
49
+ }
50
+
51
+ interface PrismaWithRegistry {
52
+ registry_entries: RegistryEntriesModel;
53
+ }
54
+
55
+ export async function discoverBlocks(
56
+ blockPackages: string[],
57
+ ): Promise<DiscoveredBlocks> {
58
+ // Scope discovery to what is ACTUALLY INSTALLED, never the authored
59
+ // `block_packages` list. That list was an authored hint that silently
60
+ // DRIFTED: a page-used package missing from it caused its blocks to be
61
+ // skipped and rendered BLANK with only a warning (scala-sign, 2026-06-04 —
62
+ // SignIn / SignatureField / ScrollProgress all blanked because sign-in-block
63
+ // had fallen out of the list). The filesystem is the source of truth: a page
64
+ // can only compose blocks from packages installed as the service's
65
+ // dependencies, so "installed @teamgbg package" is always a correct superset
66
+ // of "packages the pages need". A block_schema whose package is installed is
67
+ // imported; an uninstalled one is skipped (its import would fail anyway).
68
+ // This makes an authored-list mistake STRUCTURALLY UNABLE to blank a
69
+ // page-used block. The install dir is also narrower than "all block_schema",
70
+ // so the 2026-05-29 empty-list-scans-everything incident cannot recur either.
71
+ const serviceModules = join(process.cwd(), "node_modules");
72
+ const installedCache = new Map<string, boolean>();
73
+ const isInstalled = (pkg: string): boolean => {
74
+ const cached = installedCache.get(pkg);
75
+ if (cached !== undefined) return cached;
76
+ const ok = existsSync(join(serviceModules, ...pkg.split("/")));
77
+ installedCache.set(pkg, ok);
78
+ return ok;
79
+ };
80
+ // `blockPackages` is retained only to surface authored-vs-installed drift to
81
+ // the operator; it no longer gates discovery, so it can never blank a block.
82
+ if (blockPackages.length > 0) {
83
+ const stale = blockPackages.filter((p) => !isInstalled(p));
84
+ if (stale.length > 0) {
85
+ console.warn(
86
+ `[discover-blocks] block_packages lists ${stale.length} package(s) that are not installed (ignored): ${stale.join(", ")}`,
87
+ );
88
+ }
89
+ }
90
+
91
+ const components: Record<string, RendererComponentEntry> = {};
92
+ const loaded: string[] = [];
93
+ const skipped: string[] = [];
94
+
95
+ const prisma = (await getServerClient()) as unknown as PrismaWithRegistry;
96
+ const rows: Array<{ slug: string; config: unknown }> =
97
+ await prisma.registry_entries.findMany({
98
+ where: { type: "block_schema", is_active: true },
99
+ select: { slug: true, config: true },
100
+ orderBy: { slug: "asc" },
101
+ });
102
+
103
+ // Parallel block discovery (no-serial-per-unit-work-on-hot-paths: boot is a
104
+ // recurring cycle via the package-poller's restarts). Previously each block's
105
+ // component was imported SERIALLY (sum of ~44 import times) under a 3s
106
+ // timeout — heavy blocks (e.g. AiChat with Plate) exceeded 3s, were SKIPPED
107
+ // at boot, and paid their full import at first render (cold-start cost).
108
+ // Bounded-parallel (chunks of 8) collapses boot to ceil(N/8) waves of the
109
+ // max import time, and the raised 8s timeout lets heavy blocks import at
110
+ // boot (shifted off the first render). `skipped`/`loaded`/`components`
111
+ // mutations are safe concurrently (single-threaded; atomic at await points).
112
+ const loadBlock = async (row: { slug: string; config: unknown }) => {
113
+ const rawConfig = parseConfig(row.config);
114
+ const rawComponentPath = getStringProperty(rawConfig, "componentPath");
115
+ const rawPackage = rawComponentPath
116
+ ? rawComponentPath.split("/").slice(0, 2).join("/")
117
+ : null;
118
+ if (!rawPackage || !isInstalled(rawPackage)) {
119
+ skipped.push(
120
+ `${row.slug} (${rawPackage ?? "missing componentPath"} not installed)`,
121
+ );
122
+ return;
123
+ }
124
+
125
+ let cfg: BlockSchemaConfig;
126
+ try {
127
+ cfg = v.parse(BlockSchemaConfigSchema, rawConfig) as BlockSchemaConfig;
128
+ } catch (err) {
129
+ skipped.push(
130
+ `${row.slug} (invalid block_schema config: ${formatValidationError(err)})`,
131
+ );
132
+ return;
133
+ }
134
+
135
+ const componentPath = cfg.componentPath;
136
+ const componentExport = cfg.componentExport;
137
+ if (!componentPath || !componentExport) {
138
+ skipped.push(`${row.slug} (missing componentPath/componentExport)`);
139
+ return;
140
+ }
141
+ // Scope to installed packages: each block_schema's componentPath must
142
+ // start with a package present in the service's node_modules.
143
+ const pkgPrefix = componentPath.split("/").slice(0, 2).join("/");
144
+ if (!isInstalled(pkgPrefix)) {
145
+ skipped.push(`${row.slug} (${pkgPrefix} not installed)`);
146
+ return;
147
+ }
148
+ try {
149
+ const TIMEOUT_MS = 8_000;
150
+ const mod = (await Promise.race([
151
+ import(componentPath),
152
+ new Promise<never>((_, rej) =>
153
+ setTimeout(
154
+ () => rej(new Error(`import timeout >${TIMEOUT_MS}ms`)),
155
+ TIMEOUT_MS,
156
+ ),
157
+ ),
158
+ ])) as Record<string, unknown>;
159
+ const component = mod[componentExport] ?? mod.default;
160
+ if (typeof component !== "function") {
161
+ skipped.push(
162
+ `${row.slug} (${componentExport} not a component in ${componentPath})`,
163
+ );
164
+ return;
165
+ }
166
+ components[row.slug] = {
167
+ render: component as RendererComponentEntry["render"],
168
+ fields: (cfg.fields ?? {}) as never,
169
+ // block_schema.defaults → the SSR component entry, so buildPuckConfig
170
+ // merges them UNDER authored props at render (blocks-fully-programmable:
171
+ // DB-declared defaults reach render, never a component parameter
172
+ // constant). Puck's RSC <Render> ignores defaultProps itself.
173
+ defaultProps: (cfg.defaults ?? {}) as never,
174
+ };
175
+ loaded.push(row.slug);
176
+ } catch (err) {
177
+ skipped.push(`${row.slug} (${(err as Error).message})`);
178
+ }
179
+ };
180
+ // Pre-filter to rows the service can actually import, so expected-absent
181
+ // blocks don't pollute the boot log as "skipped". A service (e.g.
182
+ // scala-projects) installs only a subset of the platform's ~45 blocks; the
183
+ // rest are expected-absent (their imports would fail anyway), not warnings.
184
+ // The filesystem remains the source of truth (block-discovery-scopes-by-
185
+ // install): an uninstalled block is simply not considered. Rows with a
186
+ // missing componentPath are kept so `loadBlock` still flags them (real data
187
+ // signal); `skipped` thus catches only genuine failures (installed package
188
+ // but import/config/export failed) and the call-site WARN becomes signal.
189
+ const candidateRows = rows.filter((row) => {
190
+ const cp = getStringProperty(parseConfig(row.config), "componentPath");
191
+ if (!cp) return true;
192
+ const pkg = cp.split("/").slice(0, 2).join("/");
193
+ return isInstalled(pkg);
194
+ });
195
+ // All blocks concurrent (module imports are in-process I/O + V8 compile — no
196
+ // fork tax, so bounded concurrency isn't needed here; the chunk-of-8 was
197
+ // needlessly serializing 44 imports into 6 waves when 1 wave suffices).
198
+ await Promise.all(candidateRows.map(loadBlock));
199
+
200
+ return { components, loaded, skipped };
201
+ }
202
+
203
+ function parseConfig(config: unknown): unknown {
204
+ if (typeof config !== "string") return config;
205
+ try {
206
+ return JSON.parse(config) as unknown;
207
+ } catch {
208
+ // Malformed JSON string → absent signal, never the raw string: the caller's
209
+ // valibot parse then fails and the block is skipped (fail-closed), rather
210
+ // than a non-object masquerading as config (protective-layers-fail-explicitly).
211
+ return null;
212
+ }
213
+ }
214
+
215
+ function getStringProperty(value: unknown, key: string): string | null {
216
+ if (!value || typeof value !== "object") return null;
217
+ const maybe = (value as Record<string, unknown>)[key];
218
+ return typeof maybe === "string" ? maybe : null;
219
+ }
220
+
221
+ function formatValidationError(err: unknown): string {
222
+ if (err && typeof err === "object" && "issues" in err) {
223
+ const issues = (err as { issues?: Array<{ message?: string }> }).issues;
224
+ const first = Array.isArray(issues) ? issues[0]?.message : undefined;
225
+ if (first) return first;
226
+ }
227
+ return err instanceof Error ? err.message : String(err);
228
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Server-side roster expansion — the executor that handles "render N of block X
7
+ * per data row" so the page never needs a domain-specific panel/list block. A
8
+ * block authored with `props.dataRepeat: "parties"` is a TEMPLATE; before SSR
9
+ * this clones it once per roster item into its parent slot (top-level content or
10
+ * any Container's `content` slot), then drops the marker. Each clone:
11
+ * - sets `role` + `dataKey` to the item's key, so a prop-driven block (the
12
+ * SignatureField cell) finds its own party with no per-clone wiring;
13
+ * - substitutes `{{name}}` / `{{role}}` / `{{company}}` / `{{date}}` /
14
+ * `{{status}}` placeholders in any string prop (so a recipient row is plain
15
+ * `Container` + `Text` primitives — the copy lives on the page, not here).
16
+ * `{{status}}` resolves from the template's own `signedLabel` /
17
+ * `awaitingLabel` props (DB-driven copy), never hardcoded.
18
+ * Generic over any roster; the agreement page passes its parties.
19
+ */
20
+
21
+ export interface RosterItem {
22
+ key: string;
23
+ role: string;
24
+ name: string;
25
+ title: string | null;
26
+ company: string | null;
27
+ signedAt: string | null;
28
+ signature: string | null;
29
+ }
30
+
31
+ interface Block {
32
+ type?: string;
33
+ props?: Record<string, unknown>;
34
+ }
35
+
36
+ function formatDate(iso: string | null): string {
37
+ if (!iso) return "";
38
+ try {
39
+ return new Date(iso).toLocaleDateString("en-AU", {
40
+ day: "numeric",
41
+ month: "long",
42
+ year: "numeric",
43
+ });
44
+ } catch {
45
+ return "";
46
+ }
47
+ }
48
+
49
+ /** Resolve a `{{field}}` token for one roster item. */
50
+ function tokenValue(
51
+ field: string,
52
+ item: RosterItem,
53
+ labels: { signed: string; awaiting: string },
54
+ ): string {
55
+ switch (field) {
56
+ case "name":
57
+ return item.name;
58
+ case "role":
59
+ return item.role;
60
+ case "company":
61
+ return item.company ?? "";
62
+ case "title":
63
+ return item.title ?? "";
64
+ case "date":
65
+ return formatDate(item.signedAt);
66
+ case "status":
67
+ return item.signedAt ? labels.signed : labels.awaiting;
68
+ default:
69
+ return "";
70
+ }
71
+ }
72
+
73
+ /** Replace every `{{field}}` token in a string with the item's value. */
74
+ function substitute(
75
+ value: string,
76
+ item: RosterItem,
77
+ labels: { signed: string; awaiting: string },
78
+ ): string {
79
+ return value.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, f) =>
80
+ tokenValue(String(f), item, labels),
81
+ );
82
+ }
83
+
84
+ /** The `content` slot arrays a block exposes (Container.props.content, …). */
85
+ function slotArrays(block: Block): unknown[][] {
86
+ const out: unknown[][] = [];
87
+ const props = block.props;
88
+ if (!props) return out;
89
+ for (const v of Object.values(props)) {
90
+ if (Array.isArray(v) && v.every((e) => e && typeof e === "object")) {
91
+ out.push(v as unknown[]);
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /** Deep-clone a template block, binding it to one roster item. */
98
+ function cloneForItem(template: Block, item: RosterItem): Block {
99
+ const labels = {
100
+ signed: String(template.props?.signedLabel ?? ""),
101
+ awaiting: String(template.props?.awaitingLabel ?? ""),
102
+ };
103
+ const walk = (node: unknown): unknown => {
104
+ if (typeof node === "string") return substitute(node, item, labels);
105
+ if (Array.isArray(node)) return node.map(walk);
106
+ if (node && typeof node === "object") {
107
+ const out: Record<string, unknown> = {};
108
+ for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
109
+ if (k === "dataRepeat" || k === "signedLabel" || k === "awaitingLabel")
110
+ continue;
111
+ out[k] = walk(v);
112
+ }
113
+ return out;
114
+ }
115
+ return node;
116
+ };
117
+ const clone = walk(template) as Block;
118
+ clone.props = {
119
+ ...(clone.props ?? {}),
120
+ // fresh id per clone so React keys + hydration stay stable
121
+ id: `${String(template.props?.id ?? "tmpl")}-${item.key}`,
122
+ role: item.key,
123
+ dataKey: item.key,
124
+ };
125
+ return clone;
126
+ }
127
+
128
+ /** Is this block a per-party template to expand? */
129
+ function isPartyTemplate(block: Block): boolean {
130
+ // Generic marker (any block that declares `dataRepeat`)…
131
+ if (block?.props?.dataRepeat === "parties") return true;
132
+ // …plus the natural convention for the signature cell: a SignatureField with
133
+ // no assigned role IS "all parties". This needs no extra prop, so it survives
134
+ // Puck's slot serialization (which keeps only component-declared props).
135
+ if (block?.type === "SignatureField") {
136
+ const role = block.props?.role;
137
+ if (!(typeof role === "string" && role.trim())) return true;
138
+ }
139
+ return false;
140
+ }
141
+
142
+ /** Expand every per-party template in a content array, recursing into slots. */
143
+ function expandArray(content: unknown[], parties: RosterItem[]): unknown[] {
144
+ const out: unknown[] = [];
145
+ for (const raw of content) {
146
+ const block = raw as Block;
147
+ // Recurse into this block's own slots first.
148
+ for (const slot of slotArrays(block)) {
149
+ const expanded = expandArray(slot, parties);
150
+ slot.length = 0;
151
+ slot.push(...expanded);
152
+ }
153
+ if (isPartyTemplate(block)) {
154
+ for (const party of parties) out.push(cloneForItem(block, party));
155
+ } else {
156
+ out.push(block);
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+
162
+ /** Agreement-level tokens (one substitution, not per-party). */
163
+ export interface RosterMeta {
164
+ [token: string]: string;
165
+ }
166
+
167
+ /** Substitute `{{agreement.token}}` placeholders in every string prop, in place. */
168
+ function substituteMeta(node: unknown, meta: RosterMeta): unknown {
169
+ if (typeof node === "string") {
170
+ return node.replace(/\{\{\s*agreement\.(\w+)\s*\}\}/g, (_m, t) =>
171
+ meta[String(t)] != null ? meta[String(t)] : "",
172
+ );
173
+ }
174
+ if (Array.isArray(node)) return node.map((n) => substituteMeta(n, meta));
175
+ if (node && typeof node === "object") {
176
+ for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
177
+ (node as Record<string, unknown>)[k] = substituteMeta(v, meta);
178
+ }
179
+ return node;
180
+ }
181
+ return node;
182
+ }
183
+
184
+ /** Expand roster templates + bind agreement-meta placeholders across regions. */
185
+ export function expandRosterTemplates(
186
+ resolved: Record<string, unknown>,
187
+ parties: RosterItem[],
188
+ meta?: RosterMeta,
189
+ ): void {
190
+ for (const value of Object.values(resolved)) {
191
+ const pd = (value as { puck_data?: { content?: unknown[] } } | null)
192
+ ?.puck_data;
193
+ if (!pd || !Array.isArray(pd.content)) continue;
194
+ if (parties.length > 0) pd.content = expandArray(pd.content, parties);
195
+ if (meta) substituteMeta(pd.content, meta);
196
+ }
197
+ }