@cosmicdrift/kumiko-server-runtime 0.374.0 → 0.377.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 (35) hide show
  1. package/dist/boot/env-gate.d.ts +23 -0
  2. package/dist/boot/env-gate.js +136 -0
  3. package/dist/boot/worker-boot-core.d.ts +3 -0
  4. package/dist/boot/worker-boot-core.js +10 -2
  5. package/dist/boot-options/demo-mode.d.ts +7 -1
  6. package/dist/boot-options/demo-mode.js +5 -2
  7. package/dist/boot-options/feature-toggles-option.d.ts +19 -0
  8. package/dist/boot-options/feature-toggles-option.js +25 -0
  9. package/dist/boot-options/file-storage-option.d.ts +11 -2
  10. package/dist/boot-options/file-storage-option.js +48 -9
  11. package/dist/boot-options/mail-option.d.ts +8 -3
  12. package/dist/boot-options/mail-option.js +21 -6
  13. package/dist/boot-options/option-env-schema.js +30 -9
  14. package/dist/boot-options/resolve-boot-options.d.ts +9 -1
  15. package/dist/boot-options/resolve-boot-options.js +37 -4
  16. package/dist/boot-options/stack-boot-requirements.d.ts +5 -2
  17. package/dist/boot-options/stack-boot-requirements.js +23 -8
  18. package/dist/build-prod-bundle.d.ts +3 -3
  19. package/dist/build-prod-bundle.js +5 -5
  20. package/dist/extra-routes-deps.d.ts +5 -0
  21. package/dist/index.d.ts +5 -2
  22. package/dist/index.js +3 -0
  23. package/dist/renderer-web-fonts.js +1 -1
  24. package/dist/request-bound-system-query.d.ts +1 -1
  25. package/dist/resolve-tailwind-cli.d.ts +1 -1
  26. package/dist/resolve-tailwind-cli.js +1 -1
  27. package/dist/run-prod-app-boot-context.d.ts +4 -0
  28. package/dist/run-prod-app-boot-context.js +12 -5
  29. package/dist/run-prod-app-static-files.js +1 -1
  30. package/dist/run-prod-app.d.ts +22 -12
  31. package/dist/run-prod-app.js +45 -144
  32. package/dist/run-worker-app.d.ts +23 -4
  33. package/dist/run-worker-app.js +51 -5
  34. package/package.json +7 -8
  35. package/src/changes.json +51 -0
@@ -0,0 +1,23 @@
1
+ import { type ComposedEnvSchema, KumikoBootError } from "@cosmicdrift/kumiko-framework/env";
2
+ import { type DryRunEnvMode } from "@cosmicdrift/kumiko-framework/env/dry-run";
3
+ import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
4
+ import type { BootOptionFields } from "../boot-options/resolve-boot-options.ts";
5
+ export type RunMode = DryRunEnvMode;
6
+ export declare function parseRunMode(raw: string | undefined, processName?: string): RunMode | null;
7
+ export type EnvGateOptions = BootOptionFields & {
8
+ readonly envSchema: ComposedEnvSchema | undefined;
9
+ readonly envSource?: Record<string, string | undefined>;
10
+ readonly masterKey?: MasterKeyProvider;
11
+ readonly pulumiPrefix?: string;
12
+ readonly bootErrorReporter?: (err: KumikoBootError) => void;
13
+ };
14
+ export type EnvGateResult = {
15
+ readonly kind: "stopped";
16
+ } | {
17
+ readonly kind: "ready";
18
+ readonly runMode: RunMode | null;
19
+ readonly decryptedEnvSource: Record<string, string | undefined>;
20
+ };
21
+ /** Env schema parse, dry-run output and KEK-slot decryption, in front of any boot work.
22
+ * `stopped` means a dry-run mode produced its output and the caller returns an inert handle. */
23
+ export declare function runEnvGate(options: EnvGateOptions, processName: string): Promise<EnvGateResult>;
@@ -0,0 +1,136 @@
1
+ import { SECRETS_MASTER_KEK_ENV_KEYS } from "@cosmicdrift/kumiko-bundled-features/secrets";
2
+ import { resolvePlatformKeks } from "@cosmicdrift/kumiko-framework/crypto";
3
+ import { inspectEnv, KumikoBootError, kmsSlotsOf, parseEnv, withOptionalEnvKeys, } from "@cosmicdrift/kumiko-framework/env";
4
+ import { DRY_RUN_ENV_VAR, parseDryRunEnvMode, renderDryRun, renderEnvInspection, } from "@cosmicdrift/kumiko-framework/env/dry-run";
5
+ import { withBootOptionEnvBlocks } from "../boot-options/option-env-schema.js";
6
+ // `boot` is the smoke-test path: validators run, no DB/Redis connect, exit
7
+ // after the registry build. Render modes (human|json|pulumi|k8s|1) inspect the
8
+ // env schema and exit before any feature wiring.
9
+ export function parseRunMode(raw, processName = "runProdApp") {
10
+ const mode = parseDryRunEnvMode(raw);
11
+ if (mode !== undefined)
12
+ return mode;
13
+ if (raw) {
14
+ // biome-ignore lint/suspicious/noConsole: boot-time warn for typo discovery
15
+ console.warn(`[${processName}] ${DRY_RUN_ENV_VAR}="${raw}" unrecognized ` +
16
+ `(expected 1|human|json|pulumi|k8s|boot); continuing with normal boot.`);
17
+ }
18
+ return null;
19
+ }
20
+ function isRenderMode(mode) {
21
+ return mode !== null && mode !== "boot";
22
+ }
23
+ function defaultBootErrorReporter(err) {
24
+ // biome-ignore lint/suspicious/noConsole: boot-time error, no logger configured yet
25
+ console.error(err.format());
26
+ process.exit(1);
27
+ }
28
+ // The boot dry run lists these even when the app schema leaves them out
29
+ // (JWT_SECRET usually comes from auth-email-password).
30
+ const RUNTIME_REQUIRED_ENV_KEYS = ["DATABASE_URL", "REDIS_URL", "JWT_SECRET"];
31
+ function inspectUndeclaredRuntimeEnv(declaredShape, env) {
32
+ return RUNTIME_REQUIRED_ENV_KEYS.filter((name) => !(name in declaredShape)).map((name) => ({
33
+ name,
34
+ klass: "required",
35
+ source: "server-runtime",
36
+ status: env[name] ? "set" : "missing",
37
+ }));
38
+ }
39
+ function requireEnvSchema(envSchema, processName) {
40
+ if (envSchema === undefined) {
41
+ throw new KumikoBootError([
42
+ {
43
+ name: "envSchema",
44
+ kind: "missing",
45
+ message: `${processName} 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 ${processName}'s.`,
46
+ },
47
+ ]);
48
+ }
49
+ return envSchema;
50
+ }
51
+ /** Env schema parse, dry-run output and KEK-slot decryption, in front of any boot work.
52
+ * `stopped` means a dry-run mode produced its output and the caller returns an inert handle. */
53
+ export async function runEnvGate(options, processName) {
54
+ const rawEnvSource = options.envSource ?? process.env;
55
+ const runMode = parseRunMode(rawEnvSource[DRY_RUN_ENV_VAR], processName);
56
+ const composedEnvSchema = withBootOptionEnvBlocks(requireEnvSchema(options.envSchema, processName), options);
57
+ const effectiveEnvSchema = options.masterKey
58
+ ? withOptionalEnvKeys(composedEnvSchema.schema, SECRETS_MASTER_KEK_ENV_KEYS)
59
+ : composedEnvSchema.schema;
60
+ const reportBootError = (err) => {
61
+ const reporter = options.bootErrorReporter ?? defaultBootErrorReporter;
62
+ reporter(err);
63
+ };
64
+ const validateEnv = (source) => {
65
+ try {
66
+ parseEnv(effectiveEnvSchema, source, {
67
+ sources: composedEnvSchema.sources,
68
+ ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
69
+ });
70
+ }
71
+ catch (err) {
72
+ if (err instanceof KumikoBootError)
73
+ reportBootError(err);
74
+ throw err;
75
+ }
76
+ };
77
+ if (isRenderMode(runMode)) {
78
+ // biome-ignore lint/suspicious/noConsole: dry-run output IS the deliverable
79
+ console.log(renderDryRun(composedEnvSchema, runMode, {
80
+ ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
81
+ sources: composedEnvSchema.sources,
82
+ }));
83
+ // Tests inject envSource and want a return value, not an exit.
84
+ if (options.envSource === undefined) {
85
+ process.exit(0);
86
+ }
87
+ return { kind: "stopped" };
88
+ }
89
+ if (runMode === "boot") {
90
+ const inspection = [
91
+ ...inspectEnv(effectiveEnvSchema, rawEnvSource, { sources: composedEnvSchema.sources }),
92
+ ...inspectUndeclaredRuntimeEnv(effectiveEnvSchema.shape, rawEnvSource),
93
+ ];
94
+ // biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
95
+ console.log(renderEnvInspection(inspection));
96
+ if (inspection.some((row) => row.status === "invalid")) {
97
+ // Missing rows go into the same report: the run fails anyway, so the
98
+ // operator sees every variable to fix in one pass.
99
+ reportBootError(new KumikoBootError(inspection.flatMap((row) => row.status === "invalid" || row.status === "missing"
100
+ ? [
101
+ {
102
+ name: row.name,
103
+ kind: row.status,
104
+ message: row.message ?? (row.status === "missing" ? "missing" : "invalid value"),
105
+ source: row.source,
106
+ },
107
+ ]
108
+ : [])));
109
+ return { kind: "stopped" };
110
+ }
111
+ // A build pipeline has no deploy secrets: the inventory is the result,
112
+ // and the wiring check needs every value, so it stops here.
113
+ if (inspection.some((row) => row.status === "missing")) {
114
+ if (options.envSource === undefined) {
115
+ process.exit(0);
116
+ }
117
+ return { kind: "stopped" };
118
+ }
119
+ }
120
+ // boot-mode with a complete env AND normal boot run the full validation,
121
+ // including object-level refinements the per-field check skips.
122
+ validateEnv(rawEnvSource);
123
+ // Slots the schema marks `kms` are decrypted once here so every consumer
124
+ // below sees the same resolved env.
125
+ const kmsSlots = kmsSlotsOf(composedEnvSchema.schema);
126
+ const decryptedEnvSource = await resolvePlatformKeks(rawEnvSource, {
127
+ slots: kmsSlots,
128
+ logPrefix: `[${processName}]`,
129
+ });
130
+ // The first parse relaxes ciphertext-only slots, so their decrypted
131
+ // plaintext never met the slot's own validators (.regex/.min/.refine).
132
+ if (kmsSlots.some((slot) => rawEnvSource[slot] === undefined && decryptedEnvSource[slot] !== undefined)) {
133
+ validateEnv(decryptedEnvSource);
134
+ }
135
+ return { kind: "ready", runMode, decryptedEnvSource };
136
+ }
@@ -4,6 +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 { FeatureTogglesWiring } from "../boot-options/feature-toggles-option.ts";
7
8
  import type { RunWorkerAppOptions } from "../run-worker-app.ts";
8
9
  export type WorkerBootCoreOptions = Omit<RunWorkerAppOptions, "wireComponents">;
9
10
  export type WorkerBootProfile = {
@@ -14,6 +15,8 @@ export type WorkerBootProfile = {
14
15
  /** One-shot process: its job queue is not drained after exit, so ctx.notify
15
16
  * sends queued channels inline. */
16
17
  readonly deliverQueuedInline?: boolean;
18
+ /** Set by runWorkerApp when `featureToggles` mounted the feature; bound once the database exists. */
19
+ readonly featureToggles?: FeatureTogglesWiring;
17
20
  };
18
21
  export type BootedWorkerProcess = {
19
22
  readonly kind: "booted";
@@ -128,8 +128,16 @@ export async function bootWorkerProcess(options, envSource, profile) {
128
128
  await cacheSync.close();
129
129
  throw err;
130
130
  }
131
- const resolvedEffectiveFeatures = options.effectiveFeatures ??
132
- (await buildTierEffectiveFeatures(features, db, registry, cacheSync));
131
+ let resolvedEffectiveFeatures = await buildTierEffectiveFeatures(features, db, registry, cacheSync);
132
+ if (profile.featureToggles) {
133
+ const bound = await profile.featureToggles.bind({
134
+ db,
135
+ registry,
136
+ cacheSync,
137
+ tierResolver: resolvedEffectiveFeatures,
138
+ });
139
+ resolvedEffectiveFeatures = bound.resolver;
140
+ }
133
141
  if (options.migrations !== false) {
134
142
  try {
135
143
  await assertWorkerSchemaCurrent(db, options.migrations?.dir, processName);
@@ -1,5 +1,10 @@
1
1
  import type { ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
2
2
  export type DemoAccountRole = "admin" | "sysadmin";
3
+ /** A role alone, or a role with the label the banner shows next to the login. */
4
+ export type DemoAccountEntry = DemoAccountRole | {
5
+ readonly role: DemoAccountRole;
6
+ readonly label?: Readonly<Record<string, string>>;
7
+ };
3
8
  /** What `GET /demo-mode` tells the client to show. Every field is optional. */
4
9
  export type DemoModeNotice = {
5
10
  /** Banner text per locale, e.g. `{ de: "…", en: "…" }`. */
@@ -11,7 +16,7 @@ export type DemoModeNotice = {
11
16
  /** Demo logins to publish, read from `DEMO_ADMIN_*` / `DEMO_SYSADMIN_*`.
12
17
  * Published only while `readOnly` is true. Default: none — the route is
13
18
  * anonymous, so listing an account hands its password to every visitor. */
14
- readonly accounts?: readonly DemoAccountRole[];
19
+ readonly accounts?: readonly DemoAccountEntry[];
15
20
  };
16
21
  /** Live-demo switch. `readOnly: true` answers every mutating request
17
22
  * (POST/PUT/PATCH/DELETE) outside `/api/query` and the login/logout/MFA
@@ -39,6 +44,7 @@ export type DemoModePayload = {
39
44
  readonly links?: DemoModeNotice["links"];
40
45
  readonly accounts: readonly {
41
46
  readonly role: DemoAccountRole;
47
+ readonly label?: Readonly<Record<string, string>>;
42
48
  readonly email: string;
43
49
  readonly password: string;
44
50
  }[];
@@ -13,11 +13,14 @@ const ACCOUNT_ENV_KEYS = {
13
13
  };
14
14
  export function buildDemoModePayload(demoMode, envSource) {
15
15
  const accounts = demoMode.readOnly
16
- ? (demoMode.notice?.accounts ?? []).flatMap((role) => {
16
+ ? (demoMode.notice?.accounts ?? []).flatMap((entry) => {
17
+ const { role, label } = typeof entry === "string" ? { role: entry, label: undefined } : entry;
17
18
  const keys = ACCOUNT_ENV_KEYS[role];
18
19
  const email = envSource[keys.email];
19
20
  const password = envSource[keys.password];
20
- return email && password ? [{ role, email, password }] : [];
21
+ return email && password
22
+ ? [{ role, ...(label !== undefined && { label }), email, password }]
23
+ : [];
21
24
  })
22
25
  : [];
23
26
  return {
@@ -0,0 +1,19 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { EffectiveFeaturesResolver, FeatureDefinition, Registry } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { CacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
4
+ export type FeatureTogglesBinding = {
5
+ /** Resolver for the dispatcher: the global toggles, narrowing the tier resolver when one exists. */
6
+ readonly resolver: EffectiveFeaturesResolver;
7
+ };
8
+ export type FeatureTogglesWiring = {
9
+ readonly feature: FeatureDefinition;
10
+ /** Loads the toggle snapshot; call once the database, the registry and the cache bus exist. */
11
+ readonly bind: (deps: {
12
+ readonly db: DbConnection;
13
+ readonly registry: Registry;
14
+ readonly cacheSync: CacheSyncBus;
15
+ readonly tierResolver: EffectiveFeaturesResolver | undefined;
16
+ }) => Promise<FeatureTogglesBinding>;
17
+ };
18
+ /** `featureToggles: true` mounts `feature-toggles` and layers its operator switches over the tier resolver. */
19
+ export declare function resolveFeatureTogglesOption(): FeatureTogglesWiring;
@@ -0,0 +1,25 @@
1
+ import { composeTierResolverWithGlobalToggles, createFeatureToggleRuntime, createFeatureTogglesFeature, } from "@cosmicdrift/kumiko-bundled-features/feature-toggles";
2
+ /** `featureToggles: true` mounts `feature-toggles` and layers its operator switches over the tier resolver. */
3
+ export function resolveFeatureTogglesOption() {
4
+ let runtime;
5
+ const feature = createFeatureTogglesFeature({
6
+ getRuntime: () => {
7
+ if (runtime === undefined) {
8
+ throw new Error("feature-toggles: the toggle runtime is not bound yet (boot order).");
9
+ }
10
+ return runtime;
11
+ },
12
+ });
13
+ return {
14
+ feature,
15
+ bind: async ({ db, registry, cacheSync, tierResolver }) => {
16
+ runtime = await createFeatureToggleRuntime(db, registry, undefined, cacheSync);
17
+ const toggles = runtime;
18
+ return {
19
+ resolver: tierResolver
20
+ ? composeTierResolverWithGlobalToggles(tierResolver, toggles, registry)
21
+ : () => toggles.effectiveFeatures(),
22
+ };
23
+ },
24
+ };
25
+ }
@@ -9,15 +9,24 @@ export type FileStorageProviderName = "s3-env" | "inmemory";
9
9
  * - `"auto"`: `"s3-env"` when the S3 env is complete; otherwise a boot error
10
10
  * with `NODE_ENV=production` and `"inmemory"` elsewhere.
11
11
  *
12
- * `providers` mounts further providers so tenants can switch to them.
12
+ * `providers` mounts further providers so tenants can switch to them. A
13
+ * `{ name, envPrefix }` entry adds a named S3 provider reading `<envPrefix>BUCKET`
14
+ * etc.; its credentials are required at boot unless `lazy` is set.
13
15
  *
14
16
  * @example
15
17
  * runProdApp({ ..., fileStorage: { provider: "auto" } })
16
18
  */
19
+ export type NamedS3FileProvider = {
20
+ readonly name: string;
21
+ readonly envPrefix: string;
22
+ readonly lazy?: boolean;
23
+ };
17
24
  export type FileStorageBootOption = {
18
25
  readonly provider: FileStorageProviderName | "auto";
19
- readonly providers?: readonly FileStorageProviderName[];
26
+ readonly providers?: readonly (FileStorageProviderName | NamedS3FileProvider)[];
20
27
  };
28
+ export declare function isNamedS3FileProvider(provider: FileStorageProviderName | NamedS3FileProvider): provider is NamedS3FileProvider;
29
+ export declare function s3KeysOf(envPrefix: string): readonly string[];
21
30
  export declare const FILE_FOUNDATION_FEATURE = "file-foundation";
22
31
  export declare function resolveFileStorageOption(fileStorage: FileStorageBootOption, deps: {
23
32
  readonly envSource: Record<string, string | undefined>;
@@ -1,14 +1,36 @@
1
1
  import { createFileProviderInMemoryFeature } from "@cosmicdrift/kumiko-bundled-features/file-provider-inmemory";
2
- import { createFileProviderS3Feature } from "@cosmicdrift/kumiko-bundled-features/file-provider-s3";
2
+ import { createFileProviderS3Feature, createFileProviderS3NamedEnvFeature, } from "@cosmicdrift/kumiko-bundled-features/file-provider-s3";
3
3
  import { FILE_STORAGE_PROVIDER_ENV, } from "@cosmicdrift/kumiko-framework/engine";
4
+ export function isNamedS3FileProvider(provider) {
5
+ return typeof provider === "object";
6
+ }
7
+ export function s3KeysOf(envPrefix) {
8
+ return S3_KEY_SUFFIXES.map((suffix) => `${envPrefix}${suffix}`);
9
+ }
4
10
  export const FILE_FOUNDATION_FEATURE = "file-foundation";
5
- const S3_REQUIRED_ENV_KEYS = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"];
11
+ const S3_KEY_SUFFIXES = ["BUCKET", "REGION", "ACCESS_KEY", "SECRET_KEY"];
12
+ const S3_REQUIRED_ENV_KEYS = s3KeysOf("S3_");
6
13
  const PROVIDER_FEATURE_NAMES = {
7
14
  "s3-env": "file-provider-s3-env",
8
15
  inmemory: "file-provider-inmemory",
9
16
  };
10
- function missingS3EnvKeys(envSource) {
11
- return S3_REQUIRED_ENV_KEYS.filter((key) => !envSource[key]);
17
+ function missingEnvKeys(envSource, keys) {
18
+ return keys.filter((key) => !envSource[key]);
19
+ }
20
+ // Plugin names the bundled providers register: a named provider reusing one would
21
+ // shadow it (or collide at registry build) for every tenant that selects it.
22
+ const RESERVED_PROVIDER_NAMES = new Set(["s3-env", "s3", "inmemory"]);
23
+ function assertNamedProviderNames(named) {
24
+ const seen = new Set();
25
+ for (const { name } of named) {
26
+ if (RESERVED_PROVIDER_NAMES.has(name)) {
27
+ throw new Error(`[runProdApp] fileStorage provider name "${name}" is reserved for a bundled provider — pick another name.`);
28
+ }
29
+ if (seen.has(name)) {
30
+ throw new Error(`[runProdApp] fileStorage provider "${name}" is listed twice.`);
31
+ }
32
+ seen.add(name);
33
+ }
12
34
  }
13
35
  function buildProviderFeature(provider) {
14
36
  return provider === "s3-env"
@@ -16,7 +38,7 @@ function buildProviderFeature(provider) {
16
38
  : createFileProviderInMemoryFeature();
17
39
  }
18
40
  export function resolveFileStorageOption(fileStorage, deps) {
19
- const missingS3 = missingS3EnvKeys(deps.envSource);
41
+ const missingS3 = missingEnvKeys(deps.envSource, S3_REQUIRED_ENV_KEYS);
20
42
  let provider;
21
43
  if (fileStorage.provider === "auto") {
22
44
  if (missingS3.length === 0) {
@@ -38,10 +60,27 @@ export function resolveFileStorageOption(fileStorage, deps) {
38
60
  if (provider === "inmemory" && deps.isProduction) {
39
61
  throw new Error('[runProdApp] fileStorage "inmemory" loses every file on restart — not allowed with NODE_ENV=production.');
40
62
  }
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);
63
+ const additional = fileStorage.providers ?? [];
64
+ const wanted = new Set([
65
+ provider,
66
+ ...additional.filter((entry) => !isNamedS3FileProvider(entry)),
67
+ ]);
68
+ const named = additional.filter(isNamedS3FileProvider);
69
+ assertNamedProviderNames(named);
70
+ for (const entry of named) {
71
+ const missing = entry.lazy ? [] : missingEnvKeys(deps.envSource, s3KeysOf(entry.envPrefix));
72
+ if (missing.length > 0) {
73
+ throw new Error(`[runProdApp] fileStorage provider "${entry.name}" is missing: ${missing.join(", ")}.`);
74
+ }
75
+ }
76
+ const features = [
77
+ ...[...wanted]
78
+ .filter((name) => !deps.mountedFeatureNames.has(PROVIDER_FEATURE_NAMES[name]))
79
+ .map(buildProviderFeature),
80
+ ...named
81
+ .filter((entry) => !deps.mountedFeatureNames.has(`file-provider-s3-env-${entry.name}`))
82
+ .map((entry) => createFileProviderS3NamedEnvFeature({ name: entry.name, envPrefix: entry.envPrefix })),
83
+ ];
45
84
  return {
46
85
  provider,
47
86
  features,
@@ -1,5 +1,5 @@
1
1
  import { type EmailChannelOptions, type EmailTransport, type TenantTransportResolver } from "@cosmicdrift/kumiko-bundled-features/channel-email";
2
- import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
3
3
  /** Mounts `channel-email` with an app-wide transport and wires the auth mail
4
4
  * flows (`auth.mail`) to it.
5
5
  * - `"smtp-env"`: SMTP from `SMTP_HOST` / `SMTP_PORT` / `SMTP_SECURE` /
@@ -10,17 +10,22 @@ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
10
10
  * @example
11
11
  * runProdApp({ ..., mail: { transport: "smtp-env", from: "Acme <noreply@acme.example>" } })
12
12
  */
13
+ export type MailRecipientScope = "membership" | "any";
13
14
  export type MailBootOption = {
14
15
  readonly transport: "smtp-env" | "inmemory" | EmailTransport;
15
16
  /** Sender when `SMTP_FROM` is unset (`"smtp-env"` only). Default `auth.mail.from`, then `noreply@localhost`. */
16
17
  readonly from?: string;
17
- /** Maps a user id to the stored email. Default: the `user` row with that id (any tenant). */
18
+ /** Which users the default resolver reaches from a tenant other than the system tenant.
19
+ * `"membership"` (default): members of the notifying tenant and SystemAdmins.
20
+ * `"any"`: every user, whatever tenant notifies. Ignored with a custom `resolveEmail`. */
21
+ readonly recipientScope?: MailRecipientScope;
22
+ /** Maps a user id to the stored email. Default: see `recipientScope`. */
18
23
  readonly resolveEmail?: EmailChannelOptions["resolveEmail"];
19
24
  /** Per-tenant transport, see `createChannelEmailFeature`. */
20
25
  readonly tenantTransport?: TenantTransportResolver;
21
26
  };
22
27
  export declare const CHANNEL_EMAIL_FEATURE = "channel-email";
23
- export declare function resolveStoredUserEmail(userId: string, ctx: Parameters<EmailChannelOptions["resolveEmail"]>[1]): Promise<string | null>;
28
+ export declare function resolveStoredUserEmail(userId: string, ctx: Parameters<EmailChannelOptions["resolveEmail"]>[1], recipientScope?: MailRecipientScope): Promise<string | null>;
24
29
  export declare function resolveMailOption(mail: MailBootOption, deps: {
25
30
  readonly envSource: Record<string, string | undefined>;
26
31
  readonly isProduction: boolean;
@@ -1,14 +1,28 @@
1
1
  import { createChannelEmailFeature, createInMemoryTransport, createSmtpTransportFromEnv, } from "@cosmicdrift/kumiko-bundled-features/channel-email";
2
+ import { tenantMembershipsTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
2
3
  import { userTable } from "@cosmicdrift/kumiko-bundled-features/user";
3
- import { selectMany } from "@cosmicdrift/kumiko-framework/db";
4
+ import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
5
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
6
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
7
+ import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
4
8
  export const CHANNEL_EMAIL_FEATURE = "channel-email";
5
9
  // channel-email decrypts the returned value itself, so the raw stored column
6
10
  // (ciphertext under an active KMS) is the right thing to hand back.
7
- export async function resolveStoredUserEmail(userId, ctx) {
8
- const rows = await selectMany(ctx.db, userTable, {
9
- id: userId,
11
+ // User rows live on SYSTEM_TENANT_ID; the tenant-scoped db still reads them.
12
+ // SystemAdmin comes from the recipient's stored global roles, never from the session.
13
+ export async function resolveStoredUserEmail(userId, ctx, recipientScope = "membership") {
14
+ const user = await fetchOne(ctx.db, userTable, { id: userId });
15
+ if (!user)
16
+ return null;
17
+ if (recipientScope === "any" || ctx.tenantId === SYSTEM_TENANT_ID)
18
+ return user.email;
19
+ const membership = await fetchOne(ctx.db, tenantMembershipsTable, {
20
+ tenantId: ctx.tenantId,
21
+ userId,
10
22
  });
11
- return rows[0]?.email ?? null;
23
+ if (membership || parseRoles(user.roles).includes(ROLES.SystemAdmin))
24
+ return user.email;
25
+ return null;
12
26
  }
13
27
  function buildSmtpEnvTransport(envSource, fallbackFrom) {
14
28
  if (Boolean(envSource["SMTP_USER"]) !== Boolean(envSource["SMTP_PASS"])) {
@@ -36,7 +50,8 @@ export function resolveMailOption(mail, deps) {
36
50
  }
37
51
  const feature = createChannelEmailFeature({
38
52
  transport,
39
- resolveEmail: mail.resolveEmail ?? resolveStoredUserEmail,
53
+ resolveEmail: mail.resolveEmail ??
54
+ ((userId, ctx) => resolveStoredUserEmail(userId, ctx, mail.recipientScope)),
40
55
  ...(mail.tenantTransport && { tenantTransport: mail.tenantTransport }),
41
56
  });
42
57
  return { feature, transport };
@@ -1,21 +1,24 @@
1
1
  import { smtpEnvSchema } from "@cosmicdrift/kumiko-bundled-features/channel-email";
2
2
  import { kmsEnvSchema } from "@cosmicdrift/kumiko-bundled-features/crypto-shredding/env";
3
- import { s3EnvSchema } from "@cosmicdrift/kumiko-bundled-features/file-provider-s3";
3
+ import { createS3EnvSchema, s3EnvSchema, } from "@cosmicdrift/kumiko-bundled-features/file-provider-s3";
4
4
  import { demoAdminEnvSchema } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
5
5
  import { subscriptionStripeEnvSchema } from "@cosmicdrift/kumiko-bundled-features/subscription-stripe";
6
6
  import { readKumikoMeta } from "@cosmicdrift/kumiko-framework/env";
7
7
  import * as z from "zod";
8
+ import { isNamedS3FileProvider, s3KeysOf } from "./file-storage-option.js";
8
9
  import { isSeedFnList } from "./resolve-boot-options.js";
10
+ const SMTP_HOST_REQUIRED_DESCRIPTION = 'SMTP host; required with mail transport "smtp-env".';
9
11
  const S3_REQUIRED_KEYS = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"];
10
12
  // `.unwrap()` drops what sits on the optional wrapper: the description and the
11
13
  // kumiko meta (pulumi secret, kms), both of which the dry run reports.
12
- function requiredField(field) {
14
+ function requiredField(field, description) {
13
15
  if (!(field instanceof z.ZodOptional))
14
16
  return field;
15
17
  const inner = field.unwrap();
16
18
  if (!(inner instanceof z.ZodType))
17
19
  return field;
18
- const described = field.description ? inner.describe(field.description) : inner;
20
+ const text = description ?? field.description;
21
+ const described = text ? inner.describe(text) : inner;
19
22
  const meta = readKumikoMeta(field);
20
23
  return Object.keys(meta).length > 0 ? described.meta({ kumiko: meta }) : described;
21
24
  }
@@ -47,12 +50,8 @@ function seedEnvBlock(seeds) {
47
50
  return undefined;
48
51
  return { source: "runProdApp.seeds", shape: pickShape(demoAdminEnvSchema.shape, keys), required };
49
52
  }
50
- function collectOptionEnvBlocks(options) {
53
+ function fileStorageEnvBlocks(fileStorage) {
51
54
  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
55
  if (fileStorage &&
57
56
  (fileStorage.provider === "s3-env" ||
58
57
  fileStorage.provider === "auto" ||
@@ -65,6 +64,28 @@ function collectOptionEnvBlocks(options) {
65
64
  required: fileStorage.provider === "s3-env" ? S3_REQUIRED_KEYS : [],
66
65
  });
67
66
  }
67
+ for (const provider of fileStorage?.providers ?? []) {
68
+ if (!isNamedS3FileProvider(provider))
69
+ continue;
70
+ blocks.push({
71
+ source: `runProdApp.fileStorage.${provider.name}`,
72
+ shape: createS3EnvSchema({ prefix: provider.envPrefix }).shape,
73
+ required: provider.lazy ? [] : s3KeysOf(provider.envPrefix),
74
+ });
75
+ }
76
+ return blocks;
77
+ }
78
+ function collectOptionEnvBlocks(options) {
79
+ const blocks = [];
80
+ if (options.mail?.transport === "smtp-env") {
81
+ blocks.push({
82
+ source: "runProdApp.mail",
83
+ shape: smtpEnvSchema.shape,
84
+ required: ["SMTP_HOST"],
85
+ descriptions: { SMTP_HOST: SMTP_HOST_REQUIRED_DESCRIPTION },
86
+ });
87
+ }
88
+ blocks.push(...fileStorageEnvBlocks(options.fileStorage));
68
89
  // The trio stays optional even under "required": each KMS slot may arrive
69
90
  // as its *_CIPHERTEXT twin instead, which resolveKmsMode checks after decryption.
70
91
  if (options.kmsMode === "required" || options.kmsMode === "optional") {
@@ -100,7 +121,7 @@ export function withBootOptionEnvBlocks(composed, options) {
100
121
  for (const [key, field] of Object.entries(block.shape)) {
101
122
  const existing = additions[key] ?? declared[key];
102
123
  if (required.has(key)) {
103
- additions[key] = requiredField(existing ?? field);
124
+ additions[key] = requiredField(existing ?? field, block.descriptions?.[key]);
104
125
  sources[key] ??= block.source;
105
126
  }
106
127
  else if (existing === undefined) {
@@ -3,6 +3,7 @@ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import type { ProdSeedFn } from "../run-prod-app.ts";
4
4
  import { type BillingBootOption } from "./billing-option.ts";
5
5
  import { type DemoModeOption } from "./demo-mode.ts";
6
+ import { type FeatureTogglesWiring } from "./feature-toggles-option.ts";
6
7
  import { type FileStorageBootOption } from "./file-storage-option.ts";
7
8
  import { type KmsMode, type ResolvedKmsMode } from "./kms-mode.ts";
8
9
  import { type MailBootOption } from "./mail-option.ts";
@@ -13,12 +14,16 @@ export type BootOptionFields = {
13
14
  readonly kmsMode?: KmsMode;
14
15
  readonly fileStorage?: FileStorageBootOption;
15
16
  readonly billing?: BillingBootOption;
17
+ readonly featureToggles?: boolean;
16
18
  readonly demoMode?: DemoModeOption;
17
19
  readonly seeds?: readonly ProdSeedFn[] | ProdSeedsConfig;
18
20
  readonly kms?: ResolvedKmsMode["kms"];
19
21
  readonly blindIndexKey?: string;
20
22
  readonly allowPlaintextPii?: string;
21
23
  readonly runSingleInstance?: boolean;
24
+ /** With `runSingleInstance: false`: the worker process mounts the same mail / fileStorage
25
+ * features (its own boot options or by hand), which silences the boot warning. */
26
+ readonly workerMountsFeatures?: boolean;
22
27
  readonly auth?: {
23
28
  readonly admin?: unknown;
24
29
  readonly mail?: {
@@ -34,11 +39,14 @@ export type BootOptionWiring = {
34
39
  readonly kms: ResolvedKmsMode;
35
40
  readonly shortcutSeeds: readonly DbSeedFn[];
36
41
  readonly customSeeds: readonly ProdSeedFn[];
42
+ /** Set when `featureToggles` mounted the feature; bind it once the database exists. */
43
+ readonly featureToggles?: FeatureTogglesWiring;
37
44
  /** True when `mail` mounted a transport; the auth mail flows then wire without `SMTP_HOST`. */
38
45
  readonly mailConfigured: boolean;
39
46
  readonly wrapFetch: <TArg>(handler: (req: Request, arg?: TArg) => Promise<Response>) => (req: Request, arg?: TArg) => Promise<Response>;
40
47
  };
41
- export declare function isSeedFnList(seeds: readonly ProdSeedFn[] | ProdSeedsConfig): seeds is readonly ProdSeedFn[];
48
+ export declare function isSeedFnList<Fn, Config extends object>(seeds: readonly Fn[] | Config): seeds is readonly Fn[];
49
+ export declare function rejectRemovedEffectiveFeatures(options: object): void;
42
50
  /** Runs after the env is validated and KEK slots are decrypted, before any
43
51
  * connection: every misconfiguration of the new options fails here. */
44
52
  export declare function resolveBootOptionWiring(options: BootOptionFields, envSource: Record<string, string | undefined>): Promise<BootOptionWiring>;