@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.
Files changed (44) 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/resolve-unsubscribe-wiring.d.ts +17 -0
  32. package/dist/resolve-unsubscribe-wiring.js +24 -0
  33. package/dist/run-bootstrap.d.ts +2 -2
  34. package/dist/run-prod-app-boot-context.d.ts +5 -3
  35. package/dist/run-prod-app-boot-context.js +4 -2
  36. package/dist/run-prod-app-static-files.d.ts +6 -2
  37. package/dist/run-prod-app-static-files.js +22 -1
  38. package/dist/run-prod-app.d.ts +77 -32
  39. package/dist/run-prod-app.js +172 -120
  40. package/dist/run-worker-app.d.ts +2 -2
  41. package/dist/security-headers.d.ts +16 -5
  42. package/dist/security-headers.js +56 -5
  43. package/package.json +18 -9
  44. package/src/changes.json +43 -0
@@ -14,17 +14,7 @@ export function envHasMasterKek(env) {
14
14
  return Object.entries(env).some(([k, v]) => MASTER_KEK_VAR.test(k) && !!v);
15
15
  }
16
16
  export function resolveBootCrypto(envSource, masterKeyOverride) {
17
- const masterKeyProvider = masterKeyOverride ??
18
- (envHasMasterKek(envSource)
19
- ? createEnvMasterKeyProvider({
20
- // CURRENT_VERSION default "1" spiegelt secretsEnvSchema — ohne
21
- // ihn wirft der raw-env-Provider, obwohl V1 gesetzt ist.
22
- env: {
23
- ...envSource,
24
- KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: envSource["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
25
- },
26
- })
27
- : undefined);
17
+ const masterKeyProvider = masterKeyOverride ?? envMasterKeyProviderIfConfigured(envSource);
28
18
  const dekCache = createDekCache();
29
19
  const configCipher = masterKeyProvider
30
20
  ? createEnvelopeCipher(masterKeyProvider, { dekCache })
@@ -37,3 +27,19 @@ export function resolveBootCrypto(envSource, masterKeyOverride) {
37
27
  dekCache,
38
28
  };
39
29
  }
30
+ function envMasterKeyProviderIfConfigured(envSource) {
31
+ if (!envHasMasterKek(envSource))
32
+ return undefined;
33
+ const provider = createEnvMasterKeyProvider({
34
+ // CURRENT_VERSION default "1" spiegelt secretsEnvSchema — ohne
35
+ // ihn wirft der raw-env-Provider, obwohl V1 gesetzt ist.
36
+ env: {
37
+ ...envSource,
38
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: envSource["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
39
+ },
40
+ });
41
+ // The provider defers its keyring check under KUMIKO_DRY_RUN_ENV; the boot
42
+ // must still reject a malformed keyring there.
43
+ provider.currentVersion();
44
+ return provider;
45
+ }
@@ -4,7 +4,7 @@ import { type FeatureDefinition, type Registry } from "@cosmicdrift/kumiko-frame
4
4
  import { type WorkerEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
5
5
  import type { EnvelopeCipher, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
6
6
  import { Redis } from "ioredis";
7
- import type { RunWorkerAppOptions } from "../run-worker-app.js";
7
+ import type { RunWorkerAppOptions } from "../run-worker-app.ts";
8
8
  export type WorkerBootCoreOptions = Omit<RunWorkerAppOptions, "wireComponents">;
9
9
  export type WorkerBootProfile = {
10
10
  /** Log prefix and requireEnv context, e.g. "runWorkerApp". */
@@ -0,0 +1,34 @@
1
+ import { type SubscriptionStripeOptions } from "@cosmicdrift/kumiko-bundled-features/subscription-stripe";
2
+ import type { ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
3
+ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
4
+ /** Mounts `subscription-stripe` plus the webhook route that writes the
5
+ * subscription and syncs the tenant's tier (`tier-engine`). Needs
6
+ * `billing-foundation`, `tier-engine`, `config` and `secrets` in `features`.
7
+ * `STRIPE_API_KEY` / `STRIPE_WEBHOOK_SECRET` from the env are only the
8
+ * bridge fallback; the keys normally live in the secrets store.
9
+ *
10
+ * @example
11
+ * runProdApp({ ..., billing: {
12
+ * stripe: { priceToTier: { [env.STRIPE_PRICE_PRO]: "pro" } },
13
+ * defaultTier: "free",
14
+ * } })
15
+ */
16
+ export type BillingBootOption = {
17
+ readonly stripe: {
18
+ readonly priceToTier: Readonly<Record<string, string>>;
19
+ /** Default `SUBSCRIPTION_WEBHOOK_PATH`; must contain `:providerName`. */
20
+ readonly webhookPath?: string;
21
+ readonly oneOffPriceIds?: SubscriptionStripeOptions["oneOffPriceIds"];
22
+ readonly paymentInvoiceCreation?: boolean;
23
+ };
24
+ /** Tier a tenant falls back to without an active subscription. */
25
+ readonly defaultTier: string;
26
+ /** Valid tier names beyond `defaultTier` and the `priceToTier` targets. */
27
+ readonly tierNames?: readonly string[];
28
+ /** Default `"fail-webhook"`: Stripe retries, so a failed tier sync gets another attempt. */
29
+ readonly onTierSyncError?: "log" | "fail-webhook";
30
+ };
31
+ export declare function resolveBillingOption(billing: BillingBootOption, envSource: Record<string, string | undefined>): {
32
+ readonly feature: FeatureDefinition;
33
+ readonly route: ExtraRouteDefinition;
34
+ };
@@ -0,0 +1,35 @@
1
+ import { createSubscriptionTierSync, SUBSCRIPTION_WEBHOOK_PATH, } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
2
+ import { createSubscriptionStripeFeature, } from "@cosmicdrift/kumiko-bundled-features/subscription-stripe";
3
+ const PROVIDER_NAME_PARAM = ":providerName";
4
+ export function resolveBillingOption(billing, envSource) {
5
+ const webhookPath = billing.stripe.webhookPath ?? SUBSCRIPTION_WEBHOOK_PATH;
6
+ if (!webhookPath.includes(PROVIDER_NAME_PARAM)) {
7
+ throw new Error(`[runProdApp] billing.stripe.webhookPath "${webhookPath}" must contain ${PROVIDER_NAME_PARAM}.`);
8
+ }
9
+ if (Object.keys(billing.stripe.priceToTier).length === 0) {
10
+ // biome-ignore lint/suspicious/noConsole: boot-time warn, no logger configured this early
11
+ console.warn("[runProdApp] billing.stripe.priceToTier is empty: every subscription webhook is ignored until a price maps to a tier.");
12
+ }
13
+ const apiKey = envSource["STRIPE_API_KEY"];
14
+ const webhookSecret = envSource["STRIPE_WEBHOOK_SECRET"];
15
+ const feature = createSubscriptionStripeFeature({
16
+ priceToTier: billing.stripe.priceToTier,
17
+ ...(billing.stripe.oneOffPriceIds && { oneOffPriceIds: billing.stripe.oneOffPriceIds }),
18
+ ...(billing.stripe.paymentInvoiceCreation !== undefined && {
19
+ paymentInvoiceCreation: billing.stripe.paymentInvoiceCreation,
20
+ }),
21
+ ...(apiKey && { apiKey }),
22
+ ...(webhookSecret && { webhookSecret }),
23
+ });
24
+ const tierNames = new Set([
25
+ billing.defaultTier,
26
+ ...Object.values(billing.stripe.priceToTier),
27
+ ...(billing.tierNames ?? []),
28
+ ]);
29
+ const tierSync = createSubscriptionTierSync({
30
+ isTierName: (value) => tierNames.has(value),
31
+ defaultTier: billing.defaultTier,
32
+ onSyncError: billing.onTierSyncError ?? "fail-webhook",
33
+ });
34
+ return { feature, route: tierSync.createWebhookRoute({ path: webhookPath }) };
35
+ }
@@ -0,0 +1,51 @@
1
+ import type { ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
2
+ export type DemoAccountRole = "admin" | "sysadmin";
3
+ /** What `GET /demo-mode` tells the client to show. Every field is optional. */
4
+ export type DemoModeNotice = {
5
+ /** Banner text per locale, e.g. `{ de: "…", en: "…" }`. */
6
+ readonly text?: Readonly<Record<string, string>>;
7
+ readonly links?: readonly {
8
+ readonly label: Readonly<Record<string, string>>;
9
+ readonly href: string;
10
+ }[];
11
+ /** Demo logins to publish, read from `DEMO_ADMIN_*` / `DEMO_SYSADMIN_*`.
12
+ * Published only while `readOnly` is true. Default: none — the route is
13
+ * anonymous, so listing an account hands its password to every visitor. */
14
+ readonly accounts?: readonly DemoAccountRole[];
15
+ };
16
+ /** Live-demo switch. `readOnly: true` answers every mutating request
17
+ * (POST/PUT/PATCH/DELETE) outside `/api/query` and the login/logout/MFA
18
+ * routes with 403 `demo_read_only`. `GET /demo-mode` (anonymous) returns
19
+ * `{ readOnly, notice }` for the client banner.
20
+ *
21
+ * @example
22
+ * runProdApp({ ..., demoMode: {
23
+ * readOnly: env.DEMO_READ_ONLY === "true",
24
+ * notice: { text: { en: "Read-only demo" }, accounts: ["admin"] },
25
+ * } })
26
+ */
27
+ export type DemoModeOption = {
28
+ readonly readOnly: boolean;
29
+ readonly notice?: DemoModeNotice;
30
+ /** Mutating paths that stay open besides the default allowlist. */
31
+ readonly allowedWritePaths?: readonly string[];
32
+ };
33
+ export declare const DEMO_MODE_PATH = "/demo-mode";
34
+ export declare const DEMO_READ_ONLY_ERROR_CODE = "demo_read_only";
35
+ export type DemoModePayload = {
36
+ readonly readOnly: boolean;
37
+ readonly notice: {
38
+ readonly text?: Readonly<Record<string, string>>;
39
+ readonly links?: DemoModeNotice["links"];
40
+ readonly accounts: readonly {
41
+ readonly role: DemoAccountRole;
42
+ readonly email: string;
43
+ readonly password: string;
44
+ }[];
45
+ };
46
+ };
47
+ export declare function buildDemoModePayload(demoMode: DemoModeOption, envSource: Record<string, string | undefined>): DemoModePayload;
48
+ export declare function buildDemoModeRoute(demoMode: DemoModeOption, envSource: Record<string, string | undefined>): ExtraRouteDefinition;
49
+ /** Wraps the app fetch handler; the second argument (socket address) is
50
+ * passed through so client-IP rate limits keep working behind the gate. */
51
+ export declare function withDemoReadOnlyFetch<TArg>(fetchHandler: (req: Request, arg?: TArg) => Promise<Response>, demoMode: DemoModeOption): (req: Request, arg?: TArg) => Promise<Response>;
@@ -0,0 +1,64 @@
1
+ export const DEMO_MODE_PATH = "/demo-mode";
2
+ export const DEMO_READ_ONLY_ERROR_CODE = "demo_read_only";
3
+ const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
4
+ const DEFAULT_ALLOWED_WRITE_PATHS = [
5
+ "/api/query",
6
+ "/api/auth/login",
7
+ "/api/auth/logout",
8
+ "/api/auth/mfa/verify",
9
+ ];
10
+ const ACCOUNT_ENV_KEYS = {
11
+ admin: { email: "DEMO_ADMIN_EMAIL", password: "DEMO_ADMIN_PASSWORD" },
12
+ sysadmin: { email: "DEMO_SYSADMIN_EMAIL", password: "DEMO_SYSADMIN_PASSWORD" },
13
+ };
14
+ export function buildDemoModePayload(demoMode, envSource) {
15
+ const accounts = demoMode.readOnly
16
+ ? (demoMode.notice?.accounts ?? []).flatMap((role) => {
17
+ const keys = ACCOUNT_ENV_KEYS[role];
18
+ const email = envSource[keys.email];
19
+ const password = envSource[keys.password];
20
+ return email && password ? [{ role, email, password }] : [];
21
+ })
22
+ : [];
23
+ return {
24
+ readOnly: demoMode.readOnly,
25
+ notice: {
26
+ ...(demoMode.notice?.text && { text: demoMode.notice.text }),
27
+ ...(demoMode.notice?.links && { links: demoMode.notice.links }),
28
+ accounts,
29
+ },
30
+ };
31
+ }
32
+ export function buildDemoModeRoute(demoMode, envSource) {
33
+ const payload = buildDemoModePayload(demoMode, envSource);
34
+ return {
35
+ method: "GET",
36
+ path: DEMO_MODE_PATH,
37
+ entry: "anonymous",
38
+ handler: (c) => c.json(payload),
39
+ };
40
+ }
41
+ /** Wraps the app fetch handler; the second argument (socket address) is
42
+ * passed through so client-IP rate limits keep working behind the gate. */
43
+ export function withDemoReadOnlyFetch(fetchHandler, demoMode) {
44
+ // skip: no read-only gate requested, keep the handler untouched
45
+ if (!demoMode.readOnly)
46
+ return fetchHandler;
47
+ const allowed = new Set([
48
+ ...DEFAULT_ALLOWED_WRITE_PATHS,
49
+ ...(demoMode.allowedWritePaths ?? []),
50
+ ]);
51
+ return async (req, arg) => {
52
+ if (MUTATING_METHODS.has(req.method) && !allowed.has(new URL(req.url).pathname)) {
53
+ return Response.json({
54
+ isSuccess: false,
55
+ error: {
56
+ code: DEMO_READ_ONLY_ERROR_CODE,
57
+ message: "This live demo is read-only.",
58
+ i18nKey: "errors.demoReadOnly",
59
+ },
60
+ }, { status: 403 });
61
+ }
62
+ return fetchHandler(req, arg);
63
+ };
64
+ }
@@ -0,0 +1,30 @@
1
+ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ export type FileStorageProviderName = "s3-env" | "inmemory";
3
+ /** Mounts the file provider feature(s) for `file-foundation` and makes the
4
+ * chosen one the default (`FILE_STORAGE_PROVIDER`, an explicit env value wins).
5
+ * - `"s3-env"`: one bucket from `S3_BUCKET` / `S3_REGION` / `S3_ACCESS_KEY` /
6
+ * `S3_SECRET_KEY` (optional `S3_ENDPOINT`, `S3_FORCE_PATH_STYLE`); missing
7
+ * values abort the boot.
8
+ * - `"inmemory"`: bytes live in process memory; refused with `NODE_ENV=production`.
9
+ * - `"auto"`: `"s3-env"` when the S3 env is complete; otherwise a boot error
10
+ * with `NODE_ENV=production` and `"inmemory"` elsewhere.
11
+ *
12
+ * `providers` mounts further providers so tenants can switch to them.
13
+ *
14
+ * @example
15
+ * runProdApp({ ..., fileStorage: { provider: "auto" } })
16
+ */
17
+ export type FileStorageBootOption = {
18
+ readonly provider: FileStorageProviderName | "auto";
19
+ readonly providers?: readonly FileStorageProviderName[];
20
+ };
21
+ export declare const FILE_FOUNDATION_FEATURE = "file-foundation";
22
+ export declare function resolveFileStorageOption(fileStorage: FileStorageBootOption, deps: {
23
+ readonly envSource: Record<string, string | undefined>;
24
+ readonly isProduction: boolean;
25
+ readonly mountedFeatureNames: ReadonlySet<string>;
26
+ }): {
27
+ readonly provider: FileStorageProviderName;
28
+ readonly features: readonly FeatureDefinition[];
29
+ readonly envDefaults: Readonly<Record<string, string>>;
30
+ };
@@ -0,0 +1,52 @@
1
+ import { createFileProviderInMemoryFeature } from "@cosmicdrift/kumiko-bundled-features/file-provider-inmemory";
2
+ import { createFileProviderS3Feature } from "@cosmicdrift/kumiko-bundled-features/file-provider-s3";
3
+ import { FILE_STORAGE_PROVIDER_ENV, } from "@cosmicdrift/kumiko-framework/engine";
4
+ export const FILE_FOUNDATION_FEATURE = "file-foundation";
5
+ const S3_REQUIRED_ENV_KEYS = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"];
6
+ const PROVIDER_FEATURE_NAMES = {
7
+ "s3-env": "file-provider-s3-env",
8
+ inmemory: "file-provider-inmemory",
9
+ };
10
+ function missingS3EnvKeys(envSource) {
11
+ return S3_REQUIRED_ENV_KEYS.filter((key) => !envSource[key]);
12
+ }
13
+ function buildProviderFeature(provider) {
14
+ return provider === "s3-env"
15
+ ? createFileProviderS3Feature({ fromEnv: true })
16
+ : createFileProviderInMemoryFeature();
17
+ }
18
+ export function resolveFileStorageOption(fileStorage, deps) {
19
+ const missingS3 = missingS3EnvKeys(deps.envSource);
20
+ let provider;
21
+ if (fileStorage.provider === "auto") {
22
+ if (missingS3.length === 0) {
23
+ provider = "s3-env";
24
+ }
25
+ else if (deps.isProduction) {
26
+ throw new Error(`[runProdApp] fileStorage "auto" with NODE_ENV=production needs S3, missing: ${missingS3.join(", ")}.`);
27
+ }
28
+ else {
29
+ provider = "inmemory";
30
+ }
31
+ }
32
+ else {
33
+ provider = fileStorage.provider;
34
+ }
35
+ if (provider === "s3-env" && missingS3.length > 0) {
36
+ throw new Error(`[runProdApp] fileStorage "s3-env" is missing: ${missingS3.join(", ")}.`);
37
+ }
38
+ if (provider === "inmemory" && deps.isProduction) {
39
+ throw new Error('[runProdApp] fileStorage "inmemory" loses every file on restart — not allowed with NODE_ENV=production.');
40
+ }
41
+ const wanted = new Set([provider, ...(fileStorage.providers ?? [])]);
42
+ const features = [...wanted]
43
+ .filter((name) => !deps.mountedFeatureNames.has(PROVIDER_FEATURE_NAMES[name]))
44
+ .map(buildProviderFeature);
45
+ return {
46
+ provider,
47
+ features,
48
+ envDefaults: deps.envSource[FILE_STORAGE_PROVIDER_ENV]
49
+ ? {}
50
+ : { [FILE_STORAGE_PROVIDER_ENV]: provider },
51
+ };
52
+ }
@@ -0,0 +1,27 @@
1
+ import { type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
2
+ /** Short form over the low-level `kms` / `blindIndexKey` / `allowPlaintextPii`
3
+ * fields:
4
+ * - `"required"`: wires the subject-key KMS from `PLATFORM_KEK`,
5
+ * `SUBJECT_KEYS_DATABASE_URL` and `KUMIKO_BLIND_INDEX_KEY` (ciphertext
6
+ * slots resolved via the Key Manager); an empty trio aborts the boot.
7
+ * - `"optional"`: wires it when the trio is complete. An empty trio leaves the
8
+ * app without KMS and the PII boot gate decides: PII carriers abort.
9
+ * - `{ plaintextReason }`: no KMS, PII stays plaintext; the reason is logged
10
+ * for the audit trail. Use only for demo apps.
11
+ *
12
+ * A partial trio throws in every mode. */
13
+ export type KmsMode = "required" | "optional" | {
14
+ readonly plaintextReason: string;
15
+ };
16
+ export type ResolvedKmsMode = {
17
+ readonly kms?: KmsAdapter;
18
+ readonly blindIndexKey?: string;
19
+ readonly allowPlaintextPii?: string;
20
+ readonly close: () => Promise<void>;
21
+ };
22
+ export type LowLevelKmsFields = {
23
+ readonly kms?: KmsAdapter;
24
+ readonly blindIndexKey?: string;
25
+ readonly allowPlaintextPii?: string;
26
+ };
27
+ export declare function resolveKmsMode(kmsMode: KmsMode | undefined, lowLevel: LowLevelKmsFields, envSource: Record<string, string | undefined>): Promise<ResolvedKmsMode>;
@@ -0,0 +1,40 @@
1
+ import { resolveKmsWiringAsync } from "@cosmicdrift/kumiko-framework/crypto";
2
+ const KMS_CIPHERTEXT_SLOTS = ["PLATFORM_KEK", "PLATFORM_KEK_PREVIOUS", "KUMIKO_BLIND_INDEX_KEY"];
3
+ export async function resolveKmsMode(kmsMode, lowLevel, envSource) {
4
+ const noop = () => Promise.resolve();
5
+ if (kmsMode === undefined) {
6
+ return {
7
+ ...(lowLevel.kms && { kms: lowLevel.kms }),
8
+ ...(lowLevel.blindIndexKey !== undefined && { blindIndexKey: lowLevel.blindIndexKey }),
9
+ ...(lowLevel.allowPlaintextPii !== undefined && {
10
+ allowPlaintextPii: lowLevel.allowPlaintextPii,
11
+ }),
12
+ close: noop,
13
+ };
14
+ }
15
+ if (lowLevel.kms !== undefined ||
16
+ lowLevel.blindIndexKey !== undefined ||
17
+ lowLevel.allowPlaintextPii !== undefined) {
18
+ throw new Error("[runProdApp] kmsMode cannot be combined with kms / blindIndexKey / allowPlaintextPii — pick the short form or the low-level fields.");
19
+ }
20
+ if (typeof kmsMode === "object") {
21
+ if (kmsMode.plaintextReason.trim() === "") {
22
+ throw new Error("[runProdApp] kmsMode.plaintextReason must name a reason.");
23
+ }
24
+ return { allowPlaintextPii: kmsMode.plaintextReason, close: noop };
25
+ }
26
+ const wiring = await resolveKmsWiringAsync(envSource, {
27
+ slots: KMS_CIPHERTEXT_SLOTS,
28
+ logPrefix: "[runProdApp]",
29
+ });
30
+ if ("kms" in wiring) {
31
+ return { kms: wiring.kms, blindIndexKey: wiring.blindIndexKey, close: wiring.close };
32
+ }
33
+ if (kmsMode === "required") {
34
+ throw new Error('[runProdApp] kmsMode "required": PLATFORM_KEK / SUBJECT_KEYS_DATABASE_URL / KUMIKO_BLIND_INDEX_KEY are not set. ' +
35
+ 'Provision the KMS or use kmsMode: { plaintextReason: "…" }.');
36
+ }
37
+ // The wiring's own plaintext fallback reason is deliberately dropped: only an
38
+ // explicit plaintextReason may switch off the PII boot gate.
39
+ return { close: noop };
40
+ }
@@ -0,0 +1,30 @@
1
+ import { type EmailChannelOptions, type EmailTransport, type TenantTransportResolver } from "@cosmicdrift/kumiko-bundled-features/channel-email";
2
+ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
3
+ /** Mounts `channel-email` with an app-wide transport and wires the auth mail
4
+ * flows (`auth.mail`) to it.
5
+ * - `"smtp-env"`: SMTP from `SMTP_HOST` / `SMTP_PORT` / `SMTP_SECURE` /
6
+ * `SMTP_USER` / `SMTP_PASS` / `SMTP_FROM`; a missing `SMTP_HOST` aborts the boot.
7
+ * - `"inmemory"`: keeps mails in memory; refused when `NODE_ENV=production`.
8
+ * - an `EmailTransport` object: used as is.
9
+ *
10
+ * @example
11
+ * runProdApp({ ..., mail: { transport: "smtp-env", from: "Acme <noreply@acme.example>" } })
12
+ */
13
+ export type MailBootOption = {
14
+ readonly transport: "smtp-env" | "inmemory" | EmailTransport;
15
+ /** Sender when `SMTP_FROM` is unset (`"smtp-env"` only). Default `auth.mail.from`, then `noreply@localhost`. */
16
+ readonly from?: string;
17
+ /** Maps a user id to the stored email. Default: the `user` row of that tenant. */
18
+ readonly resolveEmail?: EmailChannelOptions["resolveEmail"];
19
+ /** Per-tenant transport, see `createChannelEmailFeature`. */
20
+ readonly tenantTransport?: TenantTransportResolver;
21
+ };
22
+ export declare const CHANNEL_EMAIL_FEATURE = "channel-email";
23
+ export declare function resolveMailOption(mail: MailBootOption, deps: {
24
+ readonly envSource: Record<string, string | undefined>;
25
+ readonly isProduction: boolean;
26
+ readonly authMailFrom: string | undefined;
27
+ }): {
28
+ readonly feature: FeatureDefinition;
29
+ readonly transport: EmailTransport;
30
+ };
@@ -0,0 +1,44 @@
1
+ import { createChannelEmailFeature, createInMemoryTransport, createSmtpTransportFromEnv, } from "@cosmicdrift/kumiko-bundled-features/channel-email";
2
+ import { userTable } from "@cosmicdrift/kumiko-bundled-features/user";
3
+ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
4
+ export const CHANNEL_EMAIL_FEATURE = "channel-email";
5
+ // channel-email decrypts the returned value itself, so the raw stored column
6
+ // (ciphertext under an active KMS) is the right thing to hand back.
7
+ async function resolveStoredUserEmail(userId, ctx) {
8
+ const rows = await selectMany(ctx.db, userTable, {
9
+ id: userId,
10
+ tenantId: ctx.tenantId,
11
+ });
12
+ return rows[0]?.email ?? null;
13
+ }
14
+ function buildSmtpEnvTransport(envSource, fallbackFrom) {
15
+ if (Boolean(envSource["SMTP_USER"]) !== Boolean(envSource["SMTP_PASS"])) {
16
+ throw new Error('[runProdApp] mail.transport "smtp-env": SMTP_USER and SMTP_PASS must be set together.');
17
+ }
18
+ const transport = createSmtpTransportFromEnv(envSource, { fallbackFrom });
19
+ if (!transport) {
20
+ throw new Error('[runProdApp] mail.transport "smtp-env" needs SMTP_HOST.');
21
+ }
22
+ return transport;
23
+ }
24
+ export function resolveMailOption(mail, deps) {
25
+ let transport;
26
+ if (mail.transport === "smtp-env") {
27
+ transport = buildSmtpEnvTransport(deps.envSource, mail.from ?? deps.authMailFrom ?? "noreply@localhost");
28
+ }
29
+ else if (mail.transport === "inmemory") {
30
+ if (deps.isProduction) {
31
+ throw new Error('[runProdApp] mail.transport "inmemory" drops every mail — not allowed with NODE_ENV=production.');
32
+ }
33
+ transport = createInMemoryTransport();
34
+ }
35
+ else {
36
+ transport = mail.transport;
37
+ }
38
+ const feature = createChannelEmailFeature({
39
+ transport,
40
+ resolveEmail: mail.resolveEmail ?? resolveStoredUserEmail,
41
+ ...(mail.tenantTransport && { tenantTransport: mail.tenantTransport }),
42
+ });
43
+ return { feature, transport };
44
+ }
@@ -0,0 +1,7 @@
1
+ import { type ComposedEnvSchema } from "@cosmicdrift/kumiko-framework/env";
2
+ import { type BootOptionFields } from "./resolve-boot-options.ts";
3
+ /** Adds the env blocks the boot options read to the app's composed schema, so
4
+ * the dry run and the env validation cover them without the app composing
5
+ * them itself. A key the app already declares keeps the app's field unless
6
+ * the option needs it set; then the stricter, required field wins. */
7
+ export declare function withBootOptionEnvBlocks(composed: ComposedEnvSchema, options: BootOptionFields): ComposedEnvSchema;
@@ -0,0 +1,113 @@
1
+ import { smtpEnvSchema } from "@cosmicdrift/kumiko-bundled-features/channel-email";
2
+ import { kmsEnvSchema } from "@cosmicdrift/kumiko-bundled-features/crypto-shredding/env";
3
+ import { s3EnvSchema } from "@cosmicdrift/kumiko-bundled-features/file-provider-s3";
4
+ import { demoAdminEnvSchema } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
5
+ import { subscriptionStripeEnvSchema } from "@cosmicdrift/kumiko-bundled-features/subscription-stripe";
6
+ import { readKumikoMeta } from "@cosmicdrift/kumiko-framework/env";
7
+ import * as z from "zod";
8
+ import { isSeedFnList } from "./resolve-boot-options.js";
9
+ const S3_REQUIRED_KEYS = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"];
10
+ // `.unwrap()` drops what sits on the optional wrapper: the description and the
11
+ // kumiko meta (pulumi secret, kms), both of which the dry run reports.
12
+ function requiredField(field) {
13
+ if (!(field instanceof z.ZodOptional))
14
+ return field;
15
+ const inner = field.unwrap();
16
+ if (!(inner instanceof z.ZodType))
17
+ return field;
18
+ const described = field.description ? inner.describe(field.description) : inner;
19
+ const meta = readKumikoMeta(field);
20
+ return Object.keys(meta).length > 0 ? described.meta({ kumiko: meta }) : described;
21
+ }
22
+ function pickShape(shape, keys) {
23
+ const picked = {};
24
+ for (const key of keys) {
25
+ const field = shape[key];
26
+ if (field)
27
+ picked[key] = field;
28
+ }
29
+ return picked;
30
+ }
31
+ function seedEnvBlock(seeds) {
32
+ const keys = [];
33
+ const required = [];
34
+ if (seeds.demoTenant && seeds.demoTenant.id === undefined) {
35
+ keys.push("DEMO_TENANT_ID");
36
+ required.push("DEMO_TENANT_ID");
37
+ }
38
+ if (seeds.demoAdminFromEnv) {
39
+ keys.push("DEMO_ADMIN_EMAIL", "DEMO_ADMIN_PASSWORD");
40
+ required.push("DEMO_ADMIN_EMAIL", "DEMO_ADMIN_PASSWORD");
41
+ }
42
+ if (seeds.sysadminFromEnv) {
43
+ keys.push("DEMO_SYSADMIN_EMAIL", "DEMO_SYSADMIN_PASSWORD");
44
+ required.push("DEMO_SYSADMIN_EMAIL", "DEMO_SYSADMIN_PASSWORD");
45
+ }
46
+ if (keys.length === 0)
47
+ return undefined;
48
+ return { source: "runProdApp.seeds", shape: pickShape(demoAdminEnvSchema.shape, keys), required };
49
+ }
50
+ function collectOptionEnvBlocks(options) {
51
+ const blocks = [];
52
+ if (options.mail?.transport === "smtp-env") {
53
+ blocks.push({ source: "runProdApp.mail", shape: smtpEnvSchema.shape, required: ["SMTP_HOST"] });
54
+ }
55
+ const { fileStorage } = options;
56
+ if (fileStorage &&
57
+ (fileStorage.provider === "s3-env" ||
58
+ fileStorage.provider === "auto" ||
59
+ fileStorage.providers?.includes("s3-env"))) {
60
+ // auto may fall back to in-memory outside production, so its keys stay
61
+ // optional here; resolveFileStorageOption rejects a partial set.
62
+ blocks.push({
63
+ source: "runProdApp.fileStorage",
64
+ shape: s3EnvSchema.shape,
65
+ required: fileStorage.provider === "s3-env" ? S3_REQUIRED_KEYS : [],
66
+ });
67
+ }
68
+ // The trio stays optional even under "required": each KMS slot may arrive
69
+ // as its *_CIPHERTEXT twin instead, which resolveKmsMode checks after decryption.
70
+ if (options.kmsMode === "required" || options.kmsMode === "optional") {
71
+ blocks.push({ source: "runProdApp.kmsMode", shape: kmsEnvSchema.shape, required: [] });
72
+ }
73
+ if (options.billing) {
74
+ blocks.push({
75
+ source: "runProdApp.billing",
76
+ shape: subscriptionStripeEnvSchema.shape,
77
+ required: [],
78
+ });
79
+ }
80
+ if (options.seeds && !isSeedFnList(options.seeds)) {
81
+ const block = seedEnvBlock(options.seeds);
82
+ if (block)
83
+ blocks.push(block);
84
+ }
85
+ return blocks;
86
+ }
87
+ /** Adds the env blocks the boot options read to the app's composed schema, so
88
+ * the dry run and the env validation cover them without the app composing
89
+ * them itself. A key the app already declares keeps the app's field unless
90
+ * the option needs it set; then the stricter, required field wins. */
91
+ export function withBootOptionEnvBlocks(composed, options) {
92
+ const blocks = collectOptionEnvBlocks(options);
93
+ if (blocks.length === 0)
94
+ return composed;
95
+ const declared = composed.schema.shape;
96
+ const additions = {};
97
+ const sources = { ...composed.sources };
98
+ for (const block of blocks) {
99
+ const required = new Set(block.required);
100
+ for (const [key, field] of Object.entries(block.shape)) {
101
+ const existing = additions[key] ?? declared[key];
102
+ if (required.has(key)) {
103
+ additions[key] = requiredField(existing ?? field);
104
+ sources[key] ??= block.source;
105
+ }
106
+ else if (existing === undefined) {
107
+ additions[key] = field;
108
+ sources[key] = block.source;
109
+ }
110
+ }
111
+ }
112
+ return { schema: composed.schema.safeExtend(additions), sources };
113
+ }
@@ -0,0 +1,23 @@
1
+ export type ShutdownSignal = "SIGTERM" | "SIGINT";
2
+ /** `false` keeps the process signals untouched (the app installs its own
3
+ * handlers). The object form tunes the drain budget. */
4
+ export type SignalsOption = false | {
5
+ readonly drainTimeoutMs?: number;
6
+ };
7
+ export declare const DEFAULT_DRAIN_TIMEOUT_MS = 25000;
8
+ export type SignalProcess = {
9
+ on(signal: ShutdownSignal, listener: () => void): unknown;
10
+ off(signal: ShutdownSignal, listener: () => void): unknown;
11
+ };
12
+ export type ShutdownSignalDeps = {
13
+ readonly stop: () => Promise<void>;
14
+ readonly drainTimeoutMs?: number;
15
+ readonly signalProcess?: SignalProcess;
16
+ readonly exit?: (code: number) => void;
17
+ readonly log?: (message: string) => void;
18
+ readonly logError?: (message: string, error: unknown) => void;
19
+ };
20
+ /** Registers SIGTERM/SIGINT: the first signal drains via `stop()` and exits
21
+ * 0, a drain that fails or outlives the timeout exits 1. Returns the
22
+ * uninstall function so `stop()` can drop the listeners again. */
23
+ export declare function installShutdownSignals(deps: ShutdownSignalDeps): () => void;