@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.
- package/package.json +232 -1127
- package/src/agreement-gate/settings.ts +8 -8
- package/src/apply-logger-config.test.ts +46 -0
- package/src/apply-logger-config.ts +31 -0
- package/src/asset-proxy.test.ts +9 -5
- package/src/auth/sign-out.ts +122 -0
- package/src/build-client-assets/hydrate-bundle.ts +14 -4
- package/src/build-client-assets/manifest.ts +5 -18
- package/src/build-client-assets/styles-build.test.ts +11 -8
- package/src/build-client-assets/styles-build.ts +14 -63
- package/src/build-client-assets.test.ts +0 -161
- package/src/build-client-assets.ts +20 -67
- package/src/content-gate/token.ts +9 -2
- package/src/install-logging.ts +10 -26
- package/src/mount-orpc-router.ts +8 -2
- package/src/mutable-app-ref.ts +0 -7
- package/src/page-server-loader-graph.test.ts +31 -0
- package/src/page-server-loader-graph.ts +21 -0
- package/src/pg-boss-mount.ts +3 -16
- package/src/readiness-gate.ts +0 -4
- package/src/register-api-routes.ts +8 -17
- package/src/register-page-server-client.ts +6 -0
- package/src/register-registry-page-route/meta-title.test.ts +50 -0
- package/src/register-registry-page-route/meta-title.ts +1 -1
- package/src/register-registry-page-route/page-data-shared.ts +7 -11
- package/src/register-registry-page-route/resolve-request-graph.test.ts +1 -1
- package/src/register-registry-page-route/resolve-request-graph.ts +1 -2
- package/src/register-registry-page-route.ts +14 -16
- package/src/resolve-brand-favicon.test.ts +63 -0
- package/src/resolve-theme-mode.test.ts +51 -0
- package/src/resolvers/fetch-org-brand.test.ts +48 -0
- package/src/resolvers/website-by-host-map.test.ts +63 -0
- package/src/resolvers/website-by-host-map.ts +15 -3
- package/src/run-boot-hook-list.test.ts +47 -0
- package/src/run-boot-hooks.test.ts +56 -0
- package/src/scala-tools-currency-watchdog.test.ts +43 -0
- package/src/scala-tools-currency-watchdog.ts +0 -5
- package/src/transfers/presigned-upload.ts +165 -0
- package/src/two-phase-minimal-app.test.ts +24 -0
- package/src/two-phase-readiness.test.ts +102 -0
- package/src/types.test.ts +20 -0
- package/src/ui-service.test.ts +24 -0
- package/src/ws-boot-self-test.test.ts +43 -0
- package/src/build-client-assets/input-fingerprint.ts +0 -92
- package/src/build-client-assets/input-hashing.ts +0 -206
- package/src/handlers/bun-stable-watch.ts +0 -86
- package/src/handlers/messaging-send-reconcile-logic.test.ts +0 -79
- package/src/handlers/messaging-send-reconcile-logic.ts +0 -83
- package/src/handlers/messaging-send-recording-logic.test.ts +0 -86
- package/src/handlers/messaging-send-recording-logic.ts +0 -68
- package/src/handlers/messaging-send-recording-watch.test.ts +0 -91
- package/src/handlers/messaging-send-recording-watch.ts +0 -244
- package/src/handlers/messaging-send-sweep-selection.test.ts +0 -47
- package/src/handlers/messaging-send-sweep-selection.ts +0 -35
- package/src/handlers/platform-fault-escalation/config.ts +0 -95
- package/src/handlers/platform-fault-escalation/delivery.ts +0 -327
- package/src/handlers/platform-fault-escalation/event-log-queries.ts +0 -182
- package/src/handlers/platform-fault-escalation/orpc-client.ts +0 -114
- package/src/handlers/platform-fault-escalation/probe.test.ts +0 -63
- package/src/handlers/platform-fault-escalation/probe.ts +0 -19
- package/src/handlers/platform-fault-escalation-logic.ts +0 -382
- package/src/handlers/platform-fault-escalation-refresh.test.ts +0 -72
- package/src/handlers/platform-fault-escalation.test.ts +0 -497
- package/src/handlers/platform-fault-escalation.ts +0 -246
- package/src/handlers/projects-digest.ts +0 -198
- package/src/handlers/quiz-research.ts +0 -178
- package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
- package/src/handlers/scala-tools-currency-watch.test.ts +0 -65
- package/src/handlers/scala-tools-currency-watch.ts +0 -162
- package/src/handlers/singleton-tools-currency-watch.test.ts +0 -51
- package/src/handlers/singleton-tools-currency-watch.ts +0 -380
- package/src/handlers/sweep-event-log.ts +0 -98
- package/src/handlers/upstream-issue-sync.ts +0 -220
- package/src/handlers/whatsapp-liveness-handler.test.ts +0 -56
- package/src/handlers/whatsapp-liveness-logic.ts +0 -53
- package/src/handlers/whatsapp-liveness.test.ts +0 -78
- package/src/handlers/whatsapp-liveness.ts +0 -138
- package/src/interpolate-meta.ts +0 -123
- package/src/load-service-prisma-client.ts +0 -53
- package/src/loader-errors.ts +0 -41
- package/src/read-viewport-from-request.ts +0 -36
- package/src/register-page-server-client.test.ts +0 -185
- package/src/resolve-app-base-url.ts +0 -35
- package/src/resolve-from-loader-config.test.ts +0 -429
- package/src/resolve-from-loader-config.ts +0 -316
- 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);
|
package/src/interpolate-meta.ts
DELETED
|
@@ -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
|
-
}
|
package/src/loader-errors.ts
DELETED
|
@@ -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
|
-
}
|