@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,141 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Injects the ProposalChatLauncher block into an AUTHORISED proposal page (called
7
+ * from proposalGateConfig.onAuthorized — so the bubble lives strictly behind the
8
+ * email gate, and every proposal gets it with no per-page Puck edit). It builds
9
+ * the proposal's full page content into a runtime-context string and passes it to
10
+ * the launcher, which forwards it to the chat as <runtime_context> — so ONE shared
11
+ * "Proposal Assistant" agent answers about whichever proposal is being viewed.
12
+ *
13
+ * The agent id is config, not code (registries-are-source-of-truth): the
14
+ * `config / proposal-assistant-agent` row carries { agentId }. No row (or no
15
+ * agent) → no injection, the page renders exactly as before (fail-soft).
16
+ */
17
+
18
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
19
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
20
+ import { getServerClient } from "./page-server-orpc/server-client.ts";
21
+ import * as v from "valibot";
22
+
23
+ const AgentConfigSchema = v.object({ agentId: v.string() });
24
+
25
+ /** Serialise the entire page content tree as text for the GPT context. */
26
+ async function buildProposalContext(resolved: Record<string, unknown>): Promise<string> {
27
+ const proposal = resolved.proposal as Record<string, unknown> | undefined;
28
+ const parts: string[] = [];
29
+
30
+ if (typeof proposal?.name === "string" && proposal.name) {
31
+ parts.push(`Proposal title: ${proposal.name}`);
32
+ }
33
+ if (typeof proposal?.client_name === "string" && proposal.client_name) {
34
+ parts.push(`Prepared for: ${proposal.client_name}`);
35
+ }
36
+ if (typeof proposal?.description === "string" && proposal.description) {
37
+ parts.push(`Description: ${proposal.description}`);
38
+ }
39
+
40
+ // Dump the entire page content tree — every block, every prop, verbatim.
41
+ const page = (resolved.page as { puck_data?: { content?: unknown[] } } | undefined)?.puck_data;
42
+ if (page && Array.isArray(page.content)) {
43
+ parts.push("\n--- Page content ---");
44
+ parts.push(JSON.stringify(page.content, null, 2));
45
+ }
46
+
47
+ // Resolve DB-backed product tiers so the GPT sees actual pricing data.
48
+ const client = getServerClient() as {
49
+ products?: {
50
+ findFirst: (args: {
51
+ where: { id: string };
52
+ select: { id: true; billing_type: true; tiers: true; components: true };
53
+ }) => Promise<{
54
+ id: string;
55
+ billing_type: string;
56
+ tiers: unknown;
57
+ components: unknown;
58
+ } | null>;
59
+ };
60
+ };
61
+
62
+ for (const block of (page?.content ?? []) as Array<Record<string, unknown>>) {
63
+ if (block?.type !== "ProductTiers") continue;
64
+ const props = block.props as Record<string, unknown> | undefined;
65
+ if (!props) continue;
66
+
67
+ const productIds = [
68
+ props.productId,
69
+ props.productId2,
70
+ props.productId3,
71
+ ].filter((id): id is string => typeof id === "string" && id.trim().length > 0);
72
+
73
+ for (const pid of productIds) {
74
+ try {
75
+ const product = await client.products?.findFirst({
76
+ where: { id: pid },
77
+ select: { id: true, billing_type: true, tiers: true, components: true },
78
+ });
79
+ if (product) {
80
+ parts.push(`\n--- Product tiers (productId: ${pid}) ---`);
81
+ parts.push(JSON.stringify(product, null, 2));
82
+ }
83
+ } catch {
84
+ // best-effort
85
+ }
86
+ }
87
+ }
88
+
89
+ return parts.join("\n");
90
+ }
91
+
92
+ function brandPrimaryColor(resolved: Record<string, unknown>): string {
93
+ const brand = resolved.brand as { primaryColor?: unknown } | undefined;
94
+ return typeof brand?.primaryColor === "string" ? brand.primaryColor : "";
95
+ }
96
+
97
+ export async function injectProposalChatLauncher(
98
+ resolved: Record<string, unknown>,
99
+ viewerEmail: string,
100
+ ): Promise<void> {
101
+ const page = (resolved.page as { puck_data?: { content?: unknown[] } } | undefined)?.puck_data;
102
+ if (!page || !Array.isArray(page.content)) return;
103
+
104
+ const proposal = resolved.proposal as Record<string, unknown> | undefined;
105
+ const organisationId =
106
+ typeof proposal?.organisation_id === "string" ? proposal.organisation_id : "";
107
+ if (!organisationId) return;
108
+
109
+ let agentId = "";
110
+ try {
111
+ agentId = (
112
+ await loadRegistryConfig("config", "proposal-assistant-agent", AgentConfigSchema)
113
+ ).agentId;
114
+ } catch {
115
+ return; // no agent configured yet → no bubble (fail-soft, page unchanged)
116
+ }
117
+ if (!agentId) return;
118
+
119
+ try {
120
+ const runtimeContext = await buildProposalContext(resolved);
121
+ (page.content as Array<Record<string, unknown>>).push({
122
+ type: "ChatLauncherBlock",
123
+ props: {
124
+ id: "proposal-chat-launcher",
125
+ agentId,
126
+ organisationId,
127
+ viewerEmail,
128
+ runtimeContext,
129
+ primaryColor: brandPrimaryColor(resolved),
130
+ title: "Proposal Assistant",
131
+ welcomeMessage:
132
+ "Hi! Ask me anything about this proposal — scope, pricing, timeline or next steps.",
133
+ launchLabel: "Ask about this proposal",
134
+ },
135
+ });
136
+ } catch (err) {
137
+ getAppLogger().error(
138
+ `[proposal-chat] launcher injection failed: ${err instanceof Error ? err.message : String(err)}`,
139
+ );
140
+ }
141
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The PROPOSAL content gate, expressed as config for the content-gate primitive
7
+ * (content-gate/). No bespoke gate plumbing — just the proposal-specific bits:
8
+ * which resource (resolved.proposal.id), who's authorized (issuing-org team +
9
+ * recipient), what to seed (initialVisitor for the chat), and how to withhold
10
+ * (replace the proposal body with an injected SignIn gate block + neutralize the
11
+ * meta tags). The engine handles cookie/token/verify/orchestration.
12
+ *
13
+ * Universal: every proposal route exposes resolved.proposal, so this gates every
14
+ * proposal (existing + new) with no per-page block or migration.
15
+ */
16
+
17
+ import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
18
+ import { signAccessToken, verifyAccessToken } from "./content-gate/token.ts";
19
+ import { resolveProposalAuthorizedEmails } from "./proposal-authorized-emails.ts";
20
+ import { injectProposalChatLauncher } from "./proposal-chat-inject.ts";
21
+ import { propTtlMs } from "./gate-ttls.ts";
22
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
23
+ import * as v from "valibot";
24
+
25
+ /**
26
+ * Proposal gate display + behaviour, DB-driven (config/content-gate-proposal),
27
+ * never hardcoded. requireOtp is the per-gate OTP switch (proposals default
28
+ * off — email-only; agreements opt in). A missing/invalid row falls back to the
29
+ * proposal-appropriate defaults so the gate UI ALWAYS renders.
30
+ */
31
+ interface ProposalGateSettings {
32
+ title: string;
33
+ subtitle: string;
34
+ submitLabel: string;
35
+ requireOtp: boolean;
36
+ }
37
+
38
+ const PROPOSAL_GATE_DEFAULTS: ProposalGateSettings = {
39
+ title: "This proposal is private",
40
+ subtitle: "Enter the email this proposal was shared with to view it.",
41
+ submitLabel: "View proposal",
42
+ requireOtp: false,
43
+ };
44
+
45
+ const ProposalGateSettingsSchema = v.object({
46
+ title: v.optional(v.string(), PROPOSAL_GATE_DEFAULTS.title),
47
+ subtitle: v.optional(v.string(), PROPOSAL_GATE_DEFAULTS.subtitle),
48
+ submitLabel: v.optional(v.string(), PROPOSAL_GATE_DEFAULTS.submitLabel),
49
+ requireOtp: v.optional(v.boolean(), PROPOSAL_GATE_DEFAULTS.requireOtp),
50
+ });
51
+
52
+ async function loadProposalGateSettings(): Promise<ProposalGateSettings> {
53
+ try {
54
+ return await loadRegistryConfig(
55
+ "config",
56
+ "content-gate-proposal",
57
+ ProposalGateSettingsSchema,
58
+ );
59
+ } catch {
60
+ return PROPOSAL_GATE_DEFAULTS;
61
+ }
62
+ }
63
+
64
+ const PROP_COOKIE = "prop_token";
65
+
66
+ interface PuckHolder {
67
+ puck_data?: { content?: unknown[] };
68
+ }
69
+
70
+ function brandPrimaryColor(resolved: Record<string, unknown>): string {
71
+ const brand = resolved.brand as { primaryColor?: unknown } | undefined;
72
+ return typeof brand?.primaryColor === "string" ? brand.primaryColor : "";
73
+ }
74
+
75
+ function buildGateBlock(
76
+ proposalId: string,
77
+ primaryColor: string,
78
+ settings: ProposalGateSettings,
79
+ ) {
80
+ return {
81
+ type: "SignIn",
82
+ props: {
83
+ id: "proposal-gate",
84
+ mode: "gate",
85
+ gateContextField: "proposalId",
86
+ gateContextValue: proposalId,
87
+ gateAction: "/api/proposal-access",
88
+ // Display + behaviour are DB-driven (config/content-gate-proposal),
89
+ // not hardcoded. requireOtp is the per-gate OTP switch.
90
+ title: settings.title,
91
+ subtitle: settings.subtitle,
92
+ gateRequireOtp: settings.requireOtp,
93
+ gateSubmitLabel: settings.submitLabel,
94
+ primaryColor,
95
+ showBackToHome: false,
96
+ },
97
+ };
98
+ }
99
+
100
+ export const proposalGateConfig: ContentGateConfig = {
101
+ cookieName: PROP_COOKIE,
102
+ getResourceId(resolved) {
103
+ const proposal = resolved.proposal as { id?: unknown } | null | undefined;
104
+ return typeof proposal?.id === "string" ? proposal.id : "";
105
+ },
106
+ verifyToken(token, resourceId, nowMs) {
107
+ const email = verifyAccessToken(token, PROP_COOKIE, resourceId, nowMs);
108
+ return email ? { email } : null;
109
+ },
110
+ // Baked-in unified auth (engine default): a logged-in viewer whose session
111
+ // email is in this set (issuing-org team ∪ recipient ∪ partner) is released
112
+ // with no email/OTP entry. Just exposing the resolver opts this gate into the
113
+ // platform-wide behaviour — no per-gate session code.
114
+ resolveAuthorizedEmails: resolveProposalAuthorizedEmails,
115
+ async onAuthorized(resolved, _resourceId, { email }) {
116
+ // Seed the verified viewer identity (building-applications initialVisitor).
117
+ (resolved as { initialVisitor?: { email: string } }).initialVisitor = {
118
+ email,
119
+ };
120
+ // Inject the floating Proposal Assistant chat bubble — strictly behind the
121
+ // gate, so only an authorised viewer ever sees it. Content otherwise intact.
122
+ await injectProposalChatLauncher(resolved, email);
123
+ },
124
+ async onUnauthorized(resolved, resourceId) {
125
+ const settings = await loadProposalGateSettings();
126
+ // Replace the proposal BODY with the gate block — no proposal content
127
+ // reaches the HTML. Header/footer chrome stays branded.
128
+ const page = resolved.page as
129
+ | (PuckHolder & {
130
+ meta_title?: unknown;
131
+ meta_description?: unknown;
132
+ name?: unknown;
133
+ })
134
+ | null
135
+ | undefined;
136
+ if (page?.puck_data && Array.isArray(page.puck_data.content)) {
137
+ page.puck_data.content = [
138
+ buildGateBlock(resourceId, brandPrimaryColor(resolved), settings),
139
+ ];
140
+ }
141
+ // Neutralize the PAGE record's meta too: page.meta_title/meta_description
142
+ // feed pageData.meta → the <title>/og tags AND the __PAGE_DATA__ hydration
143
+ // JSON. Without this the proposal topic + partner name leak via meta even
144
+ // though the body is withheld (the 2026-06-13 meta-leak: a gated proposal
145
+ // still exposed "Webinar Campaign · in partnership with <partner>").
146
+ if (page) {
147
+ page.meta_title = "Private proposal";
148
+ page.meta_description = "";
149
+ page.name = "Private proposal";
150
+ }
151
+ // Neutralize the proposal record so the meta_context resolver + any block
152
+ // reading it can't leak name/partner/cover/recipient.
153
+ const prop = resolved.proposal as Record<string, unknown> | undefined;
154
+ if (prop) {
155
+ prop.name = "Private proposal";
156
+ prop.description = "";
157
+ prop.client_name = "";
158
+ prop.prepared_for = "";
159
+ prop.cover_image_url = "";
160
+ }
161
+ },
162
+ };
163
+
164
+ export const proposalAccessConfig: AccessConfig = {
165
+ path: "/api/proposal-access",
166
+ cookieName: PROP_COOKIE,
167
+ get ttlMs() {
168
+ return propTtlMs();
169
+ },
170
+ recordIdField: "proposalId",
171
+ resolveAuthorizedEmails: resolveProposalAuthorizedEmails,
172
+ signToken(resourceId, email, nowMs) {
173
+ return signAccessToken(PROP_COOKIE, resourceId, email, nowMs, propTtlMs());
174
+ },
175
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Regression guard for the manifest-keyed HTML cache. The lifetime-pinned
7
+ * cache served a deleted bundle hash after a post-boot rebuild (scala-docs
8
+ * 2026-06-13): build-client-assets rewrites asset-manifest.json + deletes the
9
+ * old hashed bundle, but the cached HTML kept the old hash → 404 → no
10
+ * hydration. These tests pin that a manifest change re-substitutes the HTML.
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
14
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { readHtmlTemplate } from "./read-html-template.ts";
18
+
19
+ let dir: string;
20
+ let origCwd: string;
21
+
22
+ function writeManifest(hydrate: string, styles: string): void {
23
+ writeFileSync(
24
+ join(dir, "dist", "client", "asset-manifest.json"),
25
+ `${JSON.stringify({ stylesPath: styles, hydratePath: hydrate }, null, 2)}\n`,
26
+ );
27
+ }
28
+
29
+ beforeEach(() => {
30
+ origCwd = process.cwd();
31
+ dir = mkdtempSync(join(tmpdir(), "read-html-template-"));
32
+ mkdirSync(join(dir, "src"), { recursive: true });
33
+ mkdirSync(join(dir, "dist", "client"), { recursive: true });
34
+ writeFileSync(
35
+ join(dir, "src", "index.html"),
36
+ '<link href="{{stylesPath}}"><script src="{{hydratePath}}"></script>',
37
+ );
38
+ process.chdir(dir);
39
+ });
40
+
41
+ afterEach(() => {
42
+ process.chdir(origCwd);
43
+ rmSync(dir, { recursive: true, force: true });
44
+ });
45
+
46
+ describe("readHtmlTemplate manifest-keyed cache", () => {
47
+ test("substitutes the current manifest hashes", async () => {
48
+ writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
49
+ const html = await readHtmlTemplate();
50
+ expect(html).toContain("/assets/hydrate-aaa.js");
51
+ expect(html).toContain("/assets/styles-aaa.css");
52
+ });
53
+
54
+ test("re-substitutes after a post-boot manifest change (the 404 regression)", async () => {
55
+ writeManifest("/assets/hydrate-old.js", "/assets/styles-old.css");
56
+ const first = await readHtmlTemplate();
57
+ expect(first).toContain("/assets/hydrate-old.js");
58
+
59
+ // Simulate build-client-assets rebuilding: new manifest, old bundle gone.
60
+ // mtime must advance for the stat-keyed cache to invalidate.
61
+ await Bun.sleep(10);
62
+ writeManifest("/assets/hydrate-new.js", "/assets/styles-new.css");
63
+
64
+ const second = await readHtmlTemplate();
65
+ expect(second).toContain("/assets/hydrate-new.js");
66
+ expect(second).not.toContain("/assets/hydrate-old.js");
67
+ });
68
+
69
+ test("falls back to fixed paths when the manifest is absent", async () => {
70
+ const html = await readHtmlTemplate();
71
+ expect(html).toContain("/assets/hydrate.js");
72
+ expect(html).toContain("/assets/styles.css");
73
+ });
74
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @system ui-service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Reads the per-service HTML template (codegen'd from
7
+ * `@teamscala/scala-install/src/runtime/index.html`) and substitutes the
8
+ * `{{stylesPath}}` / `{{hydratePath}}` placeholders with the current
9
+ * content-hashed asset URLs from `dist/client/asset-manifest.json`.
10
+ *
11
+ * Build-time produces hashed filenames (e.g. `styles-abc123.css`); the
12
+ * static-asset handler serves them with `cache-control: immutable`. New
13
+ * deployments emit new hashes → new URLs → browsers refetch without ever
14
+ * holding a stale copy. Without the substitution, a fixed
15
+ * `/assets/styles.css` paired with `immutable` would pin every visitor to
16
+ * whatever version they first downloaded.
17
+ *
18
+ * Cached per service process, keyed on the manifest's mtime. The cache MUST
19
+ * invalidate when `asset-manifest.json` changes: `buildClientAssets` can
20
+ * rebuild the bundle AFTER the first request — producing a new content hash,
21
+ * rewriting the manifest, AND deleting the old hashed bundle
22
+ * (`removeStaleHashedFiles`). A lifetime-pinned HTML cache then keeps serving
23
+ * the OLD hash, which now 404s → hydration never runs → the page renders SSR
24
+ * HTML but is dead client-side (the doc-editor overlay never mounts; scala-docs
25
+ * 2026-06-13, exposed by the ui-foundation publish-storm's repeated rebuilds).
26
+ * Re-stat'ing the tiny manifest per request is negligible (OS-cached, no
27
+ * network); the substituted HTML is reused while the mtime is unchanged.
28
+ *
29
+ * NO-SILENT-REVERTS: when the manifest is a TOMBSTONE (the last client build
30
+ * failed), the served HTML is stamped with a `<meta name="x-client-build-status"
31
+ * content="failed">` tag in <head> so any operator / chrome-devtools / theme
32
+ * probe inspecting the page can see the served bundle is STALE (a published fix
33
+ * that reached node_modules but failed to bundle is silently served from the
34
+ * prior bundle otherwise — scala-agents 2026-07-18, the white-on-white toolbar
35
+ * regression that looked "not fixed" while the bundle kept serving pre-fix
36
+ * bytes across restarts).
37
+ */
38
+
39
+ import { readClientBuildStatus } from "./build-client-assets.ts";
40
+ import { join } from "node:path";
41
+
42
+ interface AssetManifest {
43
+ stylesPath: string;
44
+ hydratePath: string;
45
+ }
46
+
47
+ const FALLBACK: AssetManifest = {
48
+ stylesPath: "/assets/styles.css",
49
+ hydratePath: "/assets/hydrate.js",
50
+ };
51
+
52
+ let cached: { html: string; manifestMtime: number } | null = null;
53
+
54
+ function manifestPath(): string {
55
+ return join(process.cwd(), "dist", "client", "asset-manifest.json");
56
+ }
57
+
58
+ async function manifestMtimeMs(): Promise<number> {
59
+ try {
60
+ return (await Bun.file(manifestPath()).stat()).mtimeMs;
61
+ } catch {
62
+ return 0;
63
+ }
64
+ }
65
+
66
+ async function loadManifest(): Promise<AssetManifest> {
67
+ const path = manifestPath();
68
+ if (await Bun.file(path).exists()) {
69
+ try {
70
+ const parsed = await Bun.file(path).json() as Partial<AssetManifest>;
71
+ if (parsed.stylesPath && parsed.hydratePath) {
72
+ return { stylesPath: parsed.stylesPath, hydratePath: parsed.hydratePath };
73
+ }
74
+ } catch {}
75
+ }
76
+ return FALLBACK;
77
+ }
78
+
79
+ export async function readHtmlTemplate(): Promise<string> {
80
+ const mtime = await manifestMtimeMs();
81
+ if (cached && cached.manifestMtime === mtime) return cached.html;
82
+ const templatePath = join(process.cwd(), "src", "index.html");
83
+ const raw = await Bun.file(templatePath).text();
84
+ const manifest = await loadManifest();
85
+ let html = raw
86
+ .replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
87
+ .replace(/\{\{hydratePath\}\}/g, manifest.hydratePath);
88
+ // Tombstone stamp — surfaces a failed client build at the served layer.
89
+ // `<meta>` in <head> is the minimum-invasive vehicle (no layout shift, no
90
+ // JS dependency, visible via view-source / devtools / a future status
91
+ // probe). `x-` prefix keeps it out of any standard meta namespace.
92
+ const status = await readClientBuildStatus();
93
+ if (status.failed) {
94
+ const reason = (status.reason ?? "unknown").replace(/"/g, "'").slice(0, 200);
95
+ const meta = `<meta name="x-client-build-status" content="failed" data-reason="${reason}" data-at="${status.at ?? ""}">`;
96
+ html = html.includes("</head>")
97
+ ? html.replace("</head>", `${meta}</head>`)
98
+ : `${meta}${html}`;
99
+ }
100
+ cached = { html, manifestMtime: mtime };
101
+ return html;
102
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Server-side viewport detection from request headers. Two tiers:
7
+ * desktop (tablets, laptops, desktops) and mobile (phones).
8
+ *
9
+ * Tablets are NOT a separate tier — CSS handles responsive layout
10
+ * within the desktop tier via flex min/max and @container queries.
11
+ * Only phones (where the layout is fundamentally single-pane) get
12
+ * a different server-rendered page.
13
+ *
14
+ * Order of authority:
15
+ * cookie → Sec-CH-UA-Mobile client hint → User-Agent regex → desktop default.
16
+ */
17
+
18
+ export type ViewportTier = "desktop" | "mobile";
19
+
20
+ const PHONE_UA_RE =
21
+ /iPhone|iPod|Android.*Mobile|Mobile Safari|Opera Mini|IEMobile|webOS|BlackBerry/i;
22
+
23
+ export function readViewportFromRequest(request: Request): ViewportTier {
24
+ const cookie = request.headers.get("cookie") ?? "";
25
+ const cookieMatch = cookie.match(/(?:^|;\s*)viewport=(mobile|desktop)\b/);
26
+ if (cookieMatch?.[1] === "mobile") return "mobile";
27
+ if (cookieMatch?.[1] === "desktop") return "desktop";
28
+
29
+ const chMobile = request.headers.get("sec-ch-ua-mobile");
30
+ if (chMobile === "?1") return "mobile";
31
+
32
+ const ua = request.headers.get("user-agent") ?? "";
33
+ if (PHONE_UA_RE.test(ua)) return "mobile";
34
+
35
+ return "desktop";
36
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Readiness gate — returns 503 + Retry-After: 1 until the boot pipeline
7
+ * flips the ready flag true as its last step. Registered as the FIRST
8
+ * middleware in both UI and backend boot paths per readiness-gated-boot.
9
+ *
10
+ * The gate fails open: if it ever throws, the request passes through to
11
+ * the normal handler rather than producing a hard 500.
12
+ */
13
+
14
+ import type { Context, Next } from "hono";
15
+
16
+ let _ready = false;
17
+
18
+ export function isReady(): boolean {
19
+ return _ready;
20
+ }
21
+
22
+ export function setReady(): void {
23
+ _ready = true;
24
+ }
25
+
26
+ export async function readinessGate(c: Context, next: Next): Promise<Response | void> {
27
+ try {
28
+ if (_ready) return next();
29
+ return c.json({ status: "starting" }, 503, { "Retry-After": "1" });
30
+ } catch (err) {
31
+ // Fail-open-NOISY (protective-layers-fail-explicitly): a thrown gate
32
+ // must never hard-500 (readiness-gated-boot) — it passes through to the
33
+ // handler — but the failure must be OBSERVABLE, not silent. Practically
34
+ // unreachable (the body is pure reads of module flags + c.req.path); the
35
+ // log surfaces a regression if it ever fires.
36
+ console.error("[readiness-gate] gate threw — failing open to handler", err);
37
+ return next();
38
+ }
39
+ }