@cosmicdrift/kumiko-server-runtime 0.369.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.
Files changed (42) hide show
  1. package/dist/boot/boot-crypto.js +17 -11
  2. package/dist/boot/worker-boot-core.d.ts +1 -1
  3. package/dist/boot-options/billing-option.d.ts +34 -0
  4. package/dist/boot-options/billing-option.js +35 -0
  5. package/dist/boot-options/demo-mode.d.ts +51 -0
  6. package/dist/boot-options/demo-mode.js +64 -0
  7. package/dist/boot-options/file-storage-option.d.ts +30 -0
  8. package/dist/boot-options/file-storage-option.js +52 -0
  9. package/dist/boot-options/kms-mode.d.ts +27 -0
  10. package/dist/boot-options/kms-mode.js +40 -0
  11. package/dist/boot-options/mail-option.d.ts +30 -0
  12. package/dist/boot-options/mail-option.js +44 -0
  13. package/dist/boot-options/option-env-schema.d.ts +7 -0
  14. package/dist/boot-options/option-env-schema.js +113 -0
  15. package/dist/boot-options/process-signals.d.ts +23 -0
  16. package/dist/boot-options/process-signals.js +58 -0
  17. package/dist/boot-options/resolve-boot-options.d.ts +44 -0
  18. package/dist/boot-options/resolve-boot-options.js +88 -0
  19. package/dist/boot-options/seed-shortcuts.d.ts +49 -0
  20. package/dist/boot-options/seed-shortcuts.js +92 -0
  21. package/dist/boot-options/stack-boot-requirements.d.ts +6 -0
  22. package/dist/boot-options/stack-boot-requirements.js +63 -0
  23. package/dist/build-prod-bundle.d.ts +4 -0
  24. package/dist/build-prod-bundle.js +41 -2
  25. package/dist/csp-hashes.d.ts +15 -0
  26. package/dist/csp-hashes.js +46 -0
  27. package/dist/index.d.ts +18 -9
  28. package/dist/index.js +1 -0
  29. package/dist/rate-limit-defaults.d.ts +19 -0
  30. package/dist/rate-limit-defaults.js +13 -0
  31. package/dist/run-bootstrap.d.ts +2 -2
  32. package/dist/run-prod-app-boot-context.d.ts +5 -3
  33. package/dist/run-prod-app-boot-context.js +4 -2
  34. package/dist/run-prod-app-static-files.d.ts +6 -2
  35. package/dist/run-prod-app-static-files.js +22 -1
  36. package/dist/run-prod-app.d.ts +76 -29
  37. package/dist/run-prod-app.js +161 -96
  38. package/dist/run-worker-app.d.ts +2 -2
  39. package/dist/security-headers.d.ts +16 -5
  40. package/dist/security-headers.js +56 -5
  41. package/package.json +18 -9
  42. package/src/changes.json +43 -0
@@ -0,0 +1,58 @@
1
+ // Kubernetes sends SIGKILL after terminationGracePeriodSeconds (default 30 s).
2
+ // Draining 25 s leaves room for the exit and the final log line before that.
3
+ export const DEFAULT_DRAIN_TIMEOUT_MS = 25_000;
4
+ /** Registers SIGTERM/SIGINT: the first signal drains via `stop()` and exits
5
+ * 0, a drain that fails or outlives the timeout exits 1. Returns the
6
+ * uninstall function so `stop()` can drop the listeners again. */
7
+ export function installShutdownSignals(deps) {
8
+ const signalProcess = deps.signalProcess ?? process;
9
+ const exit = deps.exit ?? ((code) => process.exit(code));
10
+ // biome-ignore lint/suspicious/noConsole: shutdown progress, no logger at this level
11
+ const log = deps.log ?? ((message) => console.log(message));
12
+ const logError = deps.logError ??
13
+ // biome-ignore lint/suspicious/noConsole: shutdown-time error, only path is stderr
14
+ ((message, error) => console.error(message, error));
15
+ const drainTimeoutMs = deps.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
16
+ let shuttingDown = false;
17
+ const shutdown = async (signal) => {
18
+ // skip: shutdown already in progress, a second signal must not double-drain
19
+ if (shuttingDown)
20
+ return;
21
+ shuttingDown = true;
22
+ log(`[runProdApp] ${signal} received — draining (timeout ${drainTimeoutMs}ms)…`);
23
+ let timer;
24
+ const timeout = new Promise((resolve) => {
25
+ timer = setTimeout(() => resolve({ kind: "timeout" }), drainTimeoutMs);
26
+ });
27
+ let exitCode = 0;
28
+ try {
29
+ const outcome = await Promise.race([
30
+ deps.stop().then(() => ({ kind: "drained" })),
31
+ timeout,
32
+ ]);
33
+ if (outcome.kind === "timeout") {
34
+ exitCode = 1;
35
+ logError(`[runProdApp] drain exceeded ${drainTimeoutMs}ms, exiting anyway`, undefined);
36
+ }
37
+ else {
38
+ log("[runProdApp] graceful shutdown complete.");
39
+ }
40
+ }
41
+ catch (error) {
42
+ exitCode = 1;
43
+ logError("[runProdApp] error during shutdown:", error);
44
+ }
45
+ finally {
46
+ clearTimeout(timer);
47
+ }
48
+ exit(exitCode);
49
+ };
50
+ const onSigterm = () => void shutdown("SIGTERM");
51
+ const onSigint = () => void shutdown("SIGINT");
52
+ signalProcess.on("SIGTERM", onSigterm);
53
+ signalProcess.on("SIGINT", onSigint);
54
+ return () => {
55
+ signalProcess.off("SIGTERM", onSigterm);
56
+ signalProcess.off("SIGINT", onSigint);
57
+ };
58
+ }
@@ -0,0 +1,44 @@
1
+ import type { ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
2
+ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { ProdSeedFn } from "../run-prod-app.ts";
4
+ import { type BillingBootOption } from "./billing-option.ts";
5
+ import { type DemoModeOption } from "./demo-mode.ts";
6
+ import { type FileStorageBootOption } from "./file-storage-option.ts";
7
+ import { type KmsMode, type ResolvedKmsMode } from "./kms-mode.ts";
8
+ import { type MailBootOption } from "./mail-option.ts";
9
+ import { type DbSeedFn, type ProdSeedsConfig } from "./seed-shortcuts.ts";
10
+ export type BootOptionFields = {
11
+ readonly features: readonly FeatureDefinition[];
12
+ readonly mail?: MailBootOption;
13
+ readonly kmsMode?: KmsMode;
14
+ readonly fileStorage?: FileStorageBootOption;
15
+ readonly billing?: BillingBootOption;
16
+ readonly demoMode?: DemoModeOption;
17
+ readonly seeds?: readonly ProdSeedFn[] | ProdSeedsConfig;
18
+ readonly kms?: ResolvedKmsMode["kms"];
19
+ readonly blindIndexKey?: string;
20
+ readonly allowPlaintextPii?: string;
21
+ readonly runSingleInstance?: boolean;
22
+ readonly auth?: {
23
+ readonly admin?: unknown;
24
+ readonly mail?: {
25
+ readonly from?: string;
26
+ };
27
+ };
28
+ };
29
+ export type BootOptionWiring = {
30
+ readonly features: readonly FeatureDefinition[];
31
+ readonly extraRoutes: readonly ExtraRouteDefinition[];
32
+ /** Applied under the env: an explicitly set variable always wins. */
33
+ readonly envDefaults: Readonly<Record<string, string>>;
34
+ readonly kms: ResolvedKmsMode;
35
+ readonly shortcutSeeds: readonly DbSeedFn[];
36
+ readonly customSeeds: readonly ProdSeedFn[];
37
+ /** True when `mail` mounted a transport; the auth mail flows then wire without `SMTP_HOST`. */
38
+ readonly mailConfigured: boolean;
39
+ readonly wrapFetch: <TArg>(handler: (req: Request, arg?: TArg) => Promise<Response>) => (req: Request, arg?: TArg) => Promise<Response>;
40
+ };
41
+ export declare function isSeedFnList(seeds: readonly ProdSeedFn[] | ProdSeedsConfig): seeds is readonly ProdSeedFn[];
42
+ /** Runs after the env is validated and KEK slots are decrypted, before any
43
+ * connection: every misconfiguration of the new options fails here. */
44
+ export declare function resolveBootOptionWiring(options: BootOptionFields, envSource: Record<string, string | undefined>): Promise<BootOptionWiring>;
@@ -0,0 +1,88 @@
1
+ import { BILLING_FOUNDATION_FEATURE } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
2
+ import { DELIVERY_FEATURE } from "@cosmicdrift/kumiko-bundled-features/delivery";
3
+ import { SUBSCRIPTION_STRIPE_FEATURE } from "@cosmicdrift/kumiko-bundled-features/subscription-stripe";
4
+ import { TIER_ENGINE_FEATURE } from "@cosmicdrift/kumiko-bundled-features/tier-engine";
5
+ import { resolveBillingOption } from "./billing-option.js";
6
+ import { buildDemoModeRoute, withDemoReadOnlyFetch } from "./demo-mode.js";
7
+ import { FILE_FOUNDATION_FEATURE, resolveFileStorageOption, } from "./file-storage-option.js";
8
+ import { resolveKmsMode } from "./kms-mode.js";
9
+ import { CHANNEL_EMAIL_FEATURE, resolveMailOption } from "./mail-option.js";
10
+ import { resolveSeedShortcuts } from "./seed-shortcuts.js";
11
+ export function isSeedFnList(seeds) {
12
+ return Array.isArray(seeds);
13
+ }
14
+ function requireFeatures(mounted, option, required, hint) {
15
+ const missing = required.filter((name) => !mounted.has(name));
16
+ if (missing.length > 0) {
17
+ throw new Error(`[runProdApp] ${option} needs the feature(s) ${missing.join(", ")} in \`features\`. ${hint}`);
18
+ }
19
+ }
20
+ function rejectDoubleMount(mounted, option, feature) {
21
+ if (mounted.has(feature)) {
22
+ throw new Error(`[runProdApp] ${option} mounts ${feature} itself — remove it from \`features\` or drop the option.`);
23
+ }
24
+ }
25
+ /** Runs after the env is validated and KEK slots are decrypted, before any
26
+ * connection: every misconfiguration of the new options fails here. */
27
+ export async function resolveBootOptionWiring(options, envSource) {
28
+ const isProduction = envSource["NODE_ENV"] === "production";
29
+ const mounted = new Set(options.features.map((feature) => feature.name));
30
+ const features = [];
31
+ const extraRoutes = [];
32
+ let envDefaults = {};
33
+ if (options.mail) {
34
+ requireFeatures(mounted, "mail", [DELIVERY_FEATURE], "channel-email sends through delivery.");
35
+ rejectDoubleMount(mounted, "mail", CHANNEL_EMAIL_FEATURE);
36
+ features.push(resolveMailOption(options.mail, {
37
+ envSource,
38
+ isProduction,
39
+ authMailFrom: options.auth?.mail?.from,
40
+ }).feature);
41
+ }
42
+ if (options.fileStorage) {
43
+ requireFeatures(mounted, "fileStorage", [FILE_FOUNDATION_FEATURE], "Mount it via composeFileStack.");
44
+ const fileStorage = resolveFileStorageOption(options.fileStorage, {
45
+ envSource,
46
+ isProduction,
47
+ mountedFeatureNames: mounted,
48
+ });
49
+ features.push(...fileStorage.features);
50
+ envDefaults = { ...envDefaults, ...fileStorage.envDefaults };
51
+ }
52
+ if (options.billing) {
53
+ requireFeatures(mounted, "billing.stripe", [BILLING_FOUNDATION_FEATURE, TIER_ENGINE_FEATURE], "Mount createBillingFoundationFeature() and createTierEngineFeature(), or composeSaasStack from @cosmicdrift/kumiko-dev-server/compose-stacks.");
54
+ rejectDoubleMount(mounted, "billing.stripe", SUBSCRIPTION_STRIPE_FEATURE);
55
+ const billing = resolveBillingOption(options.billing, envSource);
56
+ features.push(billing.feature);
57
+ extraRoutes.push(billing.route);
58
+ }
59
+ const { demoMode } = options;
60
+ if (demoMode) {
61
+ extraRoutes.push(buildDemoModeRoute(demoMode, envSource));
62
+ }
63
+ if (options.runSingleInstance === false && (options.mail || options.fileStorage)) {
64
+ // biome-ignore lint/suspicious/noConsole: boot-time warn, no logger configured this early
65
+ console.warn("[runProdApp] runSingleInstance:false — delivery and file jobs run in runWorkerApp, which does not read mail / fileStorage. Mount the same channel-email and file provider features there.");
66
+ }
67
+ const seeds = options.seeds ?? [];
68
+ const shortcutSeeds = isSeedFnList(seeds)
69
+ ? []
70
+ : resolveSeedShortcuts(seeds, {
71
+ envSource,
72
+ hasAuth: options.auth !== undefined,
73
+ hasAuthAdmin: options.auth?.admin !== undefined,
74
+ });
75
+ const customSeeds = isSeedFnList(seeds) ? seeds : (seeds.run ?? []);
76
+ // Last: it may open a KMS pool, which nothing above should leak on a throw.
77
+ const kms = await resolveKmsMode(options.kmsMode, options, envSource);
78
+ return {
79
+ features,
80
+ extraRoutes,
81
+ envDefaults,
82
+ kms,
83
+ shortcutSeeds,
84
+ customSeeds,
85
+ mailConfigured: options.mail !== undefined,
86
+ wrapFetch: (handler) => (demoMode ? withDemoReadOnlyFetch(handler, demoMode) : handler),
87
+ };
88
+ }
@@ -0,0 +1,49 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { ProdSeedFn } from "../run-prod-app.ts";
3
+ export type ProdSeedTenant = {
4
+ /** Default: `DEMO_TENANT_ID` from the env. */
5
+ readonly id?: string;
6
+ readonly key: string;
7
+ readonly name: string;
8
+ };
9
+ type SeededAccountOptions = {
10
+ readonly displayName?: string;
11
+ /** Membership roles in the tenant. */
12
+ readonly roles?: readonly string[];
13
+ readonly emailVerified?: boolean;
14
+ };
15
+ /** Declarative boot seeds, all idempotent (existing users keep their roles).
16
+ * Need `auth`; run right after `auth.admin`, before `run`.
17
+ * - `demoTenant`: the tenant the demo accounts belong to.
18
+ * - `demoAdminFromEnv`: `DEMO_ADMIN_EMAIL` / `DEMO_ADMIN_PASSWORD`, member of
19
+ * `demoTenant` with `Admin` + `TenantAdmin` (default). Replaces `auth.admin`.
20
+ * - `sysadminFromEnv`: `DEMO_SYSADMIN_EMAIL` / `DEMO_SYSADMIN_PASSWORD`, global
21
+ * role `SystemAdmin`, email verified, member of `tenant` (default
22
+ * `demoTenant`) with role `User` (default).
23
+ * - `run`: free seed functions, same contract as the array form.
24
+ *
25
+ * @example
26
+ * seeds: {
27
+ * demoTenant: { key: "demo", name: "Demo" },
28
+ * demoAdminFromEnv: true,
29
+ * sysadminFromEnv: true,
30
+ * run: [seedDemoData],
31
+ * }
32
+ */
33
+ export type ProdSeedsConfig = {
34
+ readonly demoTenant?: ProdSeedTenant;
35
+ readonly demoAdminFromEnv?: boolean | SeededAccountOptions;
36
+ readonly sysadminFromEnv?: boolean | (SeededAccountOptions & {
37
+ readonly tenant?: ProdSeedTenant;
38
+ });
39
+ readonly run?: readonly ProdSeedFn[];
40
+ };
41
+ export type DbSeedFn = (db: DbConnection) => Promise<void>;
42
+ /** Validates env and options at boot (before any connection) and returns the
43
+ * seeds to run once the database is up. */
44
+ export declare function resolveSeedShortcuts(seeds: ProdSeedsConfig, deps: {
45
+ readonly envSource: Record<string, string | undefined>;
46
+ readonly hasAuth: boolean;
47
+ readonly hasAuthAdmin: boolean;
48
+ }): readonly DbSeedFn[];
49
+ export {};
@@ -0,0 +1,92 @@
1
+ import { seedAdminGuarded, seedTenant, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
2
+ import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
3
+ import { parseTenantId } from "@cosmicdrift/kumiko-types/identifiers";
4
+ const DEFAULT_DEMO_ADMIN_ROLES = ["Admin", ROLES.TenantAdmin];
5
+ const DEFAULT_SYSADMIN_TENANT_ROLES = ["User"];
6
+ function resolveTenant(tenant, envSource, label) {
7
+ const rawId = tenant.id ?? envSource["DEMO_TENANT_ID"];
8
+ const id = parseTenantId(rawId);
9
+ if (id === null) {
10
+ throw new Error(`[runProdApp] seeds.${label} needs a UUID id (option or DEMO_TENANT_ID), got ${rawId === undefined ? "nothing" : "an invalid value"}.`);
11
+ }
12
+ return { id, key: tenant.key, name: tenant.name };
13
+ }
14
+ function requireAccountEnv(envSource, emailKey, passwordKey, label) {
15
+ const email = envSource[emailKey];
16
+ const password = envSource[passwordKey];
17
+ if (!email || !password) {
18
+ const missing = [emailKey, passwordKey].filter((key) => !envSource[key]);
19
+ throw new Error(`[runProdApp] seeds.${label} is missing ${missing.join(", ")}.`);
20
+ }
21
+ return { email, password };
22
+ }
23
+ function accountOptions(value) {
24
+ return typeof value === "object" ? value : {};
25
+ }
26
+ function membershipOf(tenant, roles) {
27
+ return { tenantId: tenant.id, tenantKey: tenant.key, tenantName: tenant.name, roles };
28
+ }
29
+ function seedAccount(admin) {
30
+ return async (db) => {
31
+ await seedAdminGuarded(db, admin);
32
+ };
33
+ }
34
+ function buildDemoAdminSeed(value, demoTenant, envSource) {
35
+ if (!demoTenant) {
36
+ throw new Error("[runProdApp] seeds.demoAdminFromEnv needs seeds.demoTenant.");
37
+ }
38
+ const options = accountOptions(value);
39
+ const account = requireAccountEnv(envSource, "DEMO_ADMIN_EMAIL", "DEMO_ADMIN_PASSWORD", "demoAdminFromEnv");
40
+ return seedAccount({
41
+ ...account,
42
+ displayName: options.displayName ?? "Admin",
43
+ memberships: [membershipOf(demoTenant, options.roles ?? DEFAULT_DEMO_ADMIN_ROLES)],
44
+ ...(options.emailVerified !== undefined && { emailVerified: options.emailVerified }),
45
+ });
46
+ }
47
+ function buildSysadminSeed(value, demoTenant, envSource) {
48
+ const options = typeof value === "object" ? value : {};
49
+ const tenant = options.tenant
50
+ ? resolveTenant(options.tenant, envSource, "sysadminFromEnv.tenant")
51
+ : demoTenant;
52
+ if (!tenant) {
53
+ throw new Error("[runProdApp] seeds.sysadminFromEnv needs seeds.demoTenant or sysadminFromEnv.tenant for its login membership.");
54
+ }
55
+ const account = requireAccountEnv(envSource, "DEMO_SYSADMIN_EMAIL", "DEMO_SYSADMIN_PASSWORD", "sysadminFromEnv");
56
+ // Verified by default: with strict verification an undelivered mail would
57
+ // otherwise lock out the only SystemAdmin.
58
+ return seedAccount({
59
+ ...account,
60
+ displayName: options.displayName ?? "Sysadmin",
61
+ globalRoles: [ROLES.SystemAdmin],
62
+ emailVerified: options.emailVerified ?? true,
63
+ memberships: [membershipOf(tenant, options.roles ?? DEFAULT_SYSADMIN_TENANT_ROLES)],
64
+ });
65
+ }
66
+ /** Validates env and options at boot (before any connection) and returns the
67
+ * seeds to run once the database is up. */
68
+ export function resolveSeedShortcuts(seeds, deps) {
69
+ const wantsAccounts = Boolean(seeds.demoAdminFromEnv) || Boolean(seeds.sysadminFromEnv);
70
+ if ((wantsAccounts || seeds.demoTenant) && !deps.hasAuth) {
71
+ throw new Error("[runProdApp] seeds.demoTenant / demoAdminFromEnv / sysadminFromEnv need `auth` (user, tenant and auth-email-password features).");
72
+ }
73
+ if (seeds.demoAdminFromEnv && deps.hasAuthAdmin) {
74
+ throw new Error("[runProdApp] seeds.demoAdminFromEnv and auth.admin both seed the admin — keep one.");
75
+ }
76
+ const demoTenant = seeds.demoTenant
77
+ ? resolveTenant(seeds.demoTenant, deps.envSource, "demoTenant")
78
+ : undefined;
79
+ const result = [];
80
+ if (demoTenant) {
81
+ result.push(async (db) => {
82
+ await seedTenant(db, demoTenant);
83
+ });
84
+ }
85
+ if (seeds.demoAdminFromEnv) {
86
+ result.push(buildDemoAdminSeed(seeds.demoAdminFromEnv, demoTenant, deps.envSource));
87
+ }
88
+ if (seeds.sysadminFromEnv) {
89
+ result.push(buildSysadminSeed(seeds.sysadminFromEnv, demoTenant, deps.envSource));
90
+ }
91
+ return result;
92
+ }
@@ -0,0 +1,6 @@
1
+ import { type BootOptionName } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { RunProdAppOptions } from "../run-prod-app.ts";
3
+ export type StackBootOptionFields = Pick<RunProdAppOptions, "features" | "kms" | "allowPlaintextPii" | BootOptionName>;
4
+ /** Reads `bootRequires` from the raw feature list: dedupe may later drop the
5
+ * tagged copy of a feature two stacks share. */
6
+ export declare function assertStackBootRequirements(options: StackBootOptionFields): void;
@@ -0,0 +1,63 @@
1
+ import { SUBSCRIPTION_PROVIDER_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
2
+ import { EXT_FILE_PROVIDER, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { CHANNEL_EMAIL_FEATURE } from "./mail-option.js";
4
+ function mountsExtension(features, extensionName) {
5
+ return features.some((feature) => feature.extensionUsages.some((usage) => usage.extensionName === extensionName));
6
+ }
7
+ // The low-level paths still count until the deprecated wiring is gone: an app
8
+ // that mounts channel-email or a provider by hand has what the option builds.
9
+ const OPTION_CHECKS = {
10
+ mail: {
11
+ isSatisfied: (options, mounted) => options.mail !== undefined || mounted.some((f) => f.name === CHANNEL_EMAIL_FEATURE),
12
+ hint: 'set mail: { transport: "smtp-env" }',
13
+ },
14
+ kmsMode: {
15
+ isSatisfied: (options) => options.kmsMode !== undefined ||
16
+ options.kms !== undefined ||
17
+ options.allowPlaintextPii !== undefined,
18
+ hint: 'set kmsMode: "required" (or { plaintextReason } for a demo)',
19
+ },
20
+ fileStorage: {
21
+ isSatisfied: (options, mounted) => options.fileStorage !== undefined || mountsExtension(mounted, EXT_FILE_PROVIDER),
22
+ hint: 'set fileStorage: { provider: "auto" }',
23
+ },
24
+ billing: {
25
+ isSatisfied: (options, mounted) => options.billing !== undefined || mountsExtension(mounted, SUBSCRIPTION_PROVIDER_EXTENSION),
26
+ hint: "set billing: { stripe: { priceToTier }, defaultTier }",
27
+ },
28
+ demoMode: {
29
+ isSatisfied: (options) => options.demoMode !== undefined,
30
+ hint: "set demoMode: { readOnly }",
31
+ },
32
+ seeds: {
33
+ isSatisfied: (options) => options.seeds !== undefined,
34
+ hint: "set seeds",
35
+ },
36
+ signals: {
37
+ isSatisfied: (options) => options.signals !== false,
38
+ hint: "drop signals: false",
39
+ },
40
+ rateLimit: {
41
+ isSatisfied: (options) => options.rateLimit !== false,
42
+ hint: "drop rateLimit: false",
43
+ },
44
+ };
45
+ /** Reads `bootRequires` from the raw feature list: dedupe may later drop the
46
+ * tagged copy of a feature two stacks share. */
47
+ export function assertStackBootRequirements(options) {
48
+ const gaps = new Set();
49
+ for (const feature of options.features) {
50
+ const requirement = feature.bootRequires;
51
+ if (requirement === undefined)
52
+ continue;
53
+ for (const option of requirement.options) {
54
+ const check = OPTION_CHECKS[option];
55
+ if (!check.isSatisfied(options, options.features)) {
56
+ gaps.add(` - ${requirement.stack} needs the boot option \`${option}\`: ${check.hint}`);
57
+ }
58
+ }
59
+ }
60
+ if (gaps.size > 0) {
61
+ throw new Error(`[runProdApp] stacks in \`features\` need boot options that are not set:\n${[...gaps].join("\n")}`);
62
+ }
63
+ }
@@ -1,3 +1,4 @@
1
+ import { type CspHashesFile } from "./csp-hashes.ts";
1
2
  export type BuildProdBundleOptions = {
2
3
  /** App-Root. Default: process.cwd(). */
3
4
  readonly cwd?: string;
@@ -101,4 +102,7 @@ export declare function computeBuildId(manifest: BuildManifest): string;
101
102
  * mit ANSI-Farben. Wird sowohl von `kumiko build` als auch von dem
102
103
  * hoisted `kumiko-build`-Bin verwendet, damit das Output konsistent ist. */
103
104
  export declare function formatBuildResult(result: BuildResult, durationMs: number): string;
105
+ /** Hashes every inline script in the HTML files of a finished build output, so the
106
+ * CSP only ever allows code that shipped in the artefact. */
107
+ export declare function collectBuildScriptHashes(distDir: string): CspHashesFile;
104
108
  export {};
@@ -38,6 +38,7 @@
38
38
  // <asset>-<hash>.<ext> ← imported file-loader assets
39
39
  // kumiko-bundled-assets/ ← package.json "kumiko.assets", never served over HTTP
40
40
  // manifest.json ← logical → hashed-URL mapping
41
+ // csp-hashes.json ← sha256 of every inline <script> in the HTML above
41
42
  // <public/* 1:1> ← favicon.ico, robots.txt, og-image.png, …
42
43
  //
43
44
  // Cache-Header (von runProdApp gesetzt, nicht hier):
@@ -46,6 +47,14 @@
46
47
  // /index.html, /sw.js → no-cache, must-revalidate
47
48
  // /manifest.json → no-cache
48
49
  // alles andere (public/) → default (auto-cache)
50
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
51
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
52
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
53
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
54
+ });
55
+ }
56
+ return path;
57
+ };
49
58
  import { createHash } from "node:crypto";
50
59
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
51
60
  import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
@@ -54,6 +63,7 @@ import { isPlainObject, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/u
54
63
  import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
55
64
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
56
65
  import { BUNDLED_ASSETS_DIST_DIR, readBundledAssetDeclarations, resolveBundledAssetSource, } from "./bundled-assets.js";
66
+ import { CSP_HASHES_FILE, inlineScriptHashes } from "./csp-hashes.js";
57
67
  import { RENDERER_WEB_FONT_FILE_PATTERN, RENDERER_WEB_FONTS_DIST_DIR, resolveRendererWebFontsDir, } from "./renderer-web-fonts.js";
58
68
  import { canResolveTailwindStylesheet, resolveTailwindCli } from "./resolve-tailwind-cli.js";
59
69
  // Bun-Runtime-Check als module-level Konstante: alle Build-Schritte
@@ -180,6 +190,10 @@ export async function buildProdBundle(options = {}) {
180
190
  if (buildInfo) {
181
191
  await writeFile(join(outDir, "build-info.json"), `${JSON.stringify(buildInfo)}\n`);
182
192
  }
193
+ // 8. CSP script hashes over the finished output (templates and copied
194
+ // public/*.html alike), so the runtime default CSP allows exactly the
195
+ // inline scripts that shipped.
196
+ await writeFile(join(outDir, CSP_HASHES_FILE), `${JSON.stringify(collectBuildScriptHashes(outDir), null, 2)}\n`);
183
197
  return { outDir, manifest, ...(buildInfo && { buildInfo }) };
184
198
  }
185
199
  const ENTRY_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
@@ -567,8 +581,8 @@ function isTailwindEngine(candidate) {
567
581
  // (same version the CLI would run), not from the app root.
568
582
  async function loadTailwindEngine(cliPath, bun) {
569
583
  const cliDir = dirname(bun.resolveSync("@tailwindcss/cli/package.json", dirname(cliPath)));
570
- const node = await import(bun.resolveSync("@tailwindcss/node", cliDir));
571
- const oxide = await import(bun.resolveSync("@tailwindcss/oxide", cliDir));
584
+ const node = await import(__rewriteRelativeImportExtension(bun.resolveSync("@tailwindcss/node", cliDir)));
585
+ const oxide = await import(__rewriteRelativeImportExtension(bun.resolveSync("@tailwindcss/oxide", cliDir)));
572
586
  const engine = {
573
587
  compile: isRecord(node) ? node["compile"] : undefined,
574
588
  optimize: isRecord(node) ? node["optimize"] : undefined,
@@ -810,3 +824,28 @@ export function formatBuildResult(result, durationMs) {
810
824
  lines.push("");
811
825
  return lines.join("\n");
812
826
  }
827
+ function htmlFilesUnder(dir) {
828
+ const files = [];
829
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
830
+ if (entry.isDirectory()) {
831
+ // Bundled assets are never served over HTTP.
832
+ if (entry.name === BUNDLED_ASSETS_DIST_DIR)
833
+ continue;
834
+ files.push(...htmlFilesUnder(join(dir, entry.name)));
835
+ }
836
+ else if (entry.name.endsWith(".html")) {
837
+ files.push(join(dir, entry.name));
838
+ }
839
+ }
840
+ return files;
841
+ }
842
+ /** Hashes every inline script in the HTML files of a finished build output, so the
843
+ * CSP only ever allows code that shipped in the artefact. */
844
+ export function collectBuildScriptHashes(distDir) {
845
+ const hashes = new Set();
846
+ for (const file of htmlFilesUnder(distDir)) {
847
+ for (const hash of inlineScriptHashes(readFileSync(file, "utf8")))
848
+ hashes.add(hash);
849
+ }
850
+ return { scriptSrc: [...hashes].sort() };
851
+ }
@@ -0,0 +1,15 @@
1
+ export declare const CSP_HASHES_FILE = "csp-hashes.json";
2
+ export type CspHashesFile = {
3
+ readonly scriptSrc: readonly string[];
4
+ };
5
+ export declare function inlineScriptHashes(html: string): string[];
6
+ export type CspHashesReadResult = {
7
+ readonly kind: "found";
8
+ readonly scriptSrc: readonly string[];
9
+ } | {
10
+ readonly kind: "missing";
11
+ } | {
12
+ readonly kind: "invalid";
13
+ readonly problem: string;
14
+ };
15
+ export declare function parseCspHashes(text: string): CspHashesReadResult;
@@ -0,0 +1,46 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isPlainObject, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
3
+ import { isScriptHash } from "./security-headers.js";
4
+ export const CSP_HASHES_FILE = "csp-hashes.json";
5
+ const INLINE_SCRIPT_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
6
+ const SRC_ATTRIBUTE_PATTERN = /\ssrc\s*=/i;
7
+ const TYPE_ATTRIBUTE_PATTERN = /\stype\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i;
8
+ // Data blocks (application/json, ld+json, templates) never execute, so
9
+ // script-src does not apply to them. Importmaps do, and are governed by it.
10
+ const EXECUTABLE_SCRIPT_TYPES = new Set([
11
+ "",
12
+ "module",
13
+ "importmap",
14
+ "text/javascript",
15
+ "application/javascript",
16
+ ]);
17
+ function scriptTypeOf(attributes) {
18
+ const match = TYPE_ATTRIBUTE_PATTERN.exec(attributes);
19
+ return (match?.[1] ?? match?.[2] ?? match?.[3] ?? "").trim().toLowerCase();
20
+ }
21
+ export function inlineScriptHashes(html) {
22
+ const hashes = [];
23
+ for (const match of html.matchAll(INLINE_SCRIPT_PATTERN)) {
24
+ const attributes = ` ${match[1] ?? ""}`;
25
+ const content = match[2] ?? "";
26
+ if (content.length === 0 || SRC_ATTRIBUTE_PATTERN.test(attributes))
27
+ continue;
28
+ if (!EXECUTABLE_SCRIPT_TYPES.has(scriptTypeOf(attributes)))
29
+ continue;
30
+ hashes.push(`sha256-${createHash("sha256").update(content, "utf8").digest("base64")}`);
31
+ }
32
+ return hashes;
33
+ }
34
+ export function parseCspHashes(text) {
35
+ try {
36
+ const parsed = parseJsonOrThrow(text, CSP_HASHES_FILE);
37
+ const scriptSrc = isPlainObject(parsed) ? parsed["scriptSrc"] : undefined;
38
+ if (!Array.isArray(scriptSrc) || !scriptSrc.every(isScriptHash)) {
39
+ return { kind: "invalid", problem: '"scriptSrc" must be an array of sha256 hashes' };
40
+ }
41
+ return { kind: "found", scriptSrc };
42
+ }
43
+ catch (error) {
44
+ return { kind: "invalid", problem: error instanceof Error ? error.message : String(error) };
45
+ }
46
+ }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,18 @@
1
- export { type BundledAssetDeclaration, type BundledAssetOptions, readBundledAsset, resolveBundledAsset, } from "./bundled-assets.js";
2
- export { type ComposeFeaturesOptions, composeFeatures } from "./compose-features.js";
3
- export type { RunBootstrapOptions } from "./run-bootstrap.js";
4
- export { runBootstrap } from "./run-bootstrap.js";
5
- export type { AccountUnlockSetup, EmailVerificationSetup, InviteSetup, PageHeadMeta, PageHeadResolver, PageHeadSystemQuery, PasswordResetSetup, ProdAppHandle, ProdSeedFn, RunProdAppAuthOptions, RunProdAppOptions, SignupSetup, } from "./run-prod-app.js";
6
- export { requireEnv, runProdApp } from "./run-prod-app.js";
7
- export type { RunWorkerAppOptions, WorkerAppHandle, WorkerWireDeps, } from "./run-worker-app.js";
8
- export { runWorkerApp } from "./run-worker-app.js";
9
- export type { SecurityHeadersOption } from "./security-headers.js";
1
+ export type { BillingBootOption } from "./boot-options/billing-option.ts";
2
+ export type { DemoAccountRole, DemoModeNotice, DemoModeOption, } from "./boot-options/demo-mode.ts";
3
+ export type { FileStorageBootOption, FileStorageProviderName, } from "./boot-options/file-storage-option.ts";
4
+ export type { KmsMode } from "./boot-options/kms-mode.ts";
5
+ export type { MailBootOption } from "./boot-options/mail-option.ts";
6
+ export type { SignalsOption } from "./boot-options/process-signals.ts";
7
+ export type { ProdSeedsConfig, ProdSeedTenant } from "./boot-options/seed-shortcuts.ts";
8
+ export { type BundledAssetDeclaration, type BundledAssetOptions, readBundledAsset, resolveBundledAsset, } from "./bundled-assets.ts";
9
+ export { type ComposeFeaturesOptions, composeFeatures } from "./compose-features.ts";
10
+ export type { RateLimitOption } from "./rate-limit-defaults.ts";
11
+ export type { RunBootstrapOptions } from "./run-bootstrap.ts";
12
+ export { runBootstrap } from "./run-bootstrap.ts";
13
+ export type { AccountUnlockSetup, EmailVerificationSetup, InviteSetup, PageHeadMeta, PageHeadResolver, PageHeadSystemQuery, PasswordResetSetup, ProdAppHandle, ProdSeedFn, RunProdAppAuthOptions, RunProdAppOptions, SignupSetup, } from "./run-prod-app.ts";
14
+ export { requireEnv, runProdApp } from "./run-prod-app.ts";
15
+ export type { RunWorkerAppOptions, WorkerAppHandle, WorkerWireDeps, } from "./run-worker-app.ts";
16
+ export { runWorkerApp } from "./run-worker-app.ts";
17
+ export type { CspExtension, CspOption, SecurityHeadersOption } from "./security-headers.ts";
18
+ export { makeSeedCreateFileRef, type SeedCreateFileRef } from "./seed-dispatcher.ts";
package/dist/index.js CHANGED
@@ -7,3 +7,4 @@ export { composeFeatures } from "./compose-features.js";
7
7
  export { runBootstrap } from "./run-bootstrap.js";
8
8
  export { requireEnv, runProdApp } from "./run-prod-app.js";
9
9
  export { runWorkerApp } from "./run-worker-app.js";
10
+ export { makeSeedCreateFileRef } from "./seed-dispatcher.js";
@@ -0,0 +1,19 @@
1
+ import type { ServerOptions } from "@cosmicdrift/kumiko-framework/api";
2
+ type ServerRateLimit = NonNullable<ServerOptions["rateLimit"]>;
3
+ /** `false` per layer or for the whole option switches it off. */
4
+ export type RateLimitOption = false | {
5
+ readonly global?: false | ServerRateLimit["global"];
6
+ readonly auth?: false | ServerRateLimit["auth"];
7
+ };
8
+ export declare const DEFAULT_GLOBAL_RATE_LIMIT: {
9
+ readonly limit: 600;
10
+ readonly windowSeconds: 60;
11
+ };
12
+ export declare const DEFAULT_AUTH_RATE_LIMIT: {
13
+ readonly limit: 30;
14
+ readonly windowSeconds: 60;
15
+ };
16
+ /** Layers the app did not mention keep their default; a layer it did mention
17
+ * overrides only the fields it names. */
18
+ export declare function resolveRateLimit(option: RateLimitOption | undefined): ServerRateLimit | undefined;
19
+ export {};
@@ -0,0 +1,13 @@
1
+ export const DEFAULT_GLOBAL_RATE_LIMIT = { limit: 600, windowSeconds: 60 };
2
+ export const DEFAULT_AUTH_RATE_LIMIT = { limit: 30, windowSeconds: 60 };
3
+ /** Layers the app did not mention keep their default; a layer it did mention
4
+ * overrides only the fields it names. */
5
+ export function resolveRateLimit(option) {
6
+ if (option === false)
7
+ return undefined;
8
+ const { global, auth } = option ?? {};
9
+ return {
10
+ ...(global !== false && { global: { ...DEFAULT_GLOBAL_RATE_LIMIT, ...global } }),
11
+ ...(auth !== false && { auth: { ...DEFAULT_AUTH_RATE_LIMIT, ...auth } }),
12
+ };
13
+ }
@@ -1,6 +1,6 @@
1
1
  import { type BootstrapPlan, type BootstrapReport } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
2
- import { type RunProdAppAuthOptions } from "./run-prod-app.js";
3
- import type { RunWorkerAppOptions } from "./run-worker-app.js";
2
+ import { type RunProdAppAuthOptions } from "./run-prod-app.ts";
3
+ import type { RunWorkerAppOptions } from "./run-worker-app.ts";
4
4
  export type RunBootstrapOptions = Omit<RunWorkerAppOptions, "wireComponents" | "includeBundled" | "jobs" | "eventDispatcher" | "metrics"> & BootstrapPlan & {
5
5
  /** The same auth block the app passes to runProdApp (`admin` is ignored).
6
6
  * Needs a mounted invite flow: `auth.invite`, or `auth.mail` with