@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
|
@@ -22,14 +22,7 @@ export const AG_COOKIE = "ag_token";
|
|
|
22
22
|
* before signing). title is the fallback used when no agreement name resolves. A
|
|
23
23
|
* missing/invalid row falls back to these defaults so the gate UI ALWAYS renders.
|
|
24
24
|
*/
|
|
25
|
-
export
|
|
26
|
-
title: string;
|
|
27
|
-
subtitle: string;
|
|
28
|
-
submitLabel: string;
|
|
29
|
-
requireOtp: boolean;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export const AGREEMENT_GATE_DEFAULTS: AgreementGateSettings = {
|
|
25
|
+
export const AGREEMENT_GATE_DEFAULTS = {
|
|
33
26
|
title: "Private document",
|
|
34
27
|
subtitle: "Verify your email to view and sign this document.",
|
|
35
28
|
submitLabel: "Access document",
|
|
@@ -43,6 +36,13 @@ const AgreementGateSettingsSchema = v.object({
|
|
|
43
36
|
requireOtp: v.optional(v.boolean(), AGREEMENT_GATE_DEFAULTS.requireOtp),
|
|
44
37
|
});
|
|
45
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The settings type is the SCHEMA'S output, never a hand mirror
|
|
41
|
+
* (`autosave-single-surface` rule 2): the interface re-spelled the schema one
|
|
42
|
+
* block away and drifts the first time the schema grows a field.
|
|
43
|
+
*/
|
|
44
|
+
export type AgreementGateSettings = v.InferOutput<typeof AgreementGateSettingsSchema>;
|
|
45
|
+
|
|
46
46
|
/** Gate copy/behaviour, or null when the config row is absent/broken. Null is
|
|
47
47
|
* the absent signal — callers default explicitly so a missing row neither 500s
|
|
48
48
|
* the page nor masks as a populated value (protective-layers-fail-explicitly). */
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./apply-logger-config declares 1 runtime export(s) and 3 declared behaviour vector(s);
|
|
5
|
+
* this suite asserts the import resolves, the runtime surface matches
|
|
6
|
+
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
|
+
* overwritten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { mock } from "bun:test";
|
|
13
|
+
const __setMinLevel: string[] = [];
|
|
14
|
+
const __fileSinks: Record<string, unknown>[] = [];
|
|
15
|
+
mock.module("@teamscala/logger/creator", () => ({ setMinLevel: (level: string) => { __setMinLevel.push(level); } }));
|
|
16
|
+
mock.module("@teamscala/logger/file-sink", () => ({ configureFileSink: (cfg: Record<string, unknown>) => { __fileSinks.push(cfg); } }));
|
|
17
|
+
|
|
18
|
+
const mod = await import("./apply-logger-config");
|
|
19
|
+
|
|
20
|
+
const { applyLoggerConfig } = mod;
|
|
21
|
+
|
|
22
|
+
describe("apply-logger-config exports-map smoke", () => {
|
|
23
|
+
it("the runtime export set is exactly the declared surface", () => {
|
|
24
|
+
expect(Object.keys(mod).sort()).toEqual([
|
|
25
|
+
"applyLoggerConfig",
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
it("applyLoggerConfig is a function", () => {
|
|
29
|
+
expect(typeof mod["applyLoggerConfig"]).toBe("function");
|
|
30
|
+
});
|
|
31
|
+
it("a declared min level reaches the logger creator", async () => {
|
|
32
|
+
__setMinLevel.length = 0;
|
|
33
|
+
applyLoggerConfig({ minLevel: "debug" });
|
|
34
|
+
expect(__setMinLevel).toEqual(["debug"]);
|
|
35
|
+
});
|
|
36
|
+
it("an undeclared min level leaves the creator untouched", async () => {
|
|
37
|
+
__setMinLevel.length = 0;
|
|
38
|
+
applyLoggerConfig({});
|
|
39
|
+
expect(__setMinLevel).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
it("the file sink is configured from the boot row's own values", async () => {
|
|
42
|
+
__fileSinks.length = 0;
|
|
43
|
+
applyLoggerConfig({ logDir: "/var/log/scala", maxFileSize: 10, maxFiles: 3 });
|
|
44
|
+
expect(__fileSinks).toEqual([{ logDir: "/var/log/scala", maxFileSize: 10, maxFiles: 3 }]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Late-stage logger configuration — runs AFTER Registry is loaded (the boot
|
|
7
|
+
* pipeline's `apply-logger-config` phase), injecting the DB-driven settings
|
|
8
|
+
* into the logger primitive. Split from install-logging.ts under
|
|
9
|
+
* single-purpose-file: the early bootstrap (hardcoded defaults, primitive
|
|
10
|
+
* injection) and this late reconfiguration are two phases, and the guard
|
|
11
|
+
* counts them as two export groups in one file.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { setMinLevel } from "@teamscala/logger/creator";
|
|
15
|
+
import { configureFileSink } from "@teamscala/logger/file-sink";
|
|
16
|
+
|
|
17
|
+
export function applyLoggerConfig(config: {
|
|
18
|
+
minLevel?: "debug" | "info" | "warn" | "error";
|
|
19
|
+
logDir?: string;
|
|
20
|
+
maxFileSize?: number;
|
|
21
|
+
maxFiles?: number;
|
|
22
|
+
}): void {
|
|
23
|
+
if (config.minLevel) {
|
|
24
|
+
setMinLevel(config.minLevel);
|
|
25
|
+
}
|
|
26
|
+
configureFileSink({
|
|
27
|
+
logDir: config.logDir,
|
|
28
|
+
maxFileSize: config.maxFileSize,
|
|
29
|
+
maxFiles: config.maxFiles,
|
|
30
|
+
});
|
|
31
|
+
}
|
package/src/asset-proxy.test.ts
CHANGED
|
@@ -23,10 +23,14 @@ describe("handleAssetProxy", () => {
|
|
|
23
23
|
|
|
24
24
|
it("reaches the S3 client for a valid asset key (no early 404)", async () => {
|
|
25
25
|
// A valid key passes the path + key guards and proceeds to getClient.
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
// getClient's contract is ABSENT CREDS → null — "a visible config
|
|
27
|
+
// error", never a rejection and never a silent 404 (object-storage
|
|
28
|
+
// get-client; getCredentials never throws) — so the resolved 502 is
|
|
29
|
+
// the evidence the handler reached the client step rather than
|
|
30
|
+
// short-circuiting on the path or key.
|
|
31
|
+
const res = await handleAssetProxy(
|
|
32
|
+
new Request("https://host.example/api/assets/logo.png"),
|
|
33
|
+
);
|
|
34
|
+
expect(res.status).toBe(502);
|
|
31
35
|
});
|
|
32
36
|
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten — none derivable: the api_route row (fn-auth-sign-out) declares the path, auth and input, not a body; host-bus-handler-contracts emits the client node + wiring FROM the row, never a procedure body; the sign-out mechanism is BetterAuth's `auth.api.signOut` on the app's own session + the /api/auth/* → scala-otp forward that auth-gate already performs (the body composes those two, it is not generated from either).
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The fn.auth.signOut procedure body (api_route `fn-auth-sign-out`, authed:
|
|
7
|
+
* context). It is the host-side sign-out the Button logout action calls
|
|
8
|
+
* through the generated ORPC client node, replacing the raw browser POST to
|
|
9
|
+
* /api/auth/sign-out. It takes no input — sign-out has no fields — so the
|
|
10
|
+
* generated node is called as `orpcClient.fn.auth.signOut()`, the same no-arg
|
|
11
|
+
* shape as fn.chat.health.
|
|
12
|
+
*
|
|
13
|
+
* WHY A PROCEDURE, NOT THE RAW FETCH. The old client called
|
|
14
|
+
* `/api/auth/sign-out` directly. That path is only served when the host is
|
|
15
|
+
* running the auth gate's `/api/auth/*` forward or a local BetterAuth handler,
|
|
16
|
+
* and it is a bare string path the client hard-codes — a second, unnamed
|
|
17
|
+
* statement of "where sign-out lives". Declaring it as an api_route procedure
|
|
18
|
+
* gives it the one home (the registry row) the rest of the auth surface uses,
|
|
19
|
+
* and the generated node carries the path.
|
|
20
|
+
*
|
|
21
|
+
* WHAT IT DOES, IN ORDER, so server-side behaviour is UNCHANGED from the
|
|
22
|
+
* forwarded POST:
|
|
23
|
+
* 1. Revoke the caller's session through the SAME BetterAuth instance the
|
|
24
|
+
* session-read path uses (getAuth() — the bridge auth-gate installs, so
|
|
25
|
+
* it validates through the identical factory). This deletes the session
|
|
26
|
+
* row and returns the Set-Cookie headers that clear it.
|
|
27
|
+
* 2. When SCALA_OTP_URL is set — centralised auth, per otp-centralised-via-
|
|
28
|
+
* otp-service — forward the same POST to scala-otp's /api/auth/sign-out
|
|
29
|
+
* with the caller's cookie, so the central service clears the shared
|
|
30
|
+
* Domain=.scala.business session too. The local revoke alone would leave
|
|
31
|
+
* the central session alive. The caller's cookie is carried forward and
|
|
32
|
+
* scala-otp's response status is checked, so a central refusal is reported
|
|
33
|
+
* rather than swallowed — the same POST the /api/auth/* forward in
|
|
34
|
+
* auth-gate performs.
|
|
35
|
+
*
|
|
36
|
+
* HONEST RESIDUAL: the ORPC response is JSON, so the Set-Cookie headers
|
|
37
|
+
* BetterAuth returns on its Response cannot ride back to the browser through
|
|
38
|
+
* this procedure. The session is revoked in the database on both sides, and
|
|
39
|
+
* the caller redirects to the sign-in path, so the browser presents a
|
|
40
|
+
* revoked-and-deleted session token on its next request and the auth gate
|
|
41
|
+
* rejects it — the same observable outcome as the forwarded POST, whose
|
|
42
|
+
* cleared cookie was an optimisation. Clearing the cookie explicitly is the
|
|
43
|
+
* caller's business (the raw /api/auth/clear-stale-cookies route), not this
|
|
44
|
+
* procedure's.
|
|
45
|
+
*/
|
|
46
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
47
|
+
|
|
48
|
+
export interface SignOutResult {
|
|
49
|
+
/** True once the local session is revoked (and the central one, when centralised). */
|
|
50
|
+
ok: boolean;
|
|
51
|
+
/** True when the sign-out was forwarded to scala-otp. */
|
|
52
|
+
forwarded: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface SignOutContext {
|
|
56
|
+
headers?: Headers;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function signOut(context: SignOutContext): Promise<SignOutResult> {
|
|
60
|
+
// The auth gate registers the BetterAuth factory at boot for every authed
|
|
61
|
+
// service (auth-gate.ts), so this consumes getAuth() and does NOT
|
|
62
|
+
// re-register: registerAuthFactory drops the memoized instance on every
|
|
63
|
+
// call, so a per-request registration would rebuild the whole better-auth
|
|
64
|
+
// module graph on each sign-out. getAuth() throws if no factory is
|
|
65
|
+
// registered, which the authed middleware has already excluded.
|
|
66
|
+
const { getAuth } = await import("@teamscala/auth/auth-registry.ts");
|
|
67
|
+
const auth = await getAuth();
|
|
68
|
+
const headers = context?.headers ?? new Headers();
|
|
69
|
+
|
|
70
|
+
// 1. Revoke the local session through the app's own BetterAuth instance.
|
|
71
|
+
// The ORPC handler's headers carry the session cookie, so signOut finds
|
|
72
|
+
// and deletes that exact session.
|
|
73
|
+
const local = await auth.api.signOut({ headers });
|
|
74
|
+
const localOk = Boolean(local?.success);
|
|
75
|
+
|
|
76
|
+
const otpUrl = process.env.SCALA_OTP_URL;
|
|
77
|
+
if (!otpUrl) {
|
|
78
|
+
if (!localOk) {
|
|
79
|
+
getAppLogger().warn("[auth-sign-out] local signOut reported no session");
|
|
80
|
+
}
|
|
81
|
+
return { ok: localOk, forwarded: false };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. Centralised auth: forward to scala-otp so the shared session is
|
|
85
|
+
// cleared there too, carrying the caller's cookie forward and the
|
|
86
|
+
// Set-Cookie back. Same shape as the /api/auth/* forward in auth-gate.
|
|
87
|
+
const otpBase = otpUrl.replace(/\/+$/, "");
|
|
88
|
+
const target = `${otpBase}/api/auth/sign-out`;
|
|
89
|
+
const cookie = headers.get("cookie");
|
|
90
|
+
try {
|
|
91
|
+
const fwdHeaders = new Headers();
|
|
92
|
+
if (cookie) fwdHeaders.set("cookie", cookie);
|
|
93
|
+
fwdHeaders.set("host", new URL(otpBase).host);
|
|
94
|
+
fwdHeaders.set("content-type", "application/json");
|
|
95
|
+
const upstream = await fetch(target, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: fwdHeaders,
|
|
98
|
+
body: "{}",
|
|
99
|
+
});
|
|
100
|
+
// A central refusal must not masquerade as a successful sign-out: the
|
|
101
|
+
// local session is already gone, but the shared one is not, so report
|
|
102
|
+
// ok:false and let the caller see it.
|
|
103
|
+
if (!upstream.ok) {
|
|
104
|
+
getAppLogger().error("[auth-sign-out] scala-otp sign-out refused", {
|
|
105
|
+
target,
|
|
106
|
+
status: upstream.status,
|
|
107
|
+
});
|
|
108
|
+
return { ok: false, forwarded: true };
|
|
109
|
+
}
|
|
110
|
+
return { ok: localOk, forwarded: true };
|
|
111
|
+
} catch (err) {
|
|
112
|
+
getAppLogger().error("[auth-sign-out] failed to reach scala-otp", {
|
|
113
|
+
target,
|
|
114
|
+
error: err instanceof Error ? err.message : String(err),
|
|
115
|
+
});
|
|
116
|
+
// The local session is revoked; the central one may still be live. This
|
|
117
|
+
// is a partial success the caller must be able to see, so it is
|
|
118
|
+
// returned rather than thrown — throwing would 500 an otherwise-usable
|
|
119
|
+
// sign-out that did clear the local cookie.
|
|
120
|
+
return { ok: false, forwarded: true };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
import { mkdir, readdir, rm } from "node:fs/promises";
|
|
59
59
|
import { join } from "node:path";
|
|
60
60
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
61
|
-
import {
|
|
61
|
+
import { removeStaleHashedFiles } from "./hashed-output.ts";
|
|
62
62
|
import {
|
|
63
63
|
uploadSourceMaps,
|
|
64
64
|
type SourceMapUploadResult,
|
|
@@ -265,9 +265,19 @@ export async function buildHydrateBundle(
|
|
|
265
265
|
}
|
|
266
266
|
const hashedName = entryOutput.path.split("/").pop() as string;
|
|
267
267
|
const hashedPath = join(outDir, hashedName);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
268
|
+
// The bundler OWNS this name (naming.entry above). Read the release hash
|
|
269
|
+
// out of it; a name that no longer matches the declared
|
|
270
|
+
// hydrate-<hash>.js shape means the naming contract changed underneath
|
|
271
|
+
// us — refuse rather than re-derive a second hash from file bytes
|
|
272
|
+
// (`one-generator-pattern`): a fallback here would produce a release id
|
|
273
|
+
// the URL does not carry.
|
|
274
|
+
const hashMatch = /^hydrate-([A-Za-z0-9]+)\.js$/.exec(hashedName);
|
|
275
|
+
if (!hashMatch) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`[build-client-assets] Bun.build entry output "${hashedName}" does not match the declared hydrate-<hash>.js naming — refusing to ship a bundle whose release id cannot be read from its own name`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
const hash = hashMatch[1];
|
|
271
281
|
removeStaleHashedFiles(outDir, HYDRATE_BASE, "js", hashedName);
|
|
272
282
|
|
|
273
283
|
// Splitting emits one chunk per lazy import() alongside the entry. Keep the
|
|
@@ -28,22 +28,10 @@ export interface AssetManifest {
|
|
|
28
28
|
// tombstone reads as unbuilt everywhere (verification, readiness, render).
|
|
29
29
|
stylesPath?: string;
|
|
30
30
|
hydratePath?: string;
|
|
31
|
-
// Content fingerprint of the CLIENT-BUNDLE DEPENDENCY CLOSURE (source entrypoints + @teamscala/* JS/CSS in the closure + third-party closure-dep
|
|
32
|
-
// versions), hashed by CONTENT — never mtime (cache-key-hashes-source- inputs-only). The boot/build freshness check rebuilds iff this differs
|
|
33
|
-
// from the current closure. mtime comparison is fundamentally broken on Railway: deploy-build bakes the bundle at image-build time, and under the
|
|
34
|
-
// hoisted linker + warm `bun-install` cache the installed @teamscala/* files are hardlinks carrying OLD extraction mtimes while the baked manifest's
|
|
35
|
-
// mtime is newer — so `source.mtime > manifest.mtime` is false and the build wrongly skips, baking (then serving forever) a STALE client bundle
|
|
36
|
-
// even though a newer @teamscala/* version is installed. Observed 2026-06-14:
|
|
37
|
-
// scala-gpt shipped ui-foundation < 85 in the bundle while a newer version
|
|
38
|
-
// was installed, so a published+installed fix never reached the browser.
|
|
39
|
-
// Content hashing of the closure is immune to mtime games. bun.lock is
|
|
40
|
-
// excluded by design — see computeInputFingerprint.
|
|
41
|
-
inputFingerprint?: string;
|
|
42
31
|
// TOMBSTONE fields — present iff the last build FAILED (no-silent-reverts).
|
|
43
32
|
// `buildFailed: true` + reason + timestamp are written by performBuild
|
|
44
|
-
// before it re-throws
|
|
45
|
-
//
|
|
46
|
-
// skip on the stale manifest). read-html-template reads these to stamp
|
|
33
|
+
// before it re-throws; every boot rebuilds unconditionally, so the failed
|
|
34
|
+
// build is retried by construction. read-html-template reads these to stamp
|
|
47
35
|
// the served HTML with the failure status (operator-visible signal that
|
|
48
36
|
// the bundle is stale, not fresh).
|
|
49
37
|
buildFailed?: boolean;
|
|
@@ -52,9 +40,9 @@ export interface AssetManifest {
|
|
|
52
40
|
// VERIFIED outcome of the last source-map upload attempt for THIS build's
|
|
53
41
|
// release (`uploaded` = every response checked 2xx; `failed` names each
|
|
54
42
|
// rejection in `failures`; `skipped` = no SENTRY_AUTH_TOKEN/SENTRY_URL).
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
43
|
+
// Every boot rebuilds and re-attempts the upload, so a failed upload is
|
|
44
|
+
// retried until it verifies — an unuploaded release must not silently
|
|
45
|
+
// persist forever.
|
|
58
46
|
sourceMaps?: SourceMapUploadResult;
|
|
59
47
|
}
|
|
60
48
|
|
|
@@ -94,7 +82,6 @@ export async function readManifest(outDir: string): Promise<AssetManifest | null
|
|
|
94
82
|
return {
|
|
95
83
|
stylesPath: parsed.stylesPath,
|
|
96
84
|
hydratePath: parsed.hydratePath,
|
|
97
|
-
inputFingerprint: parsed.inputFingerprint,
|
|
98
85
|
buildFailed: parsed.buildFailed,
|
|
99
86
|
buildFailedReason: parsed.buildFailedReason,
|
|
100
87
|
buildFailedAt: parsed.buildFailedAt,
|
|
@@ -12,6 +12,7 @@ import { mkdir, rm } from "node:fs/promises";
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { bunResolveStylesheet } from "./styles-build.ts";
|
|
15
|
+
import { compile } from "tailwindcss";
|
|
15
16
|
|
|
16
17
|
async function makeTree(tag: string, files: Record<string, string>) {
|
|
17
18
|
const root = join(tmpdir(), `styles-build-${tag}-${process.pid}`);
|
|
@@ -25,14 +26,14 @@ async function makeTree(tag: string, files: Record<string, string>) {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
describe("bunResolveStylesheet — relative ids", () => {
|
|
28
|
-
test("a relative import that exists resolves and
|
|
29
|
+
test("a relative import that exists resolves and returns raw content", async () => {
|
|
29
30
|
const root = await makeTree("rel-ok", {
|
|
30
31
|
"src/entry.css": '@import "./leaf.css";\n.body { color: red; }\n',
|
|
31
32
|
"src/leaf.css": ".leaf { color: blue; }\n",
|
|
32
33
|
});
|
|
33
34
|
const r = await bunResolveStylesheet("./entry.css", join(root, "src"), root);
|
|
34
35
|
expect(r.content).toContain(".body");
|
|
35
|
-
expect(r.content).toContain(".leaf");
|
|
36
|
+
expect(r.content).not.toContain(".leaf");
|
|
36
37
|
await rm(root, { recursive: true, force: true });
|
|
37
38
|
});
|
|
38
39
|
|
|
@@ -130,7 +131,7 @@ describe("bunResolveStylesheet — remote @imports", () => {
|
|
|
130
131
|
await rm(root, { recursive: true, force: true });
|
|
131
132
|
});
|
|
132
133
|
|
|
133
|
-
test("a LOCAL @import of a missing file THROWS at the
|
|
134
|
+
test("a LOCAL @import of a missing file THROWS at compile, not the resolver, instead of leaving a literal", async () => {
|
|
134
135
|
const root = await makeTree("local-missing-nested", {
|
|
135
136
|
"node_modules/broken-pkg/package.json": JSON.stringify({
|
|
136
137
|
name: "broken-pkg",
|
|
@@ -139,11 +140,13 @@ describe("bunResolveStylesheet — remote @imports", () => {
|
|
|
139
140
|
"node_modules/broken-pkg/index.css": '@import "./gone.css";\n.real { color: red; }\n',
|
|
140
141
|
"src/app.css": ".x { color: red; }\n",
|
|
141
142
|
});
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
const r = await bunResolveStylesheet("broken-pkg", join(root, "src"), root);
|
|
144
|
+
expect(r.content).toContain('@import "./gone.css"');
|
|
145
|
+
const err = await compile(r.content, {
|
|
146
|
+
async loadStylesheet(id, base) {
|
|
147
|
+
return bunResolveStylesheet(id, base, root);
|
|
148
|
+
},
|
|
149
|
+
}).catch((e) => e);
|
|
147
150
|
expect(err).toBeInstanceOf(Error);
|
|
148
151
|
expect((err as Error).message).toContain('"./gone.css"');
|
|
149
152
|
await rm(root, { recursive: true, force: true });
|
|
@@ -4,9 +4,19 @@
|
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* The Tailwind stylesheet unit of the client-asset build: Bun-native
|
|
7
|
-
* stylesheet resolution
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* stylesheet resolution returning RAW content, stripping of remote
|
|
8
|
+
* (CSP-forbidden) @imports, and the content-hashed styles-<hash>.css
|
|
9
|
+
* build.
|
|
10
|
+
*
|
|
11
|
+
* NESTED @IMPORTS ARE THE SUPPLIER'S JOB: tailwind 4.3.3's compile()
|
|
12
|
+
* re-invokes loadStylesheet for every @import INSIDE loaded content and
|
|
13
|
+
* splices the loaded nodes into the tree (verified in the installed
|
|
14
|
+
* dist source — the dr() recursion, depth-capped at 100), so a present
|
|
15
|
+
* nested leaf is inlined by the supplier and a missing one throws at
|
|
16
|
+
* compile; the suite pins both. The 2026-07-05 dropped-leaf outage was
|
|
17
|
+
* guarded by an in-process flattener here; with the supplier recursion
|
|
18
|
+
* verified the flattener is a second owner of the same fact and is
|
|
19
|
+
* deleted.
|
|
10
20
|
*
|
|
11
21
|
* Split out of build-client-assets.ts (962 lines) alongside its build
|
|
12
22
|
* siblings. Byte-for-byte move from the parent — imports adjusted, nothing
|
|
@@ -22,7 +32,7 @@ import { mkdir, rename } from "node:fs/promises";
|
|
|
22
32
|
|
|
23
33
|
const STYLES_BASE = "styles";
|
|
24
34
|
|
|
25
|
-
async function
|
|
35
|
+
export async function bunResolveStylesheet(
|
|
26
36
|
id: string,
|
|
27
37
|
base: string,
|
|
28
38
|
cwd: string,
|
|
@@ -118,65 +128,6 @@ async function resolveStylesheetRaw(
|
|
|
118
128
|
);
|
|
119
129
|
}
|
|
120
130
|
|
|
121
|
-
// Tailwind v4's compile() invokes `loadStylesheet` for DIRECT @imports in the entry (app.css) but does NOT re-invoke it for @imports WITHIN the loaded
|
|
122
|
-
// content — so a package styles.css that itself `@import "./Leaf.css"` (a nested RELATIVE import) has its leaf silently dropped from the prod bundle.
|
|
123
|
-
// This dropped every block-specific class reachable only via a nested
|
|
124
|
-
// relative @import (project-list-view/ProjectListView.css's
|
|
125
|
-
// .nested-list-table-host, audit-block's nested css, etc.) while direct
|
|
126
|
-
// package @imports (generic-ui/blk-utilities → .blk-*) survived — the
|
|
127
|
-
// 2026-07-05 #6 outage (broken projects UI). Inline nested @imports here so
|
|
128
|
-
// the content returned to Tailwind is flat; the compile() loadStylesheet
|
|
129
|
-
// callback then never sees a nested @import it would drop. Cycle-safe via
|
|
130
|
-
// `seen` (mirrors hashResolvedPackageStylesheet's transitive walk).
|
|
131
|
-
const CSS_IMPORT_RE = /@import\s+(?:url\()?\s*["']([^"']+)["']\s*\)?[^;]*;?/g;
|
|
132
|
-
async function inlineCssImports(
|
|
133
|
-
css: string,
|
|
134
|
-
base: string,
|
|
135
|
-
cwd: string,
|
|
136
|
-
seen: Set<string>,
|
|
137
|
-
): Promise<string> {
|
|
138
|
-
let result = "";
|
|
139
|
-
let lastIdx = 0;
|
|
140
|
-
for (const m of css.matchAll(CSS_IMPORT_RE)) {
|
|
141
|
-
const spec = m[1].trim();
|
|
142
|
-
result += css.slice(lastIdx, m.index);
|
|
143
|
-
lastIdx = (m.index ?? 0) + m[0].length;
|
|
144
|
-
// Keep the tailwindcss entry verbatim (Tailwind compile owns it —
|
|
145
|
-
// including any trailing `source(none)` etc. — via m[0]).
|
|
146
|
-
if (spec === "tailwindcss" || spec.startsWith("tailwindcss/")) {
|
|
147
|
-
result += m[0];
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
// Remote @imports are CSP-forbidden and stripped by
|
|
151
|
-
// stripRemoteImports AFTER the compile: skip them here EXPLICITLY —
|
|
152
|
-
// they must never reach resolveStylesheetRaw, and an unresolved
|
|
153
|
-
// LOCAL import below is a throw, not a swallow.
|
|
154
|
-
if (spec.startsWith("http://") || spec.startsWith("https://")) {
|
|
155
|
-
result += m[0];
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
if (seen.has(spec)) continue; // cycle guard
|
|
159
|
-
seen.add(spec);
|
|
160
|
-
const resolved = await resolveStylesheetRaw(spec, base, cwd);
|
|
161
|
-
result += await inlineCssImports(resolved.content, resolved.base, cwd, seen);
|
|
162
|
-
}
|
|
163
|
-
result += css.slice(lastIdx);
|
|
164
|
-
return result;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function bunResolveStylesheet(
|
|
168
|
-
id: string,
|
|
169
|
-
base: string,
|
|
170
|
-
cwd: string,
|
|
171
|
-
): Promise<{ path: string; base: string; content: string }> {
|
|
172
|
-
const resolved = await resolveStylesheetRaw(id, base, cwd);
|
|
173
|
-
if (id === "tailwindcss" || id.startsWith("tailwindcss/")) return resolved;
|
|
174
|
-
const content = await inlineCssImports(resolved.content, resolved.base, cwd, new Set([resolved.path]));
|
|
175
|
-
return { ...resolved, content };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export { bunResolveStylesheet };
|
|
179
|
-
|
|
180
131
|
// The app owns its fonts via an explicit, CSP-allowlisted <link> in index.html (Google Fonts — `style-src ... fonts.googleapis.com`, `font-src ...
|
|
181
132
|
// fonts.gstatic.com`). Third-party package CSS MUST NOT inject its own remote `@import url(http…)`: @puckeditor/core/dist/index.css opens with
|
|
182
133
|
// `@import "https://rsms.me/inter/inter.css"`, an arbitrary CDN our CSP does NOT allow. The Tailwind compile inlines that mid-output, where it was either
|
|
@@ -11,7 +11,6 @@ import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { dirname, join } from "node:path";
|
|
13
13
|
import { afterAll, describe, expect, test } from "bun:test";
|
|
14
|
-
import { computeInputFingerprint } from "./build-client-assets/input-fingerprint.ts";
|
|
15
14
|
import { stripRemoteImports } from "./build-client-assets/styles-build.ts";
|
|
16
15
|
|
|
17
16
|
describe("stripRemoteImports", () => {
|
|
@@ -48,163 +47,3 @@ describe("stripRemoteImports", () => {
|
|
|
48
47
|
});
|
|
49
48
|
});
|
|
50
49
|
|
|
51
|
-
// The client-bundle freshness check keys on input CONTENT, never mtime
|
|
52
|
-
// (cache-key-hashes-source-inputs-only). mtime comparison silently served a
|
|
53
|
-
// stale bundle on Railway (deploy-build bakes at image-build time; hoisted
|
|
54
|
-
// hardlinks carry old mtimes; baked manifest is newer → wrongly skips), so a
|
|
55
|
-
// published+installed @teamscala/* fix never reached the browser (2026-06-14).
|
|
56
|
-
// These tests prove the fingerprint changes iff an input's CONTENT changes.
|
|
57
|
-
describe("computeInputFingerprint", () => {
|
|
58
|
-
const dirs: string[] = [];
|
|
59
|
-
async function makeService(files: Record<string, string>): Promise<string> {
|
|
60
|
-
const dir = await mkdtemp(join(tmpdir(), "bca-fp-"));
|
|
61
|
-
dirs.push(dir);
|
|
62
|
-
await mkdir(join(dir, "src"), { recursive: true });
|
|
63
|
-
for (const [rel, content] of Object.entries(files)) {
|
|
64
|
-
await mkdir(dirname(join(dir, rel)), { recursive: true });
|
|
65
|
-
await Bun.file(join(dir, rel)).write(content);
|
|
66
|
-
}
|
|
67
|
-
return dir;
|
|
68
|
-
}
|
|
69
|
-
afterAll(async () => {
|
|
70
|
-
for (const d of dirs) await rm(d, { recursive: true, force: true });
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("identical inputs → identical fingerprint (skip path)", async () => {
|
|
74
|
-
const files = {
|
|
75
|
-
"bun.lock": `{ "ui-foundation": "99.100.86" }`,
|
|
76
|
-
"src/entry-client.ts": "export {};",
|
|
77
|
-
"src/page-manifest.ts": "export const M = {};",
|
|
78
|
-
"src/app.css": ".a{}",
|
|
79
|
-
};
|
|
80
|
-
const a = await makeService(files);
|
|
81
|
-
const b = await makeService(files);
|
|
82
|
-
expect(await computeInputFingerprint(a)).toBe(
|
|
83
|
-
await computeInputFingerprint(b),
|
|
84
|
-
);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
// `no-uncontrolled-repetition-or-cascade`: bun.lock is NOT an input. A
|
|
88
|
-
// version bump of a package NOT in this service's client bundle (here
|
|
89
|
-
// ui-foundation is named in bun.lock but never imported and not installed)
|
|
90
|
-
// MUST NOT invalidate — otherwise every backend @teamscala/* publish rebuilds
|
|
91
|
-
// every UI service (the publish→rebuild storm). The closure content hashes
|
|
92
|
-
// are unchanged, so the fingerprint is identical → skip → storm cannot form.
|
|
93
|
-
test("a bun.lock change for a NON-closure package → SAME fingerprint (no false rebuild)", async () => {
|
|
94
|
-
const base = {
|
|
95
|
-
"src/entry-client.ts": "export {};",
|
|
96
|
-
"src/page-manifest.ts": "export const M = {};",
|
|
97
|
-
"src/app.css": ".a{}",
|
|
98
|
-
};
|
|
99
|
-
const oldD = await makeService({
|
|
100
|
-
...base,
|
|
101
|
-
"bun.lock": `{ "ui-foundation": "99.100.85" }`,
|
|
102
|
-
});
|
|
103
|
-
const newD = await makeService({
|
|
104
|
-
...base,
|
|
105
|
-
"bun.lock": `{ "ui-foundation": "99.100.86" }`,
|
|
106
|
-
});
|
|
107
|
-
expect(await computeInputFingerprint(oldD)).toBe(
|
|
108
|
-
await computeInputFingerprint(newD),
|
|
109
|
-
);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
// The closure-scoped third-party boundary versions: a version bump of a
|
|
113
|
-
// third-party package that IS in the client closure still invalidates — the
|
|
114
|
-
// correctness property dropping bun.lock must not regress. The canonical case
|
|
115
|
-
// is react; a react version bump in the bundle MUST rebuild.
|
|
116
|
-
test("a third-party closure-dep version bump → different fingerprint (rebuild)", async () => {
|
|
117
|
-
const makeWith = (version: string) => ({
|
|
118
|
-
"src/entry-client.ts": `import "fake-react";\n`,
|
|
119
|
-
"src/page-manifest.ts": "export const M = {};\n",
|
|
120
|
-
"src/app.css": ".a{}",
|
|
121
|
-
"node_modules/fake-react/package.json":
|
|
122
|
-
`{"name":"fake-react","version":"${version}","exports":{".":"./index.js"}}\n`,
|
|
123
|
-
"node_modules/fake-react/index.js": `export const R = "${version}";\n`,
|
|
124
|
-
});
|
|
125
|
-
const before = await makeService(makeWith("1.0.0"));
|
|
126
|
-
const after = await makeService(makeWith("1.0.1"));
|
|
127
|
-
expect(await computeInputFingerprint(before)).not.toBe(
|
|
128
|
-
await computeInputFingerprint(after),
|
|
129
|
-
);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("a regenerated page-manifest → different fingerprint", async () => {
|
|
133
|
-
const base = {
|
|
134
|
-
"bun.lock": `{ "x": "1" }`,
|
|
135
|
-
"src/entry-client.ts": "export {};",
|
|
136
|
-
"src/app.css": ".a{}",
|
|
137
|
-
};
|
|
138
|
-
const before = await makeService({
|
|
139
|
-
...base,
|
|
140
|
-
"src/page-manifest.ts": "export const M = { Hero: 1 };",
|
|
141
|
-
});
|
|
142
|
-
const after = await makeService({
|
|
143
|
-
...base,
|
|
144
|
-
"src/page-manifest.ts": "export const M = { Hero: 1, Cta: 2 };",
|
|
145
|
-
});
|
|
146
|
-
expect(await computeInputFingerprint(before)).not.toBe(
|
|
147
|
-
await computeInputFingerprint(after),
|
|
148
|
-
);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("a missing input (backend service, no app.css) is stable, not a throw", async () => {
|
|
152
|
-
const dir = await makeService({
|
|
153
|
-
"bun.lock": `{ "x": "1" }`,
|
|
154
|
-
"src/entry-client.ts": "export {};",
|
|
155
|
-
"src/page-manifest.ts": "export const M = {};",
|
|
156
|
-
});
|
|
157
|
-
const fp1 = await computeInputFingerprint(dir);
|
|
158
|
-
const fp2 = await computeInputFingerprint(dir);
|
|
159
|
-
expect(fp1).toBe(fp2);
|
|
160
|
-
expect(fp1).toMatch(/^[a-f0-9]{16}$/);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// Regression guard for the 2026-07-12 scala-agents stale-bundle bug. A boot
|
|
164
|
-
// that builds MID-INSTALL (dep-sync restarts after writing the new
|
|
165
|
-
// bun.lock but before node_modules extraction finishes) bakes OLD @teamscala/*
|
|
166
|
-
// JS while the lock-derived fingerprint records "current" — so every later
|
|
167
|
-
// restart recomputes the same fingerprint, skips the rebuild, and serves the
|
|
168
|
-
// STALE bundle forever. Bun.build itself never caches stale modules (an
|
|
169
|
-
// isolated splitting+minify repro proved it reads fresh content across
|
|
170
|
-
// processes), so the defense is to hash the actual @teamgbg JS CONTENT the
|
|
171
|
-
// bundle is built from (the JS twin of hashResolvedPackageStylesheets). With
|
|
172
|
-
// that in the fingerprint, the freshly-installed bytes hash differently and
|
|
173
|
-
// the bundle self-heals on the next boot. (bun.lock + entry files are
|
|
174
|
-
// IDENTICAL across these two fixtures — only the @teamgbg JS body changes.)
|
|
175
|
-
test("a @teamgbg JS module content change → different fingerprint (mid-install stale-bake self-heal)", async () => {
|
|
176
|
-
const makeWith = (pkgJs: string) => ({
|
|
177
|
-
"bun.lock": `{ "locks": { "@teamscala/repro-pkg": "1.0.0" } }`,
|
|
178
|
-
"src/entry-client.ts": `import { M } from "@teamscala/repro-pkg";\nconsole.log(M);\n`,
|
|
179
|
-
"src/page-manifest.ts": "export const M = {};\n",
|
|
180
|
-
"src/app.css": ".a{}",
|
|
181
|
-
"node_modules/@teamscala/repro-pkg/package.json":
|
|
182
|
-
`{"name":"@teamscala/repro-pkg","version":"1.0.0","exports":{".":"./index.ts"}}\n`,
|
|
183
|
-
"node_modules/@teamscala/repro-pkg/index.ts": pkgJs,
|
|
184
|
-
});
|
|
185
|
-
const before = await makeService(makeWith("export const M = 'AAA_REPRO';\n"));
|
|
186
|
-
const after = await makeService(makeWith("export const M = 'BBB_REPRO';\n"));
|
|
187
|
-
expect(await computeInputFingerprint(before)).not.toBe(
|
|
188
|
-
await computeInputFingerprint(after),
|
|
189
|
-
);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
// The JS graph walk runs bounded-concurrent; the aggregate must be stable
|
|
193
|
-
// across runs (independent of async scheduling) — the per-file hashes are
|
|
194
|
-
// folded in sorted path order.
|
|
195
|
-
test("@teamgbg JS graph fingerprint is deterministic across runs", async () => {
|
|
196
|
-
const dir = await makeService({
|
|
197
|
-
"bun.lock": `{ "locks": { "@teamscala/repro-pkg": "1.0.0" } }`,
|
|
198
|
-
"src/entry-client.ts": `import { M } from "@teamscala/repro-pkg";\nconsole.log(M);\n`,
|
|
199
|
-
"src/page-manifest.ts": "export const M = {};\n",
|
|
200
|
-
"src/app.css": ".a{}",
|
|
201
|
-
"node_modules/@teamscala/repro-pkg/package.json":
|
|
202
|
-
`{"name":"@teamscala/repro-pkg","version":"1.0.0","exports":{".":"./index.ts"}}\n`,
|
|
203
|
-
"node_modules/@teamscala/repro-pkg/index.ts": "export const M = 'STABLE';\n",
|
|
204
|
-
});
|
|
205
|
-
const a = await computeInputFingerprint(dir);
|
|
206
|
-
const b = await computeInputFingerprint(dir);
|
|
207
|
-
expect(a).toBe(b);
|
|
208
|
-
expect(a).toMatch(/^[a-f0-9]{16}$/);
|
|
209
|
-
});
|
|
210
|
-
});
|