@cosmicdrift/kumiko-server-runtime 0.368.0 → 0.370.0
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/dist/boot/boot-crypto.js +17 -11
- package/dist/boot/worker-boot-core.d.ts +1 -1
- package/dist/boot-options/billing-option.d.ts +34 -0
- package/dist/boot-options/billing-option.js +35 -0
- package/dist/boot-options/demo-mode.d.ts +51 -0
- package/dist/boot-options/demo-mode.js +64 -0
- package/dist/boot-options/file-storage-option.d.ts +30 -0
- package/dist/boot-options/file-storage-option.js +52 -0
- package/dist/boot-options/kms-mode.d.ts +27 -0
- package/dist/boot-options/kms-mode.js +40 -0
- package/dist/boot-options/mail-option.d.ts +30 -0
- package/dist/boot-options/mail-option.js +44 -0
- package/dist/boot-options/option-env-schema.d.ts +7 -0
- package/dist/boot-options/option-env-schema.js +113 -0
- package/dist/boot-options/process-signals.d.ts +23 -0
- package/dist/boot-options/process-signals.js +58 -0
- package/dist/boot-options/resolve-boot-options.d.ts +44 -0
- package/dist/boot-options/resolve-boot-options.js +88 -0
- package/dist/boot-options/seed-shortcuts.d.ts +49 -0
- package/dist/boot-options/seed-shortcuts.js +92 -0
- package/dist/boot-options/stack-boot-requirements.d.ts +6 -0
- package/dist/boot-options/stack-boot-requirements.js +63 -0
- package/dist/build-prod-bundle.d.ts +4 -0
- package/dist/build-prod-bundle.js +41 -2
- package/dist/csp-hashes.d.ts +15 -0
- package/dist/csp-hashes.js +46 -0
- package/dist/index.d.ts +18 -9
- package/dist/index.js +1 -0
- package/dist/rate-limit-defaults.d.ts +19 -0
- package/dist/rate-limit-defaults.js +13 -0
- package/dist/resolve-unsubscribe-wiring.d.ts +17 -0
- package/dist/resolve-unsubscribe-wiring.js +24 -0
- package/dist/run-bootstrap.d.ts +2 -2
- package/dist/run-prod-app-boot-context.d.ts +5 -3
- package/dist/run-prod-app-boot-context.js +4 -2
- package/dist/run-prod-app-static-files.d.ts +6 -2
- package/dist/run-prod-app-static-files.js +22 -1
- package/dist/run-prod-app.d.ts +77 -32
- package/dist/run-prod-app.js +172 -120
- package/dist/run-worker-app.d.ts +2 -2
- package/dist/security-headers.d.ts +16 -5
- package/dist/security-headers.js +56 -5
- package/package.json +18 -9
- package/src/changes.json +43 -0
package/dist/run-prod-app.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// (gleiche Logik wie runDevApp).
|
|
17
17
|
//
|
|
18
18
|
// App-Author schreibt:
|
|
19
|
-
// await runProdApp({ features, auth, anonymousAccess, seeds });
|
|
19
|
+
// await runProdApp({ features, envSchema, auth, anonymousAccess, seeds });
|
|
20
20
|
//
|
|
21
21
|
// Container/Coolify setzt:
|
|
22
22
|
// DATABASE_URL=postgresql://...
|
|
@@ -42,20 +42,19 @@
|
|
|
42
42
|
import { AuthErrors, AuthHandlers, AuthQueries, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
43
43
|
import { seedAdminGuarded, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
44
44
|
import { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER, resolveAnonymousAccessFromRegistry, resolveTokenVerifier, } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
45
|
-
import { AUTH_MFA_FEATURE, AuthMfaHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
46
|
-
import { createUnsubscribeRoutes, } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
45
|
+
import { AUTH_MFA_FEATURE, AuthMfaHandlers, bindMfaTokenSecretsFromFeature, } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
47
46
|
import { PAT_FEATURE, patRateLimitFromFeature, } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
48
47
|
import { SECRETS_MASTER_KEK_ENV_KEYS } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
49
48
|
import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
50
49
|
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
51
50
|
import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
52
|
-
import { createDefaultSseBroker, createRedisLoginRateLimiter, loadJwtSecretOrKeyring, parseTrustedProxyHopsEnv,
|
|
51
|
+
import { createDefaultSseBroker, createRedisLoginRateLimiter, loadJwtSecretOrKeyring, parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, withFileProviderResolver, } from "@cosmicdrift/kumiko-framework/api";
|
|
53
52
|
import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
|
|
54
53
|
import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
|
|
55
54
|
import { collectWriteHandlerQns, createRegistry, findTierResolverUsage, requireTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
|
|
56
55
|
import { createAllInOneEntrypoint, createApiEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
57
|
-
import { KumikoBootError, kmsSlotsOf, parseEnv, withOptionalEnvKeys, } from "@cosmicdrift/kumiko-framework/env";
|
|
58
|
-
import { renderDryRun } from "@cosmicdrift/kumiko-framework/env/dry-run";
|
|
56
|
+
import { inspectEnv, KumikoBootError, kmsSlotsOf, parseEnv, withOptionalEnvKeys, } from "@cosmicdrift/kumiko-framework/env";
|
|
57
|
+
import { DRY_RUN_ENV_VAR, parseDryRunEnvMode, renderDryRun, renderEnvInspection, } from "@cosmicdrift/kumiko-framework/env/dry-run";
|
|
59
58
|
import { createEsOperationsTable, createSeedMigrationContext, runPendingSeedMigrations, } from "@cosmicdrift/kumiko-framework/es-ops";
|
|
60
59
|
import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/event-store";
|
|
61
60
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
@@ -71,21 +70,39 @@ import { jobRunLoggerCallbacks } from "./boot/job-run-logger.js";
|
|
|
71
70
|
import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
|
|
72
71
|
import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
|
|
73
72
|
import { waitForDatabase, waitUntilDatabaseReachable } from "./boot/wait-for-database.js";
|
|
73
|
+
import { withBootOptionEnvBlocks } from "./boot-options/option-env-schema.js";
|
|
74
|
+
import { installShutdownSignals } from "./boot-options/process-signals.js";
|
|
75
|
+
import { resolveBootOptionWiring } from "./boot-options/resolve-boot-options.js";
|
|
76
|
+
import { assertStackBootRequirements } from "./boot-options/stack-boot-requirements.js";
|
|
74
77
|
import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options.js";
|
|
75
78
|
import { buildComposeAuthOptions, composeFeatures } from "./compose-features.js";
|
|
76
79
|
import { makeDispatchSystemWrite } from "./extra-routes-deps.js";
|
|
77
80
|
import { assertPiiBootInvariants } from "./pii-boot-gate.js";
|
|
78
|
-
import {
|
|
81
|
+
import { resolveRateLimit } from "./rate-limit-defaults.js";
|
|
82
|
+
import { resolveAuthPublicOrigin } from "./resolve-auth-public-origin.js";
|
|
83
|
+
import { resolveUnsubscribeWiring } from "./resolve-unsubscribe-wiring.js";
|
|
79
84
|
import { addConfigAccessorFactory, buildBootExtraContext, buildProdSessionAuth, loggerFromExtraContext, resolveAuthMail, wireProdPatAutoRevoke, } from "./run-prod-app-boot-context.js";
|
|
80
|
-
import { buildStaticFallback } from "./run-prod-app-static-files.js";
|
|
85
|
+
import { buildStaticFallback, resolveCspScriptHashes } from "./run-prod-app-static-files.js";
|
|
81
86
|
import { withSecurityHeaders } from "./security-headers.js";
|
|
82
87
|
import { makeSeedCreateFileRef, withSeedErrorContext, } from "./seed-dispatcher.js";
|
|
83
88
|
import { assertSessionBootInvariants } from "./session-boot-gate.js";
|
|
84
89
|
import { shouldWireProdSessions } from "./session-wiring.js";
|
|
85
90
|
import { stripNoRouteMatchHeader } from "./try-hono-first.js";
|
|
86
91
|
export { buildBunServeOptions } from "./bun-serve-options.js";
|
|
92
|
+
export { resolveUnsubscribeWiring, } from "./resolve-unsubscribe-wiring.js";
|
|
87
93
|
export { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.js";
|
|
88
94
|
export { staticCachePolicy } from "./run-prod-app-static-files.js";
|
|
95
|
+
// runProdApp reads these itself, so the boot dry run lists them even when
|
|
96
|
+
// the app schema leaves them out (JWT_SECRET usually comes from auth-email-password).
|
|
97
|
+
const RUNTIME_REQUIRED_ENV_KEYS = ["DATABASE_URL", "REDIS_URL", "JWT_SECRET"];
|
|
98
|
+
function inspectUndeclaredRuntimeEnv(declaredShape, env) {
|
|
99
|
+
return RUNTIME_REQUIRED_ENV_KEYS.filter((name) => !(name in declaredShape)).map((name) => ({
|
|
100
|
+
name,
|
|
101
|
+
klass: "required",
|
|
102
|
+
source: "server-runtime",
|
|
103
|
+
status: env[name] ? "set" : "missing",
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
89
106
|
// Strict env-var read. Throws with a clear hint when missing — better
|
|
90
107
|
// than discovering a Postgres-connection-refused 30s into the boot.
|
|
91
108
|
// `src` defaults to process.env but is threaded from the caller's envSource
|
|
@@ -108,16 +125,14 @@ function readEnv(name, src = process.env) {
|
|
|
108
125
|
return value === undefined || value === "" ? undefined : value;
|
|
109
126
|
}
|
|
110
127
|
function parseRunMode(raw) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
console.warn(`[runProdApp] KUMIKO_DRY_RUN_ENV="${raw}" unrecognized ` +
|
|
120
|
-
`(expected 1|human|json|pulumi|k8s|boot); continuing with normal boot.`);
|
|
128
|
+
const mode = parseDryRunEnvMode(raw);
|
|
129
|
+
if (mode !== undefined)
|
|
130
|
+
return mode;
|
|
131
|
+
if (raw) {
|
|
132
|
+
// biome-ignore lint/suspicious/noConsole: boot-time warn for typo discovery
|
|
133
|
+
console.warn(`[runProdApp] ${DRY_RUN_ENV_VAR}="${raw}" unrecognized ` +
|
|
134
|
+
`(expected 1|human|json|pulumi|k8s|boot); continuing with normal boot.`);
|
|
135
|
+
}
|
|
121
136
|
return null;
|
|
122
137
|
}
|
|
123
138
|
function isRenderMode(mode) {
|
|
@@ -161,6 +176,19 @@ function warnLegacyJwtSecretOnce() {
|
|
|
161
176
|
"that is no longer present in any in-flight token (see resolveAuthMail / " +
|
|
162
177
|
"loadJwtSecretOrKeyring).");
|
|
163
178
|
}
|
|
179
|
+
// JS callers and apps built against older typings can still omit it.
|
|
180
|
+
function requireEnvSchema(envSchema) {
|
|
181
|
+
if (envSchema === undefined) {
|
|
182
|
+
throw new KumikoBootError([
|
|
183
|
+
{
|
|
184
|
+
name: "envSchema",
|
|
185
|
+
kind: "missing",
|
|
186
|
+
message: "runProdApp needs envSchema. Pass composeEnvSchema({ core: frameworkCoreEnvSchema, features, extend }): composeEnvSchema from @cosmicdrift/kumiko-framework/env, frameworkCoreEnvSchema from @cosmicdrift/kumiko-dev-server/env-schema, features the same list as runProdApp's.",
|
|
187
|
+
},
|
|
188
|
+
]);
|
|
189
|
+
}
|
|
190
|
+
return envSchema;
|
|
191
|
+
}
|
|
164
192
|
export async function runProdApp(options) {
|
|
165
193
|
assertSeedModeNotInProduction();
|
|
166
194
|
// 0. Env-Schema validation + dry-run modes. Runs FIRST so:
|
|
@@ -170,9 +198,9 @@ export async function runProdApp(options) {
|
|
|
170
198
|
// with ALL problems aggregated (not first-fail), before we waste
|
|
171
199
|
// seconds on a Postgres connection that was never configured.
|
|
172
200
|
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
201
|
+
// The dry-run exits sit before every other env read (KEK decrypt,
|
|
202
|
+
// JWT secret, MFA token secrets), so a build pipeline without deploy
|
|
203
|
+
// secrets can run them.
|
|
176
204
|
//
|
|
177
205
|
// Ordering invariant: this step runs BEFORE the Temporal polyfill,
|
|
178
206
|
// so env-schemas MUST use only Temporal-free Zod types. Don't author
|
|
@@ -181,63 +209,96 @@ export async function runProdApp(options) {
|
|
|
181
209
|
// .min / .email / .url cover every env-var shape we've actually
|
|
182
210
|
// needed in 9.1's audit (37 references, 25 distinct vars).
|
|
183
211
|
const rawEnvSource = options.envSource ?? process.env;
|
|
184
|
-
const runMode = parseRunMode(rawEnvSource[
|
|
185
|
-
const composedEnvSchema = options.envSchema;
|
|
212
|
+
const runMode = parseRunMode(rawEnvSource[DRY_RUN_ENV_VAR]);
|
|
213
|
+
const composedEnvSchema = withBootOptionEnvBlocks(requireEnvSchema(options.envSchema), options);
|
|
214
|
+
const effectiveEnvSchema = options.masterKey
|
|
215
|
+
? withOptionalEnvKeys(composedEnvSchema.schema, SECRETS_MASTER_KEK_ENV_KEYS)
|
|
216
|
+
: composedEnvSchema.schema;
|
|
217
|
+
const reportBootError = (err) => {
|
|
218
|
+
const reporter = options.bootErrorReporter ?? defaultBootErrorReporter;
|
|
219
|
+
reporter(err);
|
|
220
|
+
};
|
|
186
221
|
const validateEnv = (source) => {
|
|
187
|
-
// skip: app declares no env schema, nothing to validate
|
|
188
|
-
if (!composedEnvSchema)
|
|
189
|
-
return;
|
|
190
222
|
try {
|
|
191
|
-
|
|
192
|
-
? withOptionalEnvKeys(composedEnvSchema.schema, SECRETS_MASTER_KEK_ENV_KEYS)
|
|
193
|
-
: composedEnvSchema.schema;
|
|
194
|
-
parseEnv(schema, source, {
|
|
223
|
+
parseEnv(effectiveEnvSchema, source, {
|
|
195
224
|
sources: composedEnvSchema.sources,
|
|
196
225
|
...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
|
|
197
226
|
});
|
|
198
227
|
}
|
|
199
228
|
catch (err) {
|
|
200
|
-
if (err instanceof KumikoBootError)
|
|
201
|
-
|
|
202
|
-
reporter(err);
|
|
203
|
-
}
|
|
229
|
+
if (err instanceof KumikoBootError)
|
|
230
|
+
reportBootError(err);
|
|
204
231
|
throw err;
|
|
205
232
|
}
|
|
206
233
|
};
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
234
|
+
if (isRenderMode(runMode)) {
|
|
235
|
+
// biome-ignore lint/suspicious/noConsole: dry-run output IS the deliverable
|
|
236
|
+
console.log(renderDryRun(composedEnvSchema, runMode, {
|
|
237
|
+
...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
|
|
238
|
+
sources: composedEnvSchema.sources,
|
|
239
|
+
}));
|
|
240
|
+
// Tests inject envSource and want a return-value, not exit. Detecting
|
|
241
|
+
// "this is a test" via envSource is brittle; instead exit when running
|
|
242
|
+
// against the real process.env (the deploy-flow), return otherwise.
|
|
243
|
+
if (options.envSource === undefined) {
|
|
244
|
+
process.exit(0);
|
|
245
|
+
}
|
|
246
|
+
return makeDryRunHandle();
|
|
247
|
+
}
|
|
248
|
+
if (runMode === "boot") {
|
|
249
|
+
const inspection = [
|
|
250
|
+
...inspectEnv(effectiveEnvSchema, rawEnvSource, { sources: composedEnvSchema.sources }),
|
|
251
|
+
...inspectUndeclaredRuntimeEnv(effectiveEnvSchema.shape, rawEnvSource),
|
|
252
|
+
];
|
|
253
|
+
// biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
|
|
254
|
+
console.log(renderEnvInspection(inspection));
|
|
255
|
+
if (inspection.some((row) => row.status === "invalid")) {
|
|
256
|
+
// Missing rows go into the same report: the run fails anyway, so the
|
|
257
|
+
// operator sees every variable to fix in one pass.
|
|
258
|
+
reportBootError(new KumikoBootError(inspection.flatMap((row) => row.status === "invalid" || row.status === "missing"
|
|
259
|
+
? [
|
|
260
|
+
{
|
|
261
|
+
name: row.name,
|
|
262
|
+
kind: row.status,
|
|
263
|
+
message: row.message ?? (row.status === "missing" ? "missing" : "invalid value"),
|
|
264
|
+
source: row.source,
|
|
265
|
+
},
|
|
266
|
+
]
|
|
267
|
+
: [])));
|
|
268
|
+
return makeDryRunHandle();
|
|
269
|
+
}
|
|
270
|
+
// A build pipeline has no deploy secrets: the inventory is the result,
|
|
271
|
+
// and the wiring check below needs every value, so it stops here.
|
|
272
|
+
if (inspection.some((row) => row.status === "missing")) {
|
|
217
273
|
if (options.envSource === undefined) {
|
|
218
274
|
process.exit(0);
|
|
219
275
|
}
|
|
220
276
|
return makeDryRunHandle();
|
|
221
277
|
}
|
|
222
|
-
// boot-mode AND normal-boot both run env-validation. boot-mode wants
|
|
223
|
-
// a real env-check (all required vars present + schema-valid) before
|
|
224
|
-
// it asserts feature-wiring works.
|
|
225
|
-
validateEnv(rawEnvSource);
|
|
226
278
|
}
|
|
279
|
+
// boot-mode with a complete env AND normal boot run the full validation,
|
|
280
|
+
// including object-level refinements the per-field check skips.
|
|
281
|
+
validateEnv(rawEnvSource);
|
|
227
282
|
// Slots the schema marks `kms` are decrypted once here so every consumer
|
|
228
283
|
// below (boot probe, master-key keyring, config) sees the same resolved env.
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
})
|
|
234
|
-
: rawEnvSource;
|
|
284
|
+
const decryptedEnvSource = await resolvePlatformKeks(rawEnvSource, {
|
|
285
|
+
slots: kmsSlotsOf(composedEnvSchema.schema),
|
|
286
|
+
logPrefix: "[runProdApp]",
|
|
287
|
+
});
|
|
235
288
|
// The first parse relaxes ciphertext-only slots, so their decrypted
|
|
236
289
|
// plaintext never met the slot's own validators (.regex/.min/.refine).
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
validateEnv(envSource);
|
|
290
|
+
if (kmsSlotsOf(composedEnvSchema.schema).some((slot) => rawEnvSource[slot] === undefined && decryptedEnvSource[slot] !== undefined)) {
|
|
291
|
+
validateEnv(decryptedEnvSource);
|
|
240
292
|
}
|
|
293
|
+
// Stacks whose boot options are missing and the declarative boot options
|
|
294
|
+
// (mail, kmsMode, fileStorage, billing, demoMode, seed shortcuts) fail here, before any connection. Their defaults sit under
|
|
295
|
+
// the env so an explicitly set variable wins.
|
|
296
|
+
assertStackBootRequirements(options);
|
|
297
|
+
const bootOptionWiring = await resolveBootOptionWiring(options, decryptedEnvSource);
|
|
298
|
+
const envSource = Object.keys(bootOptionWiring.envDefaults).length > 0
|
|
299
|
+
? { ...bootOptionWiring.envDefaults, ...decryptedEnvSource }
|
|
300
|
+
: decryptedEnvSource;
|
|
301
|
+
const bootKms = bootOptionWiring.kms;
|
|
241
302
|
// 1. Polyfill before anything else — feature code references Temporal.
|
|
242
303
|
const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
|
|
243
304
|
await ensureTemporalPolyfill();
|
|
@@ -274,8 +335,9 @@ export async function runProdApp(options) {
|
|
|
274
335
|
// rate limiters collapse into one shared bucket behind a reverse proxy.
|
|
275
336
|
// biome-ignore lint/suspicious/noConsole: boot-time warn, no logger configured this early
|
|
276
337
|
console.warn(`[runProdApp] trustedProxyHops / ${TRUSTED_PROXY_HOPS_ENV} is not set; client IP falls back to the socket address. ` +
|
|
277
|
-
"Behind a reverse proxy all clients share one rate-limit bucket.");
|
|
338
|
+
"Behind a reverse proxy all clients share one rate-limit bucket, so the default L1/L2 limits throttle the whole site at once.");
|
|
278
339
|
}
|
|
340
|
+
const resolvedRateLimit = resolveRateLimit(options.rateLimit);
|
|
279
341
|
const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
|
|
280
342
|
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
281
343
|
console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
|
|
@@ -283,7 +345,9 @@ export async function runProdApp(options) {
|
|
|
283
345
|
// die Feature-Side (buildComposeAuthOptions) als auch das Routes-Fragment
|
|
284
346
|
// unten speisen. Ab hier IMMER effectiveAuth statt options.auth lesen.
|
|
285
347
|
const effectiveAuth = options.auth
|
|
286
|
-
? resolveAuthMail(options.auth, jwtSecret, envSource
|
|
348
|
+
? resolveAuthMail(options.auth, jwtSecret, envSource, {
|
|
349
|
+
mailConfigured: bootOptionWiring.mailConfigured,
|
|
350
|
+
})
|
|
287
351
|
: undefined;
|
|
288
352
|
const authPublicOrigin = effectiveAuth
|
|
289
353
|
? resolveAuthPublicOrigin(effectiveAuth, envSource, (message) => {
|
|
@@ -291,34 +355,20 @@ export async function runProdApp(options) {
|
|
|
291
355
|
console.warn(message);
|
|
292
356
|
})
|
|
293
357
|
: undefined;
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
:
|
|
300
|
-
|
|
301
|
-
createFallbackLogger("runProdApp").warn(`unsubscribe is enabled but no valid public app URL is known (auth.publicOrigin, auth.mail.baseUrl or ${APP_BASE_URL_ENV}), so mails get no unsubscribe link.`);
|
|
302
|
-
}
|
|
303
|
-
const unsubscribeLinks = options.unsubscribe && unsubscribeBaseUrl !== undefined
|
|
304
|
-
? {
|
|
305
|
-
secret: options.unsubscribe.secret,
|
|
306
|
-
baseUrl: unsubscribeBaseUrl,
|
|
307
|
-
...(options.unsubscribe.tenantOrigin !== undefined && {
|
|
308
|
-
tenantOrigin: options.unsubscribe.tenantOrigin,
|
|
309
|
-
}),
|
|
310
|
-
}
|
|
311
|
-
: undefined;
|
|
312
|
-
const unsubscribeRoutes = options.unsubscribe
|
|
313
|
-
? createUnsubscribeRoutes({ secret: options.unsubscribe.secret })
|
|
314
|
-
: [];
|
|
358
|
+
const { unsubscribeLinks, unsubscribeRoutes } = resolveUnsubscribeWiring({
|
|
359
|
+
unsubscribe: options.unsubscribe,
|
|
360
|
+
publicOrigin: effectiveAuth?.publicOrigin,
|
|
361
|
+
mailBaseUrl: effectiveAuth?.mail?.baseUrl,
|
|
362
|
+
envSource,
|
|
363
|
+
warn: (message) => createFallbackLogger("runProdApp").warn(message),
|
|
364
|
+
});
|
|
315
365
|
// 3. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
|
|
316
366
|
// password via composeFeatures — same source-of-truth as runDevApp
|
|
317
367
|
// AND the per-app drizzle-Schema-Generator, so Migration und Runtime
|
|
318
368
|
// sehen exakt dieselbe Liste. Built BEFORE any connection so boot-mode
|
|
319
369
|
// can validate wiring and exit without opening a Postgres/Redis socket.
|
|
320
370
|
const composeAuthOptions = buildComposeAuthOptions(effectiveAuth);
|
|
321
|
-
const features = composeFeatures(options.features, {
|
|
371
|
+
const features = composeFeatures([...options.features, ...bootOptionWiring.features], {
|
|
322
372
|
includeBundled: !!effectiveAuth,
|
|
323
373
|
...(composeAuthOptions && { authOptions: composeAuthOptions }),
|
|
324
374
|
});
|
|
@@ -331,9 +381,9 @@ export async function runProdApp(options) {
|
|
|
331
381
|
warnIfNonUtcServerTimeZone();
|
|
332
382
|
validateAppCustomScreenWriteQns(process.cwd(), collectWriteHandlerQns(features));
|
|
333
383
|
assertPiiBootInvariants(features, {
|
|
334
|
-
kms:
|
|
335
|
-
blindIndexKey:
|
|
336
|
-
allowPlaintextPii:
|
|
384
|
+
kms: bootKms.kms,
|
|
385
|
+
blindIndexKey: bootKms.blindIndexKey,
|
|
386
|
+
allowPlaintextPii: bootKms.allowPlaintextPii,
|
|
337
387
|
mode: "prod",
|
|
338
388
|
});
|
|
339
389
|
const sessionsFeature = features.find((f) => f.name === SESSIONS_FEATURE);
|
|
@@ -349,6 +399,7 @@ export async function runProdApp(options) {
|
|
|
349
399
|
if (runMode === "boot") {
|
|
350
400
|
// biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
|
|
351
401
|
console.log(`[runProdApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`);
|
|
402
|
+
await bootKms.close();
|
|
352
403
|
if (options.envSource === undefined) {
|
|
353
404
|
process.exit(0);
|
|
354
405
|
}
|
|
@@ -361,8 +412,8 @@ export async function runProdApp(options) {
|
|
|
361
412
|
// KMS health gate: an app configured for crypto-shredding must not boot
|
|
362
413
|
// against an unreachable key store — a silent start would answer every
|
|
363
414
|
// PII read with 503. Runs BEFORE connections so an abort leaks nothing.
|
|
364
|
-
if (
|
|
365
|
-
const kms =
|
|
415
|
+
if (bootKms.kms) {
|
|
416
|
+
const kms = bootKms.kms;
|
|
366
417
|
const kmsHealth = await waitUntilDatabaseReachable(() => kms.health(), {
|
|
367
418
|
processName: "runProdApp",
|
|
368
419
|
});
|
|
@@ -464,12 +515,12 @@ export async function runProdApp(options) {
|
|
|
464
515
|
// No adapter only gets this far when assertPiiBootInvariants above let it
|
|
465
516
|
// through: no PII carriers, or an explicit allowPlaintextPii opt-out. The
|
|
466
517
|
// engine then stays off and those fields are stored in plaintext.
|
|
467
|
-
configurePiiSubjectKms(
|
|
518
|
+
configurePiiSubjectKms(bootKms.kms);
|
|
468
519
|
// Blind-Index-Key (#818) — Boot-Gate: mit aktivem KMS würden lookupable-
|
|
469
520
|
// Felder als Ciphertext gespeichert, ohne Key gäbe es keinen bidx-Arm und
|
|
470
521
|
// jeder Equality-Lookup (Login by email!) liefe ins Leere. Fail-fast statt
|
|
471
522
|
// silent-broken-auth.
|
|
472
|
-
configureBlindIndexKey(
|
|
523
|
+
configureBlindIndexKey(bootKms.blindIndexKey);
|
|
473
524
|
const bootLogger = loggerFromExtraContext(resolvedExtraContext);
|
|
474
525
|
const autoExtraContext = buildBootExtraContext({
|
|
475
526
|
db,
|
|
@@ -480,7 +531,7 @@ export async function runProdApp(options) {
|
|
|
480
531
|
sseBroker,
|
|
481
532
|
redis,
|
|
482
533
|
crypto: bootCrypto,
|
|
483
|
-
...(
|
|
534
|
+
...(bootKms.kms && { kms: bootKms.kms }),
|
|
484
535
|
...(bootLogger && { log: bootLogger }),
|
|
485
536
|
...(unsubscribeLinks && { unsubscribeLinks }),
|
|
486
537
|
});
|
|
@@ -506,6 +557,8 @@ export async function runProdApp(options) {
|
|
|
506
557
|
// über die auth-routes.
|
|
507
558
|
// Secure-by-default (#1372): sessionStore provider → resolveSessionStore.
|
|
508
559
|
const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
|
|
560
|
+
if (mfaFeature)
|
|
561
|
+
bindMfaTokenSecretsFromFeature(mfaFeature)?.(jwtSecret, envSource);
|
|
509
562
|
if (mfaFeature && registry.getWriteHandler(AuthHandlers.switchTenantMfaGate) === undefined) {
|
|
510
563
|
throw new Error(`[runProdApp] auth-mfa is mounted but ${AuthHandlers.switchTenantMfaGate} is not registered: ` +
|
|
511
564
|
"createAuthEmailPasswordFeature registers it only when mfaStatusChecker is set, and without it " +
|
|
@@ -562,11 +615,15 @@ export async function runProdApp(options) {
|
|
|
562
615
|
...(options.observability && { observability: options.observability }),
|
|
563
616
|
...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
|
|
564
617
|
...(options.metrics && { metrics: options.metrics }),
|
|
565
|
-
...(
|
|
618
|
+
...(resolvedRateLimit && { rateLimit: resolvedRateLimit }),
|
|
566
619
|
...(trustedProxyHops !== undefined && { trustedProxyHops }),
|
|
567
620
|
...(options.errorDocs && { errorDocs: options.errorDocs }),
|
|
568
|
-
...((options.extraRoutes || options.unsubscribe) && {
|
|
569
|
-
extraRoutes: [
|
|
621
|
+
...((options.extraRoutes || options.unsubscribe || bootOptionWiring.extraRoutes.length > 0) && {
|
|
622
|
+
extraRoutes: [
|
|
623
|
+
...(options.extraRoutes ?? []),
|
|
624
|
+
...unsubscribeRoutes,
|
|
625
|
+
...bootOptionWiring.extraRoutes,
|
|
626
|
+
],
|
|
570
627
|
}),
|
|
571
628
|
...(effectiveAuth && {
|
|
572
629
|
auth: {
|
|
@@ -731,12 +788,16 @@ export async function runProdApp(options) {
|
|
|
731
788
|
if (effectiveAuth?.admin) {
|
|
732
789
|
await seedAdminGuarded(db, effectiveAuth.admin);
|
|
733
790
|
}
|
|
791
|
+
for (const seed of bootOptionWiring.shortcutSeeds) {
|
|
792
|
+
await seed(db);
|
|
793
|
+
}
|
|
734
794
|
await applyBootSeeds({
|
|
735
795
|
registry,
|
|
736
796
|
db,
|
|
737
797
|
...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
|
|
738
798
|
});
|
|
739
|
-
const
|
|
799
|
+
const customSeeds = bootOptionWiring.customSeeds;
|
|
800
|
+
const hasSeeds = customSeeds.length > 0 || options.seedsDir !== undefined;
|
|
740
801
|
const seedContext = hasSeeds
|
|
741
802
|
? withFileProviderResolver(registry, { db, redis, entityCache, registry, ...extraContext }, cacheSync)
|
|
742
803
|
: undefined;
|
|
@@ -748,7 +809,7 @@ export async function runProdApp(options) {
|
|
|
748
809
|
: undefined;
|
|
749
810
|
if (seedDispatcher && seedContext) {
|
|
750
811
|
const createFileRef = makeSeedCreateFileRef({ db, registry, context: seedContext });
|
|
751
|
-
for (const seed of
|
|
812
|
+
for (const seed of customSeeds) {
|
|
752
813
|
await seed({ db, registry, dispatcher: seedDispatcher, createFileRef });
|
|
753
814
|
}
|
|
754
815
|
}
|
|
@@ -780,7 +841,8 @@ export async function runProdApp(options) {
|
|
|
780
841
|
// wired via a wrapper so Hono owns /api/* + extraRoutes and disk
|
|
781
842
|
// owns the rest. Tests use this directly; listen() wraps it in
|
|
782
843
|
// Bun.serve.
|
|
783
|
-
const
|
|
844
|
+
const cspScriptHashes = await resolveCspScriptHashes(options.staticDir, options.securityHeaders);
|
|
845
|
+
const fetchHandler = withSecurityHeaders(bootOptionWiring.wrapFetch(options.staticDir
|
|
784
846
|
? buildStaticFallback(entrypoint.app.fetch.bind(entrypoint.app), options.staticDir, options.hostDispatch, options.resolvePageHead
|
|
785
847
|
? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
|
|
786
848
|
: undefined, entrypoint.dispatcher, trustedProxyHops ?? 0)
|
|
@@ -789,12 +851,13 @@ export async function runProdApp(options) {
|
|
|
789
851
|
// (and with it tryHonoFirst) entirely. Must strip the router-miss
|
|
790
852
|
// marker here too, same reason as the /api/* passthrough in
|
|
791
853
|
// run-prod-app-static-files.ts (see try-hono-first.ts).
|
|
792
|
-
async (req, socketAddress) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, socketAddress)), options.securityHeaders);
|
|
854
|
+
async (req, socketAddress) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, socketAddress))), options.securityHeaders, cspScriptHashes);
|
|
793
855
|
// 11. Mark lifecycle ready — health/ready flips to 200 after this.
|
|
794
856
|
entrypoint.lifecycle.markReady();
|
|
795
857
|
// Upgrade rejections (401/403/426...) are ordinary responses and get the same headers as fetchHandler.
|
|
796
|
-
const securedUpgradeFetch = withSecurityHeaders(async (req, env) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, env)), options.securityHeaders);
|
|
858
|
+
const securedUpgradeFetch = withSecurityHeaders(async (req, env) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, env)), options.securityHeaders, cspScriptHashes);
|
|
797
859
|
const webSocketUpgradeFetch = async (req, env) => securedUpgradeFetch(req, env);
|
|
860
|
+
let uninstallShutdownSignals;
|
|
798
861
|
const handle = {
|
|
799
862
|
entrypoint,
|
|
800
863
|
fetch: fetchHandler,
|
|
@@ -816,29 +879,15 @@ export async function runProdApp(options) {
|
|
|
816
879
|
// SIGTERM/SIGINT — graceful shutdown. Only registered when we
|
|
817
880
|
// actually own a Bun-server, otherwise the test process picks up
|
|
818
881
|
// signals it shouldn't respond to.
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
await handle.stop();
|
|
829
|
-
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
830
|
-
console.log("[runProdApp] graceful shutdown complete.");
|
|
831
|
-
}
|
|
832
|
-
catch (e) {
|
|
833
|
-
// biome-ignore lint/suspicious/noConsole: shutdown-time error, only path is stderr
|
|
834
|
-
console.error("[runProdApp] error during shutdown:", e);
|
|
835
|
-
}
|
|
836
|
-
finally {
|
|
837
|
-
process.exit(0);
|
|
838
|
-
}
|
|
839
|
-
};
|
|
840
|
-
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
841
|
-
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
882
|
+
if (options.signals !== false) {
|
|
883
|
+
uninstallShutdownSignals?.();
|
|
884
|
+
uninstallShutdownSignals = installShutdownSignals({
|
|
885
|
+
stop: () => handle.stop(),
|
|
886
|
+
...(options.signals?.drainTimeoutMs !== undefined && {
|
|
887
|
+
drainTimeoutMs: options.signals.drainTimeoutMs,
|
|
888
|
+
}),
|
|
889
|
+
});
|
|
890
|
+
}
|
|
842
891
|
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
843
892
|
console.log(`[runProdApp] ready on http://0.0.0.0:${listenPort}`);
|
|
844
893
|
},
|
|
@@ -849,6 +898,9 @@ export async function runProdApp(options) {
|
|
|
849
898
|
await closeDb();
|
|
850
899
|
await closeOutsideTransactionDb();
|
|
851
900
|
redis.disconnect();
|
|
901
|
+
await bootKms.close();
|
|
902
|
+
uninstallShutdownSignals?.();
|
|
903
|
+
uninstallShutdownSignals = undefined;
|
|
852
904
|
},
|
|
853
905
|
};
|
|
854
906
|
// 12. Auto-listen unless explicitly suppressed (tests pass autoListen:
|
package/dist/run-worker-app.d.ts
CHANGED
|
@@ -7,8 +7,8 @@ import type { JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
|
|
|
7
7
|
import { type ObservabilityOptions, type ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observability";
|
|
8
8
|
import type { MasterKeyProvider, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
9
9
|
import type { Redis } from "ioredis";
|
|
10
|
-
import { type WorkerMetricsOptions, type WorkerMetricsServer } from "./boot/worker-metrics-server.
|
|
11
|
-
import { type SystemWireDeps } from "./extra-routes-deps.
|
|
10
|
+
import { type WorkerMetricsOptions, type WorkerMetricsServer } from "./boot/worker-metrics-server.ts";
|
|
11
|
+
import { type SystemWireDeps } from "./extra-routes-deps.ts";
|
|
12
12
|
export type WorkerContextOption = Record<string, unknown> | ((deps: WorkerDeps) => Record<string, unknown>);
|
|
13
13
|
export type WorkerDeps = {
|
|
14
14
|
readonly db: DbConnection;
|
|
@@ -11,10 +11,21 @@ export type SecurityHeadersOption = false | {
|
|
|
11
11
|
/** `Referrer-Policy` value, or `false` to omit.
|
|
12
12
|
* Default: `strict-origin-when-cross-origin`. */
|
|
13
13
|
readonly referrerPolicy?: string | false;
|
|
14
|
-
/** `Content-Security-Policy`
|
|
15
|
-
*
|
|
14
|
+
/** `Content-Security-Policy` for ALL responses.
|
|
15
|
+
* - omitted: the built-in default (see `DEFAULT_CSP_DIRECTIVES`) with the
|
|
16
|
+
* inline-script hashes from `dist/csp-hashes.json`.
|
|
17
|
+
* - `false`: no CSP header.
|
|
18
|
+
* - string: replaces the default entirely (no hashes are added).
|
|
19
|
+
* - `{ extend }`: adds sources to the default directives, or new directives.
|
|
16
20
|
* A per-host CSP from `hostDispatch` wins over this value. */
|
|
17
|
-
readonly csp?:
|
|
21
|
+
readonly csp?: CspOption;
|
|
18
22
|
};
|
|
19
|
-
export
|
|
20
|
-
export
|
|
23
|
+
export type CspExtension = Readonly<Record<string, readonly string[]>>;
|
|
24
|
+
export type CspOption = false | string | {
|
|
25
|
+
readonly extend: CspExtension;
|
|
26
|
+
};
|
|
27
|
+
export declare const DEFAULT_CSP_DIRECTIVES: Readonly<Record<string, readonly string[]>>;
|
|
28
|
+
export declare function isScriptHash(value: unknown): value is string;
|
|
29
|
+
export declare function buildDefaultCsp(scriptHashes: readonly string[], extend?: CspExtension): string;
|
|
30
|
+
export declare function resolveSecurityHeaders(option: SecurityHeadersOption | undefined, scriptHashes?: readonly string[]): ReadonlyArray<readonly [string, string]>;
|
|
31
|
+
export declare function withSecurityHeaders<TServeArg = string>(handler: (req: Request, serveArg?: TServeArg) => Response | Promise<Response>, option: SecurityHeadersOption | undefined, scriptHashes?: readonly string[]): (req: Request, serveArg?: TServeArg) => Response | Promise<Response>;
|
package/dist/security-headers.js
CHANGED
|
@@ -1,7 +1,57 @@
|
|
|
1
1
|
const DEFAULT_HSTS = "max-age=31536000; includeSubDomains";
|
|
2
2
|
const DEFAULT_FRAME_OPTIONS = "DENY";
|
|
3
3
|
const DEFAULT_REFERRER_POLICY = "strict-origin-when-cross-origin";
|
|
4
|
-
export
|
|
4
|
+
export const DEFAULT_CSP_DIRECTIVES = {
|
|
5
|
+
"default-src": ["'self'"],
|
|
6
|
+
"script-src": ["'self'"],
|
|
7
|
+
"style-src": ["'self'", "'unsafe-inline'"],
|
|
8
|
+
"img-src": ["'self'", "data:", "blob:"],
|
|
9
|
+
"connect-src": ["'self'"],
|
|
10
|
+
"frame-ancestors": ["'none'"],
|
|
11
|
+
"base-uri": ["'self'"],
|
|
12
|
+
"form-action": ["'self'"],
|
|
13
|
+
"object-src": ["'none'"],
|
|
14
|
+
};
|
|
15
|
+
const SCRIPT_HASH_PATTERN = /^sha256-[A-Za-z0-9+/]{43}=$/;
|
|
16
|
+
const DIRECTIVE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
17
|
+
// Separators that would let an extension value end its own
|
|
18
|
+
// directive, start a new one or break out of the header line.
|
|
19
|
+
const FORBIDDEN_SOURCE_CHARS = /[;,\s]/;
|
|
20
|
+
export function isScriptHash(value) {
|
|
21
|
+
return typeof value === "string" && SCRIPT_HASH_PATTERN.test(value);
|
|
22
|
+
}
|
|
23
|
+
function assertValidExtension(extend) {
|
|
24
|
+
for (const [directive, sources] of Object.entries(extend)) {
|
|
25
|
+
if (!DIRECTIVE_NAME_PATTERN.test(directive)) {
|
|
26
|
+
throw new Error(`[security-headers] csp.extend: invalid directive name "${directive}".`);
|
|
27
|
+
}
|
|
28
|
+
for (const source of sources) {
|
|
29
|
+
if (source.length === 0 || FORBIDDEN_SOURCE_CHARS.test(source)) {
|
|
30
|
+
throw new Error(`[security-headers] csp.extend["${directive}"]: source "${source}" must be a single token without whitespace, ";" or ",".`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function buildDefaultCsp(scriptHashes, extend = {}) {
|
|
36
|
+
assertValidExtension(extend);
|
|
37
|
+
const directives = new Map(Object.entries(DEFAULT_CSP_DIRECTIVES).map(([name, sources]) => [name, [...sources]]));
|
|
38
|
+
directives.get("script-src")?.push(...scriptHashes.map((hash) => `'${hash}'`));
|
|
39
|
+
for (const [name, sources] of Object.entries(extend)) {
|
|
40
|
+
const existing = directives.get(name) ?? [];
|
|
41
|
+
// 'none' is only valid as the sole source, so extending it replaces it.
|
|
42
|
+
const base = existing.length === 1 && existing[0] === "'none'" ? [] : existing;
|
|
43
|
+
directives.set(name, [...new Set([...base, ...sources])]);
|
|
44
|
+
}
|
|
45
|
+
return [...directives].map(([name, sources]) => [name, ...sources].join(" ")).join("; ");
|
|
46
|
+
}
|
|
47
|
+
function resolveCsp(option, scriptHashes) {
|
|
48
|
+
if (option === false)
|
|
49
|
+
return null;
|
|
50
|
+
if (typeof option === "string")
|
|
51
|
+
return option.length > 0 ? option : null;
|
|
52
|
+
return buildDefaultCsp(scriptHashes, option?.extend);
|
|
53
|
+
}
|
|
54
|
+
export function resolveSecurityHeaders(option, scriptHashes = []) {
|
|
5
55
|
if (option === false)
|
|
6
56
|
return [];
|
|
7
57
|
const opt = option ?? {};
|
|
@@ -17,14 +67,15 @@ export function resolveSecurityHeaders(option) {
|
|
|
17
67
|
const referrerPolicy = opt.referrerPolicy ?? DEFAULT_REFERRER_POLICY;
|
|
18
68
|
if (referrerPolicy !== false)
|
|
19
69
|
headers.push(["referrer-policy", referrerPolicy]);
|
|
20
|
-
|
|
21
|
-
|
|
70
|
+
const csp = resolveCsp(opt.csp, scriptHashes);
|
|
71
|
+
if (csp !== null)
|
|
72
|
+
headers.push(["content-security-policy", csp]);
|
|
22
73
|
return headers;
|
|
23
74
|
}
|
|
24
75
|
// Sets each header only when absent so per-response values (e.g. the
|
|
25
76
|
// per-host CSP from hostDispatch) always win over the runtime default.
|
|
26
|
-
export function withSecurityHeaders(handler, option) {
|
|
27
|
-
const defaults = resolveSecurityHeaders(option);
|
|
77
|
+
export function withSecurityHeaders(handler, option, scriptHashes = []) {
|
|
78
|
+
const defaults = resolveSecurityHeaders(option, scriptHashes);
|
|
28
79
|
if (defaults.length === 0)
|
|
29
80
|
return handler;
|
|
30
81
|
return async (req, serveArg) => {
|