@teamscala/service-boot 0.23.516 → 0.23.518

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 (86) hide show
  1. package/package.json +232 -1127
  2. package/src/agreement-gate/settings.ts +8 -8
  3. package/src/apply-logger-config.test.ts +46 -0
  4. package/src/apply-logger-config.ts +31 -0
  5. package/src/asset-proxy.test.ts +9 -5
  6. package/src/auth/sign-out.ts +122 -0
  7. package/src/build-client-assets/hydrate-bundle.ts +14 -4
  8. package/src/build-client-assets/manifest.ts +5 -18
  9. package/src/build-client-assets/styles-build.test.ts +11 -8
  10. package/src/build-client-assets/styles-build.ts +14 -63
  11. package/src/build-client-assets.test.ts +0 -161
  12. package/src/build-client-assets.ts +20 -67
  13. package/src/content-gate/token.ts +9 -2
  14. package/src/install-logging.ts +10 -26
  15. package/src/mount-orpc-router.ts +8 -2
  16. package/src/mutable-app-ref.ts +0 -7
  17. package/src/page-server-loader-graph.test.ts +31 -0
  18. package/src/page-server-loader-graph.ts +21 -0
  19. package/src/pg-boss-mount.ts +3 -16
  20. package/src/readiness-gate.ts +0 -4
  21. package/src/register-api-routes.ts +8 -17
  22. package/src/register-page-server-client.ts +6 -0
  23. package/src/register-registry-page-route/meta-title.test.ts +50 -0
  24. package/src/register-registry-page-route/meta-title.ts +1 -1
  25. package/src/register-registry-page-route/page-data-shared.ts +7 -11
  26. package/src/register-registry-page-route/resolve-request-graph.test.ts +1 -1
  27. package/src/register-registry-page-route/resolve-request-graph.ts +1 -2
  28. package/src/register-registry-page-route.ts +14 -16
  29. package/src/resolve-brand-favicon.test.ts +63 -0
  30. package/src/resolve-theme-mode.test.ts +51 -0
  31. package/src/resolvers/fetch-org-brand.test.ts +48 -0
  32. package/src/resolvers/website-by-host-map.test.ts +63 -0
  33. package/src/resolvers/website-by-host-map.ts +15 -3
  34. package/src/run-boot-hook-list.test.ts +47 -0
  35. package/src/run-boot-hooks.test.ts +56 -0
  36. package/src/scala-tools-currency-watchdog.test.ts +43 -0
  37. package/src/scala-tools-currency-watchdog.ts +0 -5
  38. package/src/transfers/presigned-upload.ts +165 -0
  39. package/src/two-phase-minimal-app.test.ts +24 -0
  40. package/src/two-phase-readiness.test.ts +102 -0
  41. package/src/types.test.ts +20 -0
  42. package/src/ui-service.test.ts +24 -0
  43. package/src/ws-boot-self-test.test.ts +43 -0
  44. package/src/build-client-assets/input-fingerprint.ts +0 -92
  45. package/src/build-client-assets/input-hashing.ts +0 -206
  46. package/src/handlers/bun-stable-watch.ts +0 -86
  47. package/src/handlers/messaging-send-reconcile-logic.test.ts +0 -79
  48. package/src/handlers/messaging-send-reconcile-logic.ts +0 -83
  49. package/src/handlers/messaging-send-recording-logic.test.ts +0 -86
  50. package/src/handlers/messaging-send-recording-logic.ts +0 -68
  51. package/src/handlers/messaging-send-recording-watch.test.ts +0 -91
  52. package/src/handlers/messaging-send-recording-watch.ts +0 -244
  53. package/src/handlers/messaging-send-sweep-selection.test.ts +0 -47
  54. package/src/handlers/messaging-send-sweep-selection.ts +0 -35
  55. package/src/handlers/platform-fault-escalation/config.ts +0 -95
  56. package/src/handlers/platform-fault-escalation/delivery.ts +0 -327
  57. package/src/handlers/platform-fault-escalation/event-log-queries.ts +0 -182
  58. package/src/handlers/platform-fault-escalation/orpc-client.ts +0 -114
  59. package/src/handlers/platform-fault-escalation/probe.test.ts +0 -63
  60. package/src/handlers/platform-fault-escalation/probe.ts +0 -19
  61. package/src/handlers/platform-fault-escalation-logic.ts +0 -382
  62. package/src/handlers/platform-fault-escalation-refresh.test.ts +0 -72
  63. package/src/handlers/platform-fault-escalation.test.ts +0 -497
  64. package/src/handlers/platform-fault-escalation.ts +0 -246
  65. package/src/handlers/projects-digest.ts +0 -198
  66. package/src/handlers/quiz-research.ts +0 -178
  67. package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
  68. package/src/handlers/scala-tools-currency-watch.test.ts +0 -65
  69. package/src/handlers/scala-tools-currency-watch.ts +0 -162
  70. package/src/handlers/singleton-tools-currency-watch.test.ts +0 -51
  71. package/src/handlers/singleton-tools-currency-watch.ts +0 -380
  72. package/src/handlers/sweep-event-log.ts +0 -98
  73. package/src/handlers/upstream-issue-sync.ts +0 -220
  74. package/src/handlers/whatsapp-liveness-handler.test.ts +0 -56
  75. package/src/handlers/whatsapp-liveness-logic.ts +0 -53
  76. package/src/handlers/whatsapp-liveness.test.ts +0 -78
  77. package/src/handlers/whatsapp-liveness.ts +0 -138
  78. package/src/interpolate-meta.ts +0 -123
  79. package/src/load-service-prisma-client.ts +0 -53
  80. package/src/loader-errors.ts +0 -41
  81. package/src/read-viewport-from-request.ts +0 -36
  82. package/src/register-page-server-client.test.ts +0 -185
  83. package/src/resolve-app-base-url.ts +0 -35
  84. package/src/resolve-from-loader-config.test.ts +0 -429
  85. package/src/resolve-from-loader-config.ts +0 -316
  86. package/src/resolve-service-context.ts +0 -88
@@ -1,56 +0,0 @@
1
- // @system codegen
2
- // @status generated
3
- // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
- //
5
- // This suite's assertions are OWNED by the codegen band: the band module
6
- // carries them verbatim, this file is the emission, and hand edits here are
7
- // overwritten on the next run. The rationale each assertion carries moved
8
- // with it into the band.
9
-
10
- import { beforeEach, expect, mock, test } from "bun:test";
11
-
12
- const recordEventCalls: Array<{ kind: string; payload: Record<string, unknown> }> = [];
13
-
14
- // The live device list the mocked /mcp forwarder returns — mutated per test.
15
- let mockDevices: { results: Array<{ id: string; state?: string; jid?: string }> };
16
-
17
- mock.module("@teamscala/event-log/record-event", () => ({
18
- recordEvent: (e: { kind: string; payload: Record<string, unknown> }) => {
19
- recordEventCalls.push(e);
20
- },
21
- }));
22
- mock.module("@teamscala/chat-completion/central-tool-forwarder", () => ({
23
- forwardToolToCentralMcp: async () => mockDevices,
24
- }));
25
-
26
- const { pollWhatsAppLiveness } = await import("./whatsapp-liveness.ts");
27
-
28
- beforeEach(() => {
29
- recordEventCalls.length = 0;
30
- });
31
-
32
- test("connected → disconnected emits a device.disconnected recordEvent", async () => {
33
- // Poll 1: device healthy — seeds the baseline, no event.
34
- mockDevices = { results: [{ id: "scala-community", state: "logged_in", jid: "447@s.whatsapp.net" }] };
35
- await pollWhatsAppLiveness({ config: {} as never });
36
- expect(recordEventCalls, "no event while connected").toEqual([]);
37
-
38
- // Poll 2: device dropped — transition fires the alert.
39
- mockDevices = { results: [{ id: "scala-community", state: "logged_out", jid: "447@s.whatsapp.net" }] };
40
- await pollWhatsAppLiveness({ config: {} as never });
41
-
42
- expect(recordEventCalls).toHaveLength(1);
43
- expect(recordEventCalls[0]!.kind).toBe("messaging.whatsapp.device.disconnected");
44
- expect(recordEventCalls[0]!.payload).toMatchObject({
45
- device_id: "scala-community",
46
- state: "logged_out",
47
- jid: "447@s.whatsapp.net",
48
- });
49
- });
50
-
51
- test("staying connected across polls emits nothing", async () => {
52
- mockDevices = { results: [{ id: "stable-dev", state: "logged_in" }] };
53
- await pollWhatsAppLiveness({ config: {} as never });
54
- await pollWhatsAppLiveness({ config: {} as never });
55
- expect(recordEventCalls).toEqual([]);
56
- });
@@ -1,53 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Pure transition-detection logic for the WhatsApp linked-device liveness monitor
7
- * (`whatsapp-liveness` handler). Split out from the GOWA-coupled handler so the
8
- * logic is unit-testable with zero adapter dependencies, and the handler is a thin
9
- * poll→detect→emit shell. The connection signal is the GOWA device `state` field
10
- * (`"logged_in"` == connected) — see `whatsapp-remote-logout-monitor`.
11
- */
12
-
13
- /** GOWA device `state` value meaning the linked phone is connected + authenticated. */
14
- export const GOWA_CONNECTED_STATE = "logged_in";
15
-
16
- export interface WhatsAppDeviceState {
17
- id: string;
18
- state: string;
19
- jid?: string;
20
- display_name?: string;
21
- }
22
-
23
- export interface LivenessTransitions {
24
- disconnected: WhatsAppDeviceState[];
25
- reconnected: WhatsAppDeviceState[];
26
- }
27
-
28
- /**
29
- * Detect connected↔disconnected transitions between the last-known and the freshly
30
- * observed device states.
31
- *
32
- * `prev` is the last-known `state` per device id. A device ABSENT from `prev` is a
33
- * first sighting: it seeds the baseline and yields NO transition (so the monitor
34
- * never alerts on a state it never observed the device leave, and a process restart
35
- * re-baselines without false positives). A device present in `prev` is compared:
36
- * connected→not is `disconnected`; not→connected is `reconnected`.
37
- */
38
- export function detectLivenessTransitions(
39
- prev: Map<string, string>,
40
- curr: WhatsAppDeviceState[],
41
- ): LivenessTransitions {
42
- const disconnected: WhatsAppDeviceState[] = [];
43
- const reconnected: WhatsAppDeviceState[] = [];
44
- for (const dev of curr) {
45
- const prevState = prev.get(dev.id);
46
- if (prevState === undefined) continue; // first sighting — baseline, no alert
47
- const wasConnected = prevState === GOWA_CONNECTED_STATE;
48
- const nowConnected = dev.state === GOWA_CONNECTED_STATE;
49
- if (wasConnected && !nowConnected) disconnected.push(dev);
50
- else if (!wasConnected && nowConnected) reconnected.push(dev);
51
- }
52
- return { disconnected, reconnected };
53
- }
@@ -1,78 +0,0 @@
1
- // @system codegen
2
- // @status generated
3
- // @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
4
- //
5
- // This suite's assertions are OWNED by the codegen band: the band module
6
- // carries them verbatim, this file is the emission, and hand edits here are
7
- // overwritten on the next run. The rationale each assertion carries moved
8
- // with it into the band.
9
-
10
- import { expect, test } from "bun:test";
11
- import { detectLivenessTransitions } from "./whatsapp-liveness-logic.ts";
12
-
13
- const dev = (id: string, state: string) => ({ id, state });
14
-
15
- test("first sighting of a device is baseline — no transition", () => {
16
- const prev = new Map<string, string>(); // never seen
17
- const curr = [dev("scala-community", "logged_in"), dev("new-number", "logged_in")];
18
- expect(detectLivenessTransitions(prev, curr)).toEqual({
19
- disconnected: [],
20
- reconnected: [],
21
- });
22
- });
23
-
24
- test("connected → disconnected transition is detected", () => {
25
- const prev = new Map([["scala-community", "logged_in"]]);
26
- const curr = [dev("scala-community", "logged_out")];
27
- const out = detectLivenessTransitions(prev, curr);
28
- expect(out.disconnected).toHaveLength(1);
29
- expect(out.disconnected[0]!.id).toBe("scala-community");
30
- expect(out.reconnected).toEqual([]);
31
- });
32
-
33
- test("any non-logged_in state counts as disconnected", () => {
34
- const prev = new Map([["d", "logged_in"]]);
35
- for (const state of ["logged_out", "connecting", "qr_required", "unknown", ""]) {
36
- const out = detectLivenessTransitions(prev, [dev("d", state)]);
37
- expect(out.disconnected, `state=${state}`).toHaveLength(1);
38
- }
39
- });
40
-
41
- test("disconnected → connected recovery is detected", () => {
42
- const prev = new Map([["d", "logged_out"]]);
43
- const curr = [dev("d", "logged_in")];
44
- const out = detectLivenessTransitions(prev, curr);
45
- expect(out.reconnected).toHaveLength(1);
46
- expect(out.disconnected).toEqual([]);
47
- });
48
-
49
- test("unchanged state yields no transition", () => {
50
- const prev = new Map([
51
- ["a", "logged_in"],
52
- ["b", "logged_out"],
53
- ]);
54
- const curr = [dev("a", "logged_in"), dev("b", "logged_out")];
55
- expect(detectLivenessTransitions(prev, curr)).toEqual({
56
- disconnected: [],
57
- reconnected: [],
58
- });
59
- });
60
-
61
- test("mixed: one disconnects, one recovers, one baselines, one stable", () => {
62
- const prev = new Map<string, string>([
63
- ["keep-connected", "logged_in"],
64
- ["will-drop", "logged_in"],
65
- ["will-recover", "logged_out"],
66
- ["stays-down", "logged_out"],
67
- ]);
68
- const curr = [
69
- dev("keep-connected", "logged_in"),
70
- dev("will-drop", "logged_out"),
71
- dev("will-recover", "logged_in"),
72
- dev("stays-down", "logged_out"),
73
- dev("brand-new", "logged_in"), // first sighting — baseline, ignored
74
- ];
75
- const out = detectLivenessTransitions(prev, curr);
76
- expect(out.disconnected.map((d) => d.id)).toEqual(["will-drop"]);
77
- expect(out.reconnected.map((d) => d.id)).toEqual(["will-recover"]);
78
- });
@@ -1,138 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * The `whatsapp-liveness` pg-boss handler — polls GOWA `listDevices` and emits a
7
- * `messaging.whatsapp.device.disconnected` recordEvent + operator alert when a
8
- * WhatsApp linked device's `state` transitions away from `logged_in`. A phone-side
9
- * remote-logout (REMOTE_LOGOUT) emits NO GOWA webhook (its webhook set is
10
- * message/ack/group only), so the platform would otherwise only discover the loss
11
- * when a tool next needs the device (incident 2026-07-02: ~12h silent drop). This
12
- * monitor closes that gap with a ~5min poll (`whatsapp-remote-logout-monitor`).
13
- *
14
- * The device list is fetched by DISPATCHING the `api_gowa_devices.listDevices` tool
15
- * through the central `/mcp` loopback (`forwardToolToCentralMcp`) — reusing the
16
- * gateway's single sealed GOWA-credential resolution surface (no cred duplication,
17
- * no second resolver, no new process.env injection). The handler carries no GOWA
18
- * secrets; it authenticates to its own /mcp with `MCP_CHAT_LOOPBACK_TOKEN` as the
19
- * system caller (`MCP_SYSTEM_USER_ID`/`MCP_SYSTEM_ORG_ID` — a platform system
20
- * action, not a user-scoped one). The connection signal is the device `state` field
21
- * (`"logged_in"` == connected); the pure transition detector + its 8 tests live in
22
- * the dependency-free sibling `whatsapp-liveness-logic.ts`. State is held in-process
23
- * (Map<deviceId,state>); a process restart re-baselines every device.
24
- *
25
- * Registered under `@teamscala/service-boot:whatsapp-liveness`.
26
- */
27
- import type { ExecutionContext } from "@teamscala/os/contracts/mcp";
28
- import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
29
- import { recordEvent } from "@teamscala/event-log/record-event";
30
- import { getAppLogger } from "@teamscala/logger/app-loggers";
31
- import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
32
- import { detectLivenessTransitions, type WhatsAppDeviceState } from "./whatsapp-liveness-logic.ts";
33
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
34
- import * as v from "valibot";
35
- import { createCache } from "@teamscala/cache/create-cache";
36
-
37
- /** In-process last-known device state, keyed by GOWA device id. */
38
- const lastKnown = new Map<string, string>();
39
-
40
- /** The subset of GOWA's `/devices` response this monitor reads. */
41
- interface GowaListDevicesResponse {
42
- results?: Array<{
43
- id: string;
44
- state?: string;
45
- jid?: string;
46
- display_name?: string;
47
- }>;
48
- }
49
-
50
- const systemIdentityCache = createCache<{
51
- userId: string | undefined;
52
- organisationId: string | undefined;
53
- }>("service-boot:whatsapp-liveness-system-identity", {
54
- ttlMs: Number.POSITIVE_INFINITY,
55
- maxSize: 1,
56
- });
57
-
58
- /** The system-caller identity for platform-action tool dispatches — shared by
59
- * the sibling handlers that dispatch GOWA reads through the central /mcp
60
- * (messaging-send-recording-watch reconcile sweep). */
61
- export async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
62
- const hit = systemIdentityCache.get("default");
63
- if (hit) return hit;
64
- const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
65
- const identity = {
66
- userId: row?.system_user_id as string | undefined,
67
- organisationId: row?.system_org_id as string | undefined,
68
- };
69
- systemIdentityCache.set("default", identity);
70
- return identity;
71
- }
72
-
73
- export async function pollWhatsAppLiveness({
74
- config: _config,
75
- }: {
76
- config: ScheduledJobConfig;
77
- }): Promise<void> {
78
- const log = getAppLogger();
79
-
80
- // System-caller identity (platform action, not user-scoped) — matches the
81
- // gateway's system caller; the /mcp resolves it to cross-org super-admin.
82
- const { userId, organisationId } = await getSystemIdentity();
83
- const systemCtx: ExecutionContext = { userId, organisationId } as ExecutionContext;
84
-
85
- let devices: WhatsAppDeviceState[];
86
- try {
87
- const { forwardToolToCentralMcp } = await import("@teamscala/chat-completion/central-tool-forwarder");
88
- const res = (await forwardToolToCentralMcp(
89
- "api_gowa_devices",
90
- { action: "listDevices" },
91
- systemCtx,
92
- )) as GowaListDevicesResponse | undefined;
93
- devices = (res?.results ?? []).map((d) => ({
94
- id: d.id,
95
- state: d.state ?? "unknown",
96
- jid: d.jid,
97
- display_name: d.display_name,
98
- }));
99
- } catch (err) {
100
- log.warn(
101
- `[whatsapp-liveness] GOWA listDevices via /mcp failed: ${err instanceof Error ? err.message : String(err)}`,
102
- );
103
- return;
104
- }
105
-
106
- const { disconnected, reconnected } = detectLivenessTransitions(lastKnown, devices);
107
-
108
- for (const dev of disconnected) {
109
- recordEvent({
110
- kind: "messaging.whatsapp.device.disconnected",
111
- payload: {
112
- device_id: dev.id,
113
- jid: dev.jid,
114
- display_name: dev.display_name,
115
- state: dev.state,
116
- },
117
- });
118
- log.warn(
119
- `[whatsapp-liveness] device "${dev.id}" (${dev.jid ?? "?"}) DISCONNECTED (state=${dev.state}) — operator alert emitted`,
120
- );
121
- }
122
- for (const dev of reconnected) {
123
- recordEvent({
124
- kind: "messaging.whatsapp.device.reconnected",
125
- payload: { device_id: dev.id, jid: dev.jid, display_name: dev.display_name },
126
- });
127
- log.info(`[whatsapp-liveness] device "${dev.id}" (${dev.jid ?? "?"}) reconnected`);
128
- }
129
-
130
- // Advance the baseline to the freshly-observed states.
131
- for (const dev of devices) lastKnown.set(dev.id, dev.state);
132
-
133
- log.info(
134
- `[whatsapp-liveness] poll complete — ${devices.length} device(s), ${disconnected.length} disconnect(s), ${reconnected.length} reconnect(s)`,
135
- );
136
- }
137
-
138
- pgBossHandlerRegistry.register("@teamscala/service-boot:whatsapp-liveness", pollWhatsAppLiveness);
@@ -1,123 +0,0 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Template interpolation against URL params and resolved loader data:
7
- * resolveParamRef, interpolateTemplate, and the meta-context filler.
8
- *
9
- * interpolateTemplate moved here from resolve-from-loader-config.ts
10
- * (file-size-budget): interpolation is its own canonical unit and the meta
11
- * filler already consumed it, so the import direction now points at the
12
- * family home instead of the wave resolver.
13
- *
14
- * An unresolved ref keeps its literal template rather than becoming
15
- * undefined — a page whose brand failed should show a raw `:brand.companyName`
16
- * somewhere visible rather than silently dropping its title.
17
- */
18
-
19
- export function resolveParamRef(
20
- ref: string,
21
- params: Record<string, unknown>,
22
- resolved: Record<string, unknown>,
23
- ): unknown {
24
- const parts = ref.split(".");
25
- // A bare (no-dot) ref can name either a resolved source key (e.g. `:response`
26
- // passes the whole resolved source object to a resolver_fn) or a URL param
27
- // (e.g. `:id`). Prefer the resolved source key, then fall back to the param.
28
- // Dotted refs (`:response.total_score`) always read from `resolved`.
29
- if (parts.length === 1) {
30
- if (resolved != null && typeof resolved === "object" && ref in resolved) {
31
- return (resolved as Record<string, unknown>)[ref];
32
- }
33
- if (params != null && typeof params === "object" && ref in params) {
34
- return (params as Record<string, unknown>)[ref];
35
- }
36
- return undefined;
37
- }
38
- let current: unknown = resolved;
39
- for (const part of parts) {
40
- if (current == null || typeof current !== "object") return undefined;
41
- current = readField(current as Record<string, unknown>, part);
42
- }
43
- return current;
44
- }
45
-
46
- /**
47
- * A resource_loader row spells DATABASE COLUMN NAMES — `page_id`,
48
- * `organisation_id`, `prepared_for` — because that is what the schema calls
49
- * them. The ORM hands back CAMELCASE keys — `pageId`, `organisationId`,
50
- * `preparedFor`. Nothing reconciled the two, so a `:proposal.page_id` ref read
51
- * a key the object does not have and returned undefined.
52
- *
53
- * MEASURED 2026-09-03, and the consequence is far worse than a missing value.
54
- * `interpolateTemplate` DROPS a key whose value is undefined, so a dependent
55
- * source declared `where: { id: ":proposal.page_id" }` was issued as
56
- * `where: {}` — and findFirst with an empty filter returns AN ARBITRARY FIRST
57
- * ROW. A client proposal page therefore loaded an unrelated builda_page,
58
- * rendered a stranger's content under the correct brand chrome, and answered
59
- * 200 throughout. Instrumenting the live resolver showed every dependent
60
- * source doing it: page, website, contact, partnerOrg and clientOrg all with
61
- * `where={}`; only `proposal` was correct, because its ref is the URL param
62
- * `:id` rather than a sibling source.
63
- *
64
- * Read the spelled key first, then its camelCase equivalent. Column names stay
65
- * the authoring vocabulary and a row never has to know what the ORM did to its
66
- * keys.
67
- */
68
- function readField(source: Record<string, unknown>, key: string): unknown {
69
- if (key in source) return source[key];
70
- const camel = key.replace(/_([a-z0-9])/g, (_m, c: string) => c.toUpperCase());
71
- return camel === key ? undefined : source[camel];
72
- }
73
-
74
- export function interpolateTemplate(
75
- template: unknown,
76
- params: Record<string, unknown>,
77
- resolved: Record<string, unknown>,
78
- ): unknown {
79
- if (typeof template === "string") {
80
- const exactMatch = template.match(/^:([a-zA-Z0-9_.]+)$/);
81
- if (exactMatch) {
82
- return resolveParamRef(exactMatch[1], params, resolved);
83
- }
84
- if (template.includes(":")) {
85
- return template.replace(
86
- /:([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*)/g,
87
- (_match, ref: string) => {
88
- const value = resolveParamRef(ref, params, resolved);
89
- return value != null ? String(value) : _match;
90
- },
91
- );
92
- }
93
- return template;
94
- }
95
- if (Array.isArray(template)) {
96
- return template.map((v) => interpolateTemplate(v, params, resolved));
97
- }
98
- if (template != null && typeof template === "object") {
99
- const out: Record<string, unknown> = {};
100
- for (const [k, v] of Object.entries(template)) {
101
- const result = interpolateTemplate(v, params, resolved);
102
- if (result !== undefined) out[k] = result;
103
- }
104
- return out;
105
- }
106
- return template;
107
- }
108
-
109
- export function interpolateMeta(
110
- template: Record<string, unknown>,
111
- data: Record<string, unknown>,
112
- ): Record<string, unknown> {
113
- const out: Record<string, unknown> = {};
114
- for (const [k, v] of Object.entries(template)) {
115
- if (v != null && typeof v === "object" && !Array.isArray(v)) {
116
- out[k] = interpolateMeta(v as Record<string, unknown>, data);
117
- } else {
118
- const result = interpolateTemplate(v, {}, data);
119
- out[k] = result ?? v;
120
- }
121
- }
122
- return out;
123
- }
@@ -1,53 +0,0 @@
1
- /**
2
- * @system server-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Loads the service's own generated PrismaClient from
7
- * <cwd>/prisma/generated/prisma/client.ts. Each service repo generates its own
8
- * client from its own schema.prisma; resolving a static import would pick up
9
- * whichever @prisma/generated bun resolves first in a multi-service workspace,
10
- * which is wrong.
11
- *
12
- * No fallback: if the generated client is missing the deploy is broken — fail
13
- * loud with the remediation step.
14
- */
15
-
16
- export type ServicePrismaClientConstructor = new (args: {
17
- adapter: unknown;
18
- }) => unknown;
19
-
20
- export async function loadServicePrismaClient(
21
- serviceSlug: string,
22
- ): Promise<ServicePrismaClientConstructor> {
23
- // Per `near-empty-services`, the platform owns one Prisma client (in
24
- // @teamscala/db) shared by every service. Service-local Prisma clients
25
- // remain supported when a service ships its own `prisma/schema.prisma`.
26
- const fs = await import("node:fs");
27
- const servicePath = `${process.cwd()}/prisma/generated/prisma/client.ts`;
28
- // `@teamscala/db/client` is the sanctioned subpath that re-exports the
29
- // generated PrismaClient (per `@teamscala/db/package.json#exports`).
30
- // The previous import of `@teamscala/db/generated/client/client.ts`
31
- // reached into an internal path that isn't exported; under bun
32
- // isolated-linker + node's exports-field enforcement, that path
33
- // fails to resolve at runtime even though the file is on disk.
34
- const platformPath = "@teamscala/db/client";
35
- const path = fs.existsSync(servicePath) ? servicePath : platformPath;
36
- let mod: { PrismaClient?: ServicePrismaClientConstructor };
37
- try {
38
- mod = (await import(path)) as {
39
- PrismaClient?: ServicePrismaClientConstructor;
40
- };
41
- } catch (err) {
42
- throw new Error(
43
- `[scala-install:${serviceSlug}] Failed to load Prisma client at ${path}. ` +
44
- `Cause: ${err instanceof Error ? err.message : err}`,
45
- );
46
- }
47
- if (!mod.PrismaClient) {
48
- throw new Error(
49
- `[scala-install:${serviceSlug}] Prisma client at ${path} has no PrismaClient export.`,
50
- );
51
- }
52
- return mod.PrismaClient;
53
- }
@@ -1,41 +0,0 @@
1
- /**
2
- * @system ui-service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Error vocabulary for resource_loader source resolution. Split out of
7
- * resolve-from-loader-config (file-size-budget): the classes are the
8
- * name-matched contract other layers key on, and they belong where they
9
- * can be imported without dragging the wave resolver with them.
10
- */
11
-
12
- export class MissingLoaderModelCapabilityError extends Error {
13
- constructor(model: string, sourceKey: string) {
14
- super(
15
- `[resource_loader] source "${sourceKey}" requires model "${model}", but the generated server client does not expose findFirst for it — regenerate the service schema from its page loaders`,
16
- );
17
- this.name = "MissingLoaderModelCapabilityError";
18
- }
19
- }
20
-
21
- /**
22
- * A Prisma/pg query rejected an interpolated URL param as not a valid uuid
23
- * (pg 22P02 surfaced as the generated client's SqlQueryError). The value came
24
- * from the URL, so the URL names no row — the same state as a deleted row,
25
- * not a renderer failure. Deliberately a tight name+message match: a
26
- * genuinely malformed query is a config fault and must keep failing loudly.
27
- *
28
- * Measured 2026-09-12 on scala-quiz prod: scanner paths (/.env, /robots.txt,
29
- * *.php) and slug links each threw `invalid input syntax for type uuid` as a
30
- * 500 after a ~2.5s DB round trip, and the page-server:render-budget breaker
31
- * counted them as CONSECUTIVE render failures — 53 in a row on 2026-09-11 —
32
- * so the layer sat DEGRADED fail-closed and every real quiz page 503'd for
33
- * hours while the only successful render in the window was a crawler.
34
- */
35
- export function isUrlRefNotUuidError(err: unknown): boolean {
36
- return (
37
- err instanceof Error &&
38
- err.name === "SqlQueryError" &&
39
- err.message.includes("invalid input syntax for type uuid")
40
- );
41
- }
@@ -1,36 +0,0 @@
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
- }