@cosmicdrift/kumiko-server-runtime 0.363.0 → 0.365.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.
@@ -2,7 +2,7 @@ import type { AuthEmailPasswordOptions } from "@cosmicdrift/kumiko-bundled-featu
2
2
  import { type DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
3
  import { type FeatureDefinition, type Registry } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { type WorkerEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
5
- import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
5
+ import type { EnvelopeCipher, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
6
6
  import { Redis } from "ioredis";
7
7
  import type { RunWorkerAppOptions } from "../run-worker-app.js";
8
8
  export type WorkerBootCoreOptions = Omit<RunWorkerAppOptions, "wireComponents">;
@@ -26,6 +26,8 @@ export type BootedWorkerProcess = {
26
26
  /** ctx.secrets as wired into the entrypoint (auto-wired when the secrets
27
27
  * feature is mounted and a master key exists, or from `extraContext`). */
28
28
  readonly secrets?: SecretsContext;
29
+ /** Envelope cipher for `encrypted` config keys, when a master key is wired. */
30
+ readonly configCipher?: EnvelopeCipher;
29
31
  readonly close: () => Promise<void>;
30
32
  };
31
33
  export type WorkerBootResult = {
@@ -6,7 +6,7 @@
6
6
  import { loadJwtSecretOrKeyring } from "@cosmicdrift/kumiko-framework/api";
7
7
  import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
8
8
  import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
9
- import { createRegistry, findTierResolverUsage, isTierResolverPlugin, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { createRegistry, findTierResolverUsage, requireTierResolverPlugin, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
10
10
  import { createWorkerEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
11
11
  import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumiko-framework/migrations";
12
12
  import { createEntityCache, createEventDedup, createIdempotencyGuard, } from "@cosmicdrift/kumiko-framework/pipeline";
@@ -39,11 +39,7 @@ async function buildTierEffectiveFeatures(features, db, registry, cacheSync) {
39
39
  const tierResolverUsage = findTierResolverUsage(features);
40
40
  if (!tierResolverUsage)
41
41
  return undefined;
42
- const plugin = tierResolverUsage.options;
43
- if (!isTierResolverPlugin(plugin)) {
44
- throw new Error("tier-resolver registration options must provide a build() factory");
45
- }
46
- return plugin.build({ db, registry, cacheSync });
42
+ return requireTierResolverPlugin(tierResolverUsage).build({ db, registry, cacheSync });
47
43
  }
48
44
  async function assertWorkerSchemaCurrent(db, migrationsDir, processName) {
49
45
  const dir = migrationsDir ?? "./kumiko/migrations";
@@ -188,6 +184,7 @@ export async function bootWorkerProcess(options, envSource, profile) {
188
184
  envSource,
189
185
  entrypoint,
190
186
  ...(isSecretsContext(extraContext["secrets"]) && { secrets: extraContext["secrets"] }),
187
+ ...(bootCrypto.configCipher && { configCipher: bootCrypto.configCipher }),
191
188
  close: async () => {
192
189
  await entrypoint.stop();
193
190
  await closeDb();
@@ -15,16 +15,15 @@
15
15
  import { AUTH_SELF_REGISTRATION_FEATURE, createAuthEmailPasswordFeature, createAuthSelfRegistrationToggleFeature, } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
16
16
  import { AUTH_MFA_FEATURE, mfaStatusCheckerFromFeature, } from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
17
17
  import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
18
- import { createFilesTenantDataFeature } from "@cosmicdrift/kumiko-bundled-features/files-tenant-data";
18
+ import { createFilesTenantDataFeature, FILES_TENANT_DATA_FEATURE, } from "@cosmicdrift/kumiko-bundled-features/files-tenant-data";
19
19
  import { createRendererFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
20
20
  import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
21
21
  import { collectAssignableAppRoles, createTenantFeature, } from "@cosmicdrift/kumiko-bundled-features/tenant";
22
22
  import { TENANT_LIFECYCLE_FEATURE } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
23
23
  import { createUserFeature } from "@cosmicdrift/kumiko-bundled-features/user";
24
24
  import { dedupeFeatures } from "@cosmicdrift/kumiko-framework/engine";
25
+ import { FILES_FEATURE } from "@cosmicdrift/kumiko-framework/files";
25
26
  import { createRendererMailHtmlFeature } from "@cosmicdrift/kumiko-renderer-mail-html";
26
- const FILES_FEATURE = "files";
27
- const FILES_TENANT_DATA_FEATURE = "files-tenant-data";
28
27
  export function composeFeatures(appFeatures, options) {
29
28
  // ponytail: includeBundled:false skips the auth-mfa auto-wiring below —
30
29
  // an app composing its own foundation and mounting auth-mfa itself is
@@ -9,5 +9,7 @@ export type RunBootstrapOptions = Omit<RunWorkerAppOptions, "wireComponents" | "
9
9
  };
10
10
  /** Returns the report, or undefined in KUMIKO_DRY_RUN_ENV=boot mode. Without
11
11
  * an injected `envSource` (a real process) it exits the process when done,
12
- * so a k8s Job never hangs on a leftover socket. */
12
+ * so a k8s Job never hangs on a leftover socket. A SystemAdmin invite that
13
+ * came out `role-mismatch` or `cancelled` fails the run (exit code 1, or a
14
+ * thrown error with an injected `envSource`). */
13
15
  export declare function runBootstrap(options: RunBootstrapOptions): Promise<BootstrapReport | undefined>;
@@ -19,6 +19,12 @@ function maskEmailForLog(email) {
19
19
  const at = email.indexOf("@");
20
20
  return at <= 0 ? "***" : `${email[0]}***${email.slice(at)}`;
21
21
  }
22
+ // An admin invite that can never grant its global role leaves the first deployment without
23
+ // a SystemAdmin; a green Job would hide that.
24
+ function unprovisionedGlobalRoleInvites(report) {
25
+ return report.invites.filter((invite) => invite.globalRoles.length > 0 &&
26
+ (invite.outcome === "role-mismatch" || invite.outcome === "cancelled"));
27
+ }
22
28
  function logBootstrapReport(report) {
23
29
  for (const tenant of report.tenants) {
24
30
  // biome-ignore lint/suspicious/noConsole: the report IS the deliverable of a one-shot process
@@ -32,7 +38,9 @@ function logBootstrapReport(report) {
32
38
  }
33
39
  /** Returns the report, or undefined in KUMIKO_DRY_RUN_ENV=boot mode. Without
34
40
  * an injected `envSource` (a real process) it exits the process when done,
35
- * so a k8s Job never hangs on a leftover socket. */
41
+ * so a k8s Job never hangs on a leftover socket. A SystemAdmin invite that
42
+ * came out `role-mismatch` or `cancelled` fails the run (exit code 1, or a
43
+ * thrown error with an injected `envSource`). */
36
44
  export async function runBootstrap(options) {
37
45
  const envSource = await resolveWorkerEnvSource(options, PROCESS_NAME);
38
46
  const jwtSecret = requireEnv("JWT_SECRET", envSource, PROCESS_NAME);
@@ -52,7 +60,13 @@ export async function runBootstrap(options) {
52
60
  return undefined;
53
61
  let report;
54
62
  try {
55
- report = await bootstrapTenants({ db: boot.db, redis: boot.redis, dispatcher: boot.entrypoint.dispatcher }, {
63
+ report = await bootstrapTenants({
64
+ db: boot.db,
65
+ redis: boot.redis,
66
+ dispatcher: boot.entrypoint.dispatcher,
67
+ registry: boot.registry,
68
+ ...(boot.configCipher && { configCipher: boot.configCipher }),
69
+ }, {
56
70
  tenants: options.tenants,
57
71
  ...(options.systemAdmins && { systemAdmins: options.systemAdmins }),
58
72
  ...(options.seed && { seed: options.seed }),
@@ -62,6 +76,15 @@ export async function runBootstrap(options) {
62
76
  await boot.close();
63
77
  }
64
78
  logBootstrapReport(report);
79
+ const unprovisioned = unprovisionedGlobalRoleInvites(report);
80
+ if (unprovisioned.length > 0) {
81
+ const message = `[${PROCESS_NAME}] ${unprovisioned.length} invite(s) cannot grant their global role (${unprovisioned.map((invite) => `${maskEmailForLog(invite.email)}: ${invite.outcome}`).join(", ")})`;
82
+ if (options.envSource !== undefined)
83
+ throw new Error(message);
84
+ // biome-ignore lint/suspicious/noConsole: the failure reason of a one-shot process
85
+ console.error(message);
86
+ process.exit(1);
87
+ }
65
88
  // biome-ignore lint/suspicious/noConsole: boot-time progress hint
66
89
  console.log(`[${PROCESS_NAME}] done.`);
67
90
  if (options.envSource === undefined) {
@@ -115,15 +115,22 @@ function createBootConfigResolver(registry, envSource, crypto) {
115
115
  ...(crypto.configCipher && { cipher: crypto.configCipher }),
116
116
  });
117
117
  }
118
+ // The delivery service reads tenant config too (tenant name/locale in mail
119
+ // layouts), so apps without auth still get a resolver for it; the ctx entry
120
+ // stays auth-only.
121
+ function resolveDeliveryConfigResolver(opts, hasDeliveryFeature, crypto) {
122
+ return opts.hasAuth || hasDeliveryFeature
123
+ ? createBootConfigResolver(opts.registry, opts.envSource, crypto)
124
+ : undefined;
125
+ }
118
126
  export function buildBootExtraContext(opts) {
119
127
  const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
120
128
  const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
121
129
  const hasTenantFeature = opts.features.some((f) => f.name === TENANT_FEATURE);
122
130
  const escapeHatchAuditSink = resolveEscapeHatchAuditSink(opts.features, opts.db);
123
131
  const secrets = resolveBootSecrets(opts.db, opts.features, crypto, opts.registry);
124
- const configResolver = opts.hasAuth
125
- ? createBootConfigResolver(opts.registry, opts.envSource, crypto)
126
- : undefined;
132
+ const bootConfigResolver = resolveDeliveryConfigResolver(opts, hasDeliveryFeature, crypto);
133
+ const configResolver = opts.hasAuth ? bootConfigResolver : undefined;
127
134
  return {
128
135
  templateResolver: createTemplateResolverApi(opts.db),
129
136
  ...(opts.kms && { kms: opts.kms }),
@@ -138,7 +145,7 @@ export function buildBootExtraContext(opts) {
138
145
  ...(escapeHatchAuditSink && { escapeHatchAuditSink }),
139
146
  ...(opts.log && { log: opts.log }),
140
147
  ...(hasTenantFeature && { tenantUserIdsQuery: TenantQueries.resolveUserIds }),
141
- configResolver,
148
+ configResolver: bootConfigResolver,
142
149
  deliverQueuedInline: opts.deliverQueuedInline,
143
150
  }),
144
151
  }),
@@ -48,10 +48,10 @@ import { SECRETS_MASTER_KEK_ENV_KEYS } from "@cosmicdrift/kumiko-bundled-feature
48
48
  import { SESSIONS_FEATURE } from "@cosmicdrift/kumiko-bundled-features/sessions";
49
49
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
50
50
  import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
51
- import { createDefaultSseBroker, createRedisLoginRateLimiter, loadJwtSecretOrKeyring, parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, } from "@cosmicdrift/kumiko-framework/api";
51
+ import { createDefaultSseBroker, createRedisLoginRateLimiter, loadJwtSecretOrKeyring, parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, withFileProviderResolver, } from "@cosmicdrift/kumiko-framework/api";
52
52
  import { configureBlindIndexKey, configurePiiSubjectKms, resolvePlatformKeks, } from "@cosmicdrift/kumiko-framework/crypto";
53
53
  import { configureEntityFieldEncryption, createDbConnection, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/db";
54
- import { collectWriteHandlerQns, createRegistry, findTierResolverUsage, isTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
54
+ import { collectWriteHandlerQns, createRegistry, findTierResolverUsage, requireTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
55
55
  import { createAllInOneEntrypoint, createApiEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
56
56
  import { KumikoBootError, kmsSlotsOf, parseEnv, withOptionalEnvKeys, } from "@cosmicdrift/kumiko-framework/env";
57
57
  import { renderDryRun } from "@cosmicdrift/kumiko-framework/env/dry-run";
@@ -359,11 +359,11 @@ export async function runProdApp(options) {
359
359
  if (resolvedEffectiveFeatures === undefined) {
360
360
  const tierResolverUsage = findTierResolverUsage(features);
361
361
  if (tierResolverUsage) {
362
- const plugin = tierResolverUsage.options;
363
- if (!isTierResolverPlugin(plugin)) {
364
- throw new Error("tier-resolver registration options must provide a build() factory");
365
- }
366
- resolvedEffectiveFeatures = await plugin.build({ db, registry, cacheSync });
362
+ resolvedEffectiveFeatures = await requireTierResolverPlugin(tierResolverUsage).build({
363
+ db,
364
+ registry,
365
+ cacheSync,
366
+ });
367
367
  }
368
368
  }
369
369
  // 5. Schema-Drift-Gate (drizzle-frei, kumiko/migrations). `kumiko schema
@@ -425,9 +425,9 @@ export async function runProdApp(options) {
425
425
  // it lazily, entities without encrypted fields never touch it.
426
426
  configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
427
427
  // Subject-key KMS for pii-annotated fields (crypto-shredding, #724).
428
- // No adapter = engine off, plaintext as before — the warning keeps the gap
429
- // visible until the prod-grade PgKmsAdapter (phase E) makes a hard boot
430
- // gate viable (InMemory in prod would lose every DEK on restart).
428
+ // No adapter only gets this far when assertPiiBootInvariants above let it
429
+ // through: no PII carriers, or an explicit allowPlaintextPii opt-out. The
430
+ // engine then stays off and those fields are stored in plaintext.
431
431
  configurePiiSubjectKms(options.kms);
432
432
  // Blind-Index-Key (#818) — Boot-Gate: mit aktivem KMS würden lookupable-
433
433
  // Felder als Ciphertext gespeichert, ohne Key gäbe es keinen bidx-Arm und
@@ -469,6 +469,11 @@ export async function runProdApp(options) {
469
469
  // über die auth-routes.
470
470
  // Secure-by-default (#1372): sessionStore provider → resolveSessionStore.
471
471
  const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
472
+ if (mfaFeature && registry.getWriteHandler(AuthHandlers.switchTenantMfaGate) === undefined) {
473
+ throw new Error(`[runProdApp] auth-mfa is mounted but ${AuthHandlers.switchTenantMfaGate} is not registered: ` +
474
+ "createAuthEmailPasswordFeature registers it only when mfaStatusChecker is set, and without it " +
475
+ "POST /auth/switch-tenant would mint sessions without the second factor");
476
+ }
472
477
  // Hoisted above buildProdSessionAuth: that call also wires MFA-enable/
473
478
  // disable → PAT-revoke (bindRevokeAllPatTokensFromFeature), so it needs
474
479
  // patFeature too.
@@ -692,7 +697,7 @@ export async function runProdApp(options) {
692
697
  ...(bootCrypto.configCipher && { cipher: bootCrypto.configCipher }),
693
698
  });
694
699
  const seedDispatcher = (options.seeds?.length ?? 0) > 0 || options.seedsDir !== undefined
695
- ? createDispatcher(registry, { db, redis, entityCache, registry, ...extraContext })
700
+ ? createDispatcher(registry, withFileProviderResolver(registry, { db, redis, entityCache, registry, ...extraContext }, cacheSync))
696
701
  : undefined;
697
702
  if (seedDispatcher) {
698
703
  for (const seed of options.seeds ?? []) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.363.0",
3
+ "version": "0.365.0",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -84,12 +84,12 @@
84
84
  }
85
85
  },
86
86
  "dependencies": {
87
- "@cosmicdrift/kumiko-bundled-features": "0.363.0",
88
- "@cosmicdrift/kumiko-framework": "0.363.0",
89
- "@cosmicdrift/kumiko-headless": "0.363.0",
90
- "@cosmicdrift/kumiko-renderer-mail-html": "0.363.0",
91
- "@cosmicdrift/kumiko-renderer-web": "0.363.0",
92
- "@cosmicdrift/kumiko-types": "0.363.0",
87
+ "@cosmicdrift/kumiko-bundled-features": "0.365.0",
88
+ "@cosmicdrift/kumiko-framework": "0.365.0",
89
+ "@cosmicdrift/kumiko-headless": "0.365.0",
90
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.365.0",
91
+ "@cosmicdrift/kumiko-renderer-web": "0.365.0",
92
+ "@cosmicdrift/kumiko-types": "0.365.0",
93
93
  "bun-types": "^1.3.13",
94
94
  "ioredis": "^5.10.1",
95
95
  "temporal-polyfill": "^0.3.2"
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Bootstrap fails when a global-role invite cannot be provisioned; delivery notify works without auth",
6
+ "detail": "runBootstrap now fails with a non-zero exit when an invite with global roles ends role-mismatch or cancelled. The delivery notify factory gets a config resolver also when the app has no auth feature."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "fix",
11
+ "title": "Seeds can create fileRefs through handlers that use createFileRefFromBytes",
12
+ "detail": "The seed dispatcher built its handler context without the file provider resolver, so a write handler invoked via systemWriteAs failed with \"createFileRefFromBytes: ctx.files is unavailable\". The seed context now carries the same file provider as the HTTP path."
13
+ },
2
14
  {
3
15
  "version": "0.363.0",
4
16
  "type": "improvement",