@teamscala/service-boot 0.23.358 → 0.23.360
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 +1 -1
- package/src/attach-transfers-upload.ts +10 -0
- package/src/csrf-body-limit.test.ts +177 -0
- package/src/csrf-protection.ts +100 -0
- package/src/security-headers.test.ts +82 -0
- package/src/two-phase-boot.ts +22 -0
package/package.json
CHANGED
|
@@ -62,6 +62,16 @@ export async function attachTransfersUpload(app: Hono): Promise<void> {
|
|
|
62
62
|
const userId = session?.user?.id;
|
|
63
63
|
if (!userId) return c.json({ error: "Not authenticated" }, 401);
|
|
64
64
|
|
|
65
|
+
// Reject on Content-Length BEFORE formData() buffers the body — a
|
|
66
|
+
// file.size check after buffering is a report, not a limit (an attacker
|
|
67
|
+
// sends the bytes either way). The global 30MB boot-level bodyLimit
|
|
68
|
+
// remains the real bound for lying/absent Content-Length; this early
|
|
69
|
+
// return bounds the honest case without parsing a megabyte stream.
|
|
70
|
+
const declaredLength = Number(c.req.header("content-length") ?? 0);
|
|
71
|
+
if (declaredLength > MAX_FILE_SIZE) {
|
|
72
|
+
return c.json({ error: "file too large (max 25MB)" }, 413);
|
|
73
|
+
}
|
|
74
|
+
|
|
65
75
|
const form = await c.req.formData();
|
|
66
76
|
const file = form.get("file");
|
|
67
77
|
const type = String(form.get("type") ?? "uploads");
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Behavior proofs for the two boot-chain security invariants:
|
|
7
|
+
* - security-csrf-protection: a state-changing form-element POST from an
|
|
8
|
+
* untrusted Origin is refused 403; the managed-base-domain Origin (the
|
|
9
|
+
* derived trusted set) is allowed; same-origin sec-fetch-site is allowed.
|
|
10
|
+
* - security-body-limit: a body over the global limit is refused 413 BEFORE
|
|
11
|
+
* any handler runs, and the transfers upload rejects an over-sized
|
|
12
|
+
* Content-Length without buffering the body.
|
|
13
|
+
* Proxy-is-not-behaviour guards: these exercise the real middleware through
|
|
14
|
+
* real Hono requests.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, it } from "bun:test";
|
|
17
|
+
import { Hono } from "hono";
|
|
18
|
+
import { bodyLimit } from "hono/body-limit";
|
|
19
|
+
import { csrf } from "hono/csrf";
|
|
20
|
+
import { csrfProtection } from "./csrf-protection.ts";
|
|
21
|
+
|
|
22
|
+
type CacheGlobal = typeof globalThis & {
|
|
23
|
+
__registryCache?: Map<string, { type: string; slug: string; config: unknown }>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function seedPortalBaseDomain(base: string): void {
|
|
27
|
+
const g = globalThis as CacheGlobal;
|
|
28
|
+
if (!g.__registryCache) g.__registryCache = new Map();
|
|
29
|
+
g.__registryCache.set("config:portal-domains", {
|
|
30
|
+
type: "config",
|
|
31
|
+
slug: "portal-domains",
|
|
32
|
+
config: { base },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("csrfProtection", () => {
|
|
37
|
+
it("refuses a cross-site form POST from an untrusted origin (403)", async () => {
|
|
38
|
+
seedPortalBaseDomain("scala.business");
|
|
39
|
+
const app = new Hono();
|
|
40
|
+
app.use("*", csrfProtection());
|
|
41
|
+
app.post("/api/settings", (c) => c.text("ok"));
|
|
42
|
+
const res = await app.request("https://portal.scala.business/api/settings", {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
46
|
+
origin: "https://evil.example",
|
|
47
|
+
},
|
|
48
|
+
body: "x=1",
|
|
49
|
+
});
|
|
50
|
+
expect(res.status).toBe(403);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("allows a form POST whose origin is under the managed base domain", async () => {
|
|
54
|
+
seedPortalBaseDomain("scala.business");
|
|
55
|
+
const app = new Hono();
|
|
56
|
+
app.use("*", csrfProtection());
|
|
57
|
+
app.post("/api/settings", (c) => c.text("ok"));
|
|
58
|
+
const res = await app.request("https://portal.scala.business/api/settings", {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: {
|
|
61
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
62
|
+
// cross-SERVICE, same platform: the proposals → sign redirect-chain shape
|
|
63
|
+
origin: "https://sign.scala.business",
|
|
64
|
+
},
|
|
65
|
+
body: "x=1",
|
|
66
|
+
});
|
|
67
|
+
expect(res.status).toBe(200);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("allows a same-origin browser post via sec-fetch-site", async () => {
|
|
71
|
+
seedPortalBaseDomain("scala.business");
|
|
72
|
+
const app = new Hono();
|
|
73
|
+
app.use("*", csrfProtection());
|
|
74
|
+
app.post("/api/settings", (c) => c.text("ok"));
|
|
75
|
+
const res = await app.request("https://portal.scala.business/api/settings", {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: {
|
|
78
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
79
|
+
"sec-fetch-site": "same-origin",
|
|
80
|
+
},
|
|
81
|
+
body: "x=1",
|
|
82
|
+
});
|
|
83
|
+
expect(res.status).toBe(200);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does not arm on application/json (cross-site JSON is not formable)", async () => {
|
|
87
|
+
seedPortalBaseDomain("scala.business");
|
|
88
|
+
const app = new Hono();
|
|
89
|
+
app.use("*", csrfProtection());
|
|
90
|
+
app.post("/api/rpc/x", (c) => c.text("ok"));
|
|
91
|
+
const res = await app.request("https://portal.scala.business/api/rpc/x", {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "content-type": "application/json", origin: "https://evil.example" },
|
|
94
|
+
body: JSON.stringify({ x: 1 }),
|
|
95
|
+
});
|
|
96
|
+
expect(res.status).toBe(200);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("global body limit (security-body-limit)", () => {
|
|
101
|
+
const limit = 1024;
|
|
102
|
+
|
|
103
|
+
function appWithLimit(): Hono {
|
|
104
|
+
const app = new Hono();
|
|
105
|
+
let handlerRan = false;
|
|
106
|
+
app.use(
|
|
107
|
+
"*",
|
|
108
|
+
bodyLimit({
|
|
109
|
+
maxSize: limit,
|
|
110
|
+
onError: (c) => c.text("Payload Too Large", 413),
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
app.post("/api/upload", async (c) => {
|
|
114
|
+
handlerRan = true;
|
|
115
|
+
return c.text("ok");
|
|
116
|
+
});
|
|
117
|
+
// expose the flag after the request via a closure read below
|
|
118
|
+
(app as unknown as Record<string, unknown>).__handlerRan = () => handlerRan;
|
|
119
|
+
return app;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
it("refuses an oversized body with 413 before the handler runs", async () => {
|
|
123
|
+
const app = appWithLimit();
|
|
124
|
+
const res = await app.request("https://host.example/api/upload", {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: { "content-type": "application/octet-stream" },
|
|
127
|
+
body: "x".repeat(limit + 1),
|
|
128
|
+
});
|
|
129
|
+
expect(res.status).toBe(413);
|
|
130
|
+
expect(
|
|
131
|
+
(app as unknown as { __handlerRan: () => boolean }).__handlerRan(),
|
|
132
|
+
).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("allows a body within the limit", async () => {
|
|
136
|
+
const app = appWithLimit();
|
|
137
|
+
const res = await app.request("https://host.example/api/upload", {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "content-type": "application/octet-stream" },
|
|
140
|
+
body: "x".repeat(limit - 1),
|
|
141
|
+
});
|
|
142
|
+
expect(res.status).toBe(200);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("a route-level smaller limit still refuses what the global limit allows", async () => {
|
|
146
|
+
const app = new Hono();
|
|
147
|
+
app.use(
|
|
148
|
+
"*",
|
|
149
|
+
bodyLimit({
|
|
150
|
+
maxSize: 4 * limit,
|
|
151
|
+
onError: (c) => c.text("Payload Too Large", 413),
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
app.post(
|
|
155
|
+
"/api/rpc/*",
|
|
156
|
+
bodyLimit({
|
|
157
|
+
maxSize: limit,
|
|
158
|
+
onError: (c) => c.text("Request body too large", 413),
|
|
159
|
+
}),
|
|
160
|
+
(c) => c.text("ok"),
|
|
161
|
+
);
|
|
162
|
+
const res = await app.request("https://host.example/api/rpc/x", {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { "content-type": "application/json" },
|
|
165
|
+
body: "x".repeat(2 * limit),
|
|
166
|
+
});
|
|
167
|
+
expect(res.status).toBe(413);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("csrf import wiring", () => {
|
|
172
|
+
it("csrfProtection() composes hono csrf without options drift", () => {
|
|
173
|
+
const mw = csrfProtection();
|
|
174
|
+
expect(typeof mw).toBe("function");
|
|
175
|
+
expect(typeof csrf).toBe("function");
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* csrf-protection.ts — platform CSRF middleware (`security-csrf-protection`).
|
|
7
|
+
*
|
|
8
|
+
* Hono's csrf() with a DERIVED trusted-origin set, never a hand-authored
|
|
9
|
+
* allowlist: every trusted origin comes from ONE source per
|
|
10
|
+
* `offered-is-derived` —
|
|
11
|
+
* - the managed base domain (registry portal-domains) covers the platform's
|
|
12
|
+
* own `*.scala.business` origins, INCLUDING the cross-service form POST
|
|
13
|
+
* redirect chains CSP form-action allowlists (proposals → sign etc.), so
|
|
14
|
+
* the CSRF origin set and the CSP form-action set cannot disagree about
|
|
15
|
+
* what "one of ours" means;
|
|
16
|
+
* - customer custom domains resolve through `isTrustedAuthOrigin`
|
|
17
|
+
* (builda_websites lookup) — the SAME lookup better-auth's trustedOrigins
|
|
18
|
+
* uses in @teamscala/auth, so a new custom domain is trusted everywhere at
|
|
19
|
+
* once or nowhere.
|
|
20
|
+
*
|
|
21
|
+
* hono csrf() arms only on form-element content types (urlencoded, multipart,
|
|
22
|
+
* text/plain) — browsers cannot produce a cross-site application/json request
|
|
23
|
+
* from a form, so the JSON API surface (ORPC) is protected by cookie
|
|
24
|
+
* SameSite=Lax + this middleware's text/plain coverage.
|
|
25
|
+
*
|
|
26
|
+
* Custom-domain lookups are cached per-hostname with a TTL: a POSITIVE result
|
|
27
|
+
* is cached for the full TTL (a domain that stopped being ours keeps working
|
|
28
|
+
* for at most the TTL), a NEGATIVE result for a short window (a brand-new
|
|
29
|
+
* custom domain becomes trusted within seconds). The cache is a request
|
|
30
|
+
* accelerator over a DB fact, never the source of truth.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { getManagedBaseDomain } from "@teamscala/auth/auth-env-resolution";
|
|
34
|
+
import { isTrustedAuthOrigin } from "@teamscala/auth/is-trusted-auth-origin";
|
|
35
|
+
import { csrf } from "hono/csrf";
|
|
36
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
37
|
+
|
|
38
|
+
const POSITIVE_TTL_MS = 5 * 60_000;
|
|
39
|
+
const NEGATIVE_TTL_MS = 10_000;
|
|
40
|
+
|
|
41
|
+
type CacheEntry = { trusted: boolean; expiresAt: number };
|
|
42
|
+
const customDomainCache = new Map<string, CacheEntry>();
|
|
43
|
+
|
|
44
|
+
function isPlatformOrigin(origin: string, baseDomain: string): boolean {
|
|
45
|
+
let host: string;
|
|
46
|
+
try {
|
|
47
|
+
host = new URL(origin).hostname.toLowerCase();
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (!host) return false;
|
|
52
|
+
return host === baseDomain || host.endsWith(`.${baseDomain}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function isTrustedCustomOrigin(origin: string): Promise<boolean> {
|
|
56
|
+
let host: string;
|
|
57
|
+
try {
|
|
58
|
+
host = new URL(origin).hostname.toLowerCase();
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
if (!host) return false;
|
|
63
|
+
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
const cached = customDomainCache.get(host);
|
|
66
|
+
if (cached && cached.expiresAt > now) return cached.trusted;
|
|
67
|
+
|
|
68
|
+
const trusted = await (async () => {
|
|
69
|
+
try {
|
|
70
|
+
const { getPrisma } = await import("@teamscala/db/prisma-registry");
|
|
71
|
+
const prisma = getPrisma();
|
|
72
|
+
return await isTrustedAuthOrigin(
|
|
73
|
+
prisma as unknown as Parameters<typeof isTrustedAuthOrigin>[0],
|
|
74
|
+
host,
|
|
75
|
+
);
|
|
76
|
+
} catch {
|
|
77
|
+
// A lookup that cannot run is a lookup that trusts nothing: an
|
|
78
|
+
// unavailable DB degrades to platform-origin-only CSRF, never to
|
|
79
|
+
// allow-everything.
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
|
|
84
|
+
customDomainCache.set(host, {
|
|
85
|
+
trusted,
|
|
86
|
+
expiresAt: now + (trusted ? POSITIVE_TTL_MS : NEGATIVE_TTL_MS),
|
|
87
|
+
});
|
|
88
|
+
if (customDomainCache.size > 512) customDomainCache.clear();
|
|
89
|
+
return trusted;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function csrfProtection(): MiddlewareHandler {
|
|
93
|
+
return csrf({
|
|
94
|
+
origin: async (origin: string, _c: Context) => {
|
|
95
|
+
const baseDomain = getManagedBaseDomain();
|
|
96
|
+
if (isPlatformOrigin(origin, baseDomain)) return true;
|
|
97
|
+
return isTrustedCustomOrigin(origin);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Behavior proof for the `security-content-type-nosniff` +
|
|
7
|
+
* `security-permissions-policy` invariants: exercise injectSecurityHeaders
|
|
8
|
+
* through a real Hono request and assert the headers on the RESPONSE —
|
|
9
|
+
* a registry row naming a header is not evidence the header is served.
|
|
10
|
+
* Covers both modes: the seeded-registry path (config row applied, CSP
|
|
11
|
+
* {{nonce}} substituted) and the DB-less fallback (secureHeaders defaults).
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from "bun:test";
|
|
14
|
+
import { Hono } from "hono";
|
|
15
|
+
import { injectSecurityHeaders } from "./security-headers.ts";
|
|
16
|
+
|
|
17
|
+
type CacheGlobal = typeof globalThis & {
|
|
18
|
+
__registryCache?: Map<string, { type: string; slug: string; config: unknown }>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function seedRegistryHeaders(config: Record<string, string>): void {
|
|
22
|
+
const g = globalThis as CacheGlobal;
|
|
23
|
+
if (!g.__registryCache) g.__registryCache = new Map();
|
|
24
|
+
g.__registryCache.set("config:security-headers", {
|
|
25
|
+
type: "config",
|
|
26
|
+
slug: "security-headers",
|
|
27
|
+
config,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function clearRegistryHeaders(): void {
|
|
32
|
+
(globalThis as CacheGlobal).__registryCache?.delete("config:security-headers");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function appWithMiddleware(): Hono {
|
|
36
|
+
const app = new Hono();
|
|
37
|
+
app.use("*", injectSecurityHeaders);
|
|
38
|
+
app.get("/", (c) => c.text("ok"));
|
|
39
|
+
return app;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("injectSecurityHeaders (registry mode)", () => {
|
|
43
|
+
it("serves X-Content-Type-Options: nosniff and the configured Permissions-Policy", async () => {
|
|
44
|
+
seedRegistryHeaders({
|
|
45
|
+
"X-Content-Type-Options": "nosniff",
|
|
46
|
+
"Permissions-Policy": "camera=(), microphone=(self), geolocation=()",
|
|
47
|
+
"Content-Security-Policy": "script-src 'nonce-{{nonce}}' 'strict-dynamic'",
|
|
48
|
+
});
|
|
49
|
+
try {
|
|
50
|
+
const res = await appWithMiddleware().request("https://host.example/");
|
|
51
|
+
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
52
|
+
expect(res.headers.get("Permissions-Policy")).toBe(
|
|
53
|
+
"camera=(), microphone=(self), geolocation=()",
|
|
54
|
+
);
|
|
55
|
+
} finally {
|
|
56
|
+
clearRegistryHeaders();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("substitutes the CSP {{nonce}} placeholder — the served policy carries a real nonce", async () => {
|
|
61
|
+
seedRegistryHeaders({
|
|
62
|
+
"Content-Security-Policy": "script-src 'nonce-{{nonce}}' 'strict-dynamic'",
|
|
63
|
+
});
|
|
64
|
+
try {
|
|
65
|
+
const res = await appWithMiddleware().request("https://host.example/");
|
|
66
|
+
const csp = res.headers.get("Content-Security-Policy") ?? "";
|
|
67
|
+
expect(csp).not.toContain("{{nonce}}");
|
|
68
|
+
expect(csp).toMatch(/'nonce-[^']+'/);
|
|
69
|
+
} finally {
|
|
70
|
+
clearRegistryHeaders();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("injectSecurityHeaders (fallback mode, no registry)", () => {
|
|
76
|
+
it("still serves X-Content-Type-Options: nosniff from secureHeaders defaults", async () => {
|
|
77
|
+
clearRegistryHeaders();
|
|
78
|
+
const res = await appWithMiddleware().request("https://host.example/");
|
|
79
|
+
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
80
|
+
expect((res.headers.get("Content-Security-Policy") ?? "")).not.toContain("{{nonce}}");
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/two-phase-boot.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
|
|
|
18
18
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
19
19
|
import { createTimer } from "@teamscala/timing/create-timer";
|
|
20
20
|
import { Hono, type Context } from "hono";
|
|
21
|
+
import { bodyLimit } from "hono/body-limit";
|
|
21
22
|
import { compress } from "hono/compress";
|
|
22
23
|
import { cors } from "hono/cors";
|
|
23
24
|
import { timing } from "hono/timing";
|
|
@@ -33,6 +34,7 @@ import { attachProposalMagic } from "./attach-proposal-magic.ts";
|
|
|
33
34
|
import { attachPortalMembers } from "./attach-portal-members.ts";
|
|
34
35
|
import { attachTransfersUpload } from "./attach-transfers-upload.ts";
|
|
35
36
|
import { attachAuthGate } from "./auth-gate.ts";
|
|
37
|
+
import { csrfProtection } from "./csrf-protection.ts";
|
|
36
38
|
import { attachRequestLogger } from "./install-logging.ts";
|
|
37
39
|
import type { ServiceInstallConfig } from "./load-service-install-row.ts";
|
|
38
40
|
import { mountOrpcRouter } from "./mount-orpc-router.ts";
|
|
@@ -276,6 +278,26 @@ export async function buildMinimalApp(
|
|
|
276
278
|
);
|
|
277
279
|
app.use("*", compress());
|
|
278
280
|
app.use("*", timing());
|
|
281
|
+
// CSRF (`security-csrf-protection`): state-changing form-element requests
|
|
282
|
+
// must carry a trusted Origin — derived set (managed base domain +
|
|
283
|
+
// builda_websites custom domains via isTrustedAuthOrigin), never an
|
|
284
|
+
// allowlist beside better-auth's. Registered after security headers, before
|
|
285
|
+
// every route + gate.
|
|
286
|
+
app.use("*", csrfProtection());
|
|
287
|
+
// GLOBAL body limit (`security-body-limit`): every request body is bounded
|
|
288
|
+
// BEFORE any handler buffers it — a per-route file.size check AFTER
|
|
289
|
+
// formData() has buffered is a report, not a limit. 30MB sits above the
|
|
290
|
+
// 25MB transfers-upload maximum; routes wanting a smaller bound mount their
|
|
291
|
+
// own bodyLimit BEFORE this one (Hono runs matching middleware in
|
|
292
|
+
// registration order, and /api/rpc registers its 10MB limit in
|
|
293
|
+
// mount-orpc-router).
|
|
294
|
+
app.use(
|
|
295
|
+
"*",
|
|
296
|
+
bodyLimit({
|
|
297
|
+
maxSize: 30 * 1024 * 1024,
|
|
298
|
+
onError: (c) => c.text("Payload Too Large", 413),
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
279
301
|
attachRequestLogger(app, serviceSlug);
|
|
280
302
|
|
|
281
303
|
// Fire independent I/O + compilation NOW, overlapping with the gate chain
|