@cosmicdrift/kumiko-server-runtime 0.388.0 → 0.390.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 (55) hide show
  1. package/dist/boot/apply-boot-seeds.js +11 -8
  2. package/dist/boot/job-run-logger.js +1 -1
  3. package/dist/boot/shared-boot-steps.d.ts +8 -5
  4. package/dist/boot/shared-boot-steps.js +26 -16
  5. package/dist/boot/stream-tenant-backfill-on-boot.d.ts +27 -0
  6. package/dist/boot/stream-tenant-backfill-on-boot.js +161 -0
  7. package/dist/boot/wait-for-database.d.ts +2 -0
  8. package/dist/boot/wait-for-database.js +4 -3
  9. package/dist/boot/warn-row-level-security-off.d.ts +3 -0
  10. package/dist/boot/warn-row-level-security-off.js +6 -0
  11. package/dist/boot/worker-boot-core.d.ts +2 -1
  12. package/dist/boot/worker-boot-core.js +28 -5
  13. package/dist/boot/worker-metrics-server.js +2 -0
  14. package/dist/boot-options/demo-mode.d.ts +3 -3
  15. package/dist/boot-options/demo-mode.js +4 -1
  16. package/dist/boot-options/resolve-boot-options.d.ts +1 -0
  17. package/dist/boot-options/resolve-boot-options.js +2 -2
  18. package/dist/boot-options/seed-shortcuts.js +3 -1
  19. package/dist/boot-options/stack-boot-requirements.d.ts +1 -1
  20. package/dist/boot-options/stack-boot-requirements.js +1 -13
  21. package/dist/build-prod-bundle.js +13 -13
  22. package/dist/bundled-lists-table.d.ts +2 -0
  23. package/dist/bundled-lists-table.js +56 -0
  24. package/dist/compose-auth-options.d.ts +16 -0
  25. package/dist/compose-auth-options.js +49 -0
  26. package/dist/compose-features.d.ts +5 -17
  27. package/dist/compose-features.js +13 -53
  28. package/dist/extra-routes-deps.d.ts +5 -1
  29. package/dist/extra-routes-deps.js +1 -0
  30. package/dist/files-for-tenant.d.ts +3 -0
  31. package/dist/files-for-tenant.js +10 -0
  32. package/dist/renderer-web-fonts.js +3 -9
  33. package/dist/resolve-installed-package.d.ts +10 -0
  34. package/dist/resolve-installed-package.js +32 -0
  35. package/dist/resolve-tailwind-cli.d.ts +3 -8
  36. package/dist/resolve-tailwind-cli.js +8 -25
  37. package/dist/run-prod-app-boot-context.d.ts +2 -0
  38. package/dist/run-prod-app-boot-context.js +11 -9
  39. package/dist/run-prod-app-config.d.ts +2 -0
  40. package/dist/run-prod-app-config.js +3 -1
  41. package/dist/run-prod-app-connections.d.ts +2 -1
  42. package/dist/run-prod-app-connections.js +11 -7
  43. package/dist/run-prod-app-entrypoint.d.ts +1 -1
  44. package/dist/run-prod-app-entrypoint.js +6 -2
  45. package/dist/run-prod-app-handle.js +4 -1
  46. package/dist/run-prod-app-request-context.js +9 -2
  47. package/dist/run-prod-app-seeds.js +39 -24
  48. package/dist/run-prod-app-static-files.js +9 -1
  49. package/dist/run-prod-app.d.ts +19 -4
  50. package/dist/run-prod-app.js +7 -3
  51. package/dist/run-worker-app.d.ts +22 -4
  52. package/dist/run-worker-app.js +22 -4
  53. package/dist/seed-dispatcher.js +3 -1
  54. package/package.json +19 -8
  55. package/src/changes.json +92 -0
@@ -2,6 +2,7 @@ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { Logger } from "@cosmicdrift/kumiko-framework/logging";
3
3
  import { createRedisCacheSyncBus } from "@cosmicdrift/kumiko-framework/redis";
4
4
  import { Redis } from "ioredis";
5
+ import { type DatabaseWaitClock } from "./boot/wait-for-database.ts";
5
6
  import type { RunProdAppOptions } from "./run-prod-app.ts";
6
7
  import type { ProdBootConfig, RegisterBootAbortCleanup } from "./run-prod-app-config.ts";
7
8
  export type ProdConnections = {
@@ -12,5 +13,5 @@ export type ProdConnections = {
12
13
  readonly redis: Redis;
13
14
  readonly cacheSync: ReturnType<typeof createRedisCacheSyncBus>;
14
15
  };
15
- export declare function connectProdDependencies(config: ProdBootConfig, log: Logger, registerAbortCleanup: RegisterBootAbortCleanup): Promise<ProdConnections>;
16
+ export declare function connectProdDependencies(config: ProdBootConfig, log: Logger, registerAbortCleanup: RegisterBootAbortCleanup, databaseWait?: DatabaseWaitClock): Promise<ProdConnections>;
16
17
  export declare function assertProdSchemaCurrent(db: DbConnection, migrations: RunProdAppOptions["migrations"], log: Logger): Promise<void>;
@@ -2,27 +2,31 @@ import { assertKumikoSchemaCurrent, SchemaDriftError, } from "@cosmicdrift/kumik
2
2
  import { createRedisCacheSyncBus, logRedisConnectionState, redisClientOptionsFromEnv, } from "@cosmicdrift/kumiko-framework/redis";
3
3
  import { Redis } from "ioredis";
4
4
  import { openDatabasePools, waitForKmsHealthy } from "./boot/shared-boot-steps.js";
5
- import { WAIT_FOR_DATABASE_DEADLINE_MS, waitForDatabase } from "./boot/wait-for-database.js";
5
+ import { WAIT_FOR_DATABASE_DEADLINE_MS, waitForDatabase, } from "./boot/wait-for-database.js";
6
6
  // Postgres + Redis. The Redis client is shared by idempotency, event-dedup,
7
7
  // entity-cache, rate-limit; failing to construct here surfaces the misconfig
8
8
  // immediately. `new Redis(...)` connects eagerly, so this must stay AFTER the
9
9
  // boot-mode exit.
10
- export async function connectProdDependencies(config, log, registerAbortCleanup) {
11
- const { bootKms, databaseUrl, redisUrl, envSource } = config;
10
+ export async function connectProdDependencies(config, log, registerAbortCleanup, databaseWait) {
11
+ const { bootKms, databaseUrl, redisUrl, envSource, rowLevelSecurity } = config;
12
12
  // KMS health gate: an app configured for crypto-shredding must not boot
13
13
  // against an unreachable key store — a silent start would answer every
14
14
  // PII read with 503. Runs BEFORE connections so an abort leaks nothing.
15
- const databaseWaitDeadline = Date.now() + WAIT_FOR_DATABASE_DEADLINE_MS;
15
+ const databaseWaitDeadline = (databaseWait?.now ?? Date.now)() + WAIT_FOR_DATABASE_DEADLINE_MS;
16
16
  if (bootKms.kms) {
17
- await waitForKmsHealthy(bootKms.kms, "runProdApp", databaseWaitDeadline);
17
+ await waitForKmsHealthy(bootKms.kms, "runProdApp", databaseWaitDeadline, databaseWait);
18
18
  }
19
- const pools = openDatabasePools(databaseUrl, envSource);
19
+ const pools = openDatabasePools(databaseUrl, envSource, rowLevelSecurity);
20
20
  const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
21
21
  registerAbortCleanup(pools.closeDb);
22
22
  registerAbortCleanup(pools.closeOutsideTransactionDb);
23
23
  registerAbortCleanup(() => redis.disconnect());
24
24
  logRedisConnectionState(redis, log);
25
- await waitForDatabase(pools.db, { processName: "runProdApp", deadline: databaseWaitDeadline });
25
+ await waitForDatabase(pools.db, {
26
+ ...databaseWait,
27
+ processName: "runProdApp",
28
+ deadline: databaseWaitDeadline,
29
+ });
26
30
  // One bus per process: the tier resolver, the dispatcher and the file-provider
27
31
  // resolver all subscribe to it (invalidation across the API pods).
28
32
  const cacheSync = createRedisCacheSyncBus({
@@ -10,5 +10,5 @@ export declare function buildProdEntrypointOptions(options: RunProdAppOptions, c
10
10
  export type ProdEntrypointOptions = ReturnType<typeof buildProdEntrypointOptions>;
11
11
  export declare function createProdEntrypoint(options: RunProdAppOptions, config: ProdBootConfig, composed: ProdComposedFeatures, connections: ProdConnections, entrypointOptions: ProdEntrypointOptions): ProdEntrypoint;
12
12
  export declare function registerProdShutdownHooks(entrypoint: ProdEntrypoint, cacheSync: ProdConnections["cacheSync"], ownedRedisSseBroker: ProdRequestContext["ownedRedisSseBroker"]): void;
13
- export declare function runProdWireHook(options: RunProdAppOptions, composed: ProdComposedFeatures, connections: ProdConnections, entrypoint: ProdEntrypoint): Promise<void>;
13
+ export declare function runProdWireHook(options: RunProdAppOptions, config: ProdBootConfig, composed: ProdComposedFeatures, connections: ProdConnections, entrypoint: ProdEntrypoint): Promise<void>;
14
14
  export declare function startProdBackgroundWork(entrypoint: ProdEntrypoint, config: ProdBootConfig, composed: ProdComposedFeatures, connections: ProdConnections): Promise<void>;
@@ -1,9 +1,11 @@
1
1
  import { createAllInOneEntrypoint, createApiEntrypoint, } from "@cosmicdrift/kumiko-framework/entrypoint";
2
+ import { appHookScopedRunner } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
2
3
  import { createMetricsForFactory } from "@cosmicdrift/kumiko-framework/observability";
3
4
  import { jobRunLoggerCallbacks } from "./boot/job-run-logger.js";
4
5
  import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
5
6
  import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
6
7
  import { makeDispatchSystemQuery, makeDispatchSystemWrite } from "./extra-routes-deps.js";
8
+ import { makeFilesForTenant } from "./files-for-tenant.js";
7
9
  import { buildProdAuthRoutes } from "./run-prod-app-auth.js";
8
10
  function collectProdExtraRoutes(options, config, composed) {
9
11
  const bootExtraRoutes = config.bootOptionWiring.extraRoutes;
@@ -26,6 +28,7 @@ export function buildProdEntrypointOptions(options, config, composed, connection
26
28
  registry,
27
29
  ...requestContext.extraContext,
28
30
  },
31
+ rowLevelSecurity: config.rowLevelSecurity,
29
32
  sseBroker: requestContext.sseBroker,
30
33
  jwtSecret: config.jwtSecretOrKeyring,
31
34
  ...(jwtIssuer && { jwtIssuer }),
@@ -118,14 +121,15 @@ export function registerProdShutdownHooks(entrypoint, cacheSync, ownedRedisSseBr
118
121
  // runs. `wire` is the remaining escape-hatch for app-wired co-running
119
122
  // components that need the system-write dispatcher but don't declare a
120
123
  // route.
121
- export async function runProdWireHook(options, composed, connections, entrypoint) {
124
+ export async function runProdWireHook(options, config, composed, connections, entrypoint) {
122
125
  const { wire } = options;
123
126
  if (wire) {
124
127
  const { bootCrypto } = composed;
125
128
  await wire({
126
- db: connections.db,
129
+ db: appHookScopedRunner(connections.db, config.rowLevelSecurity),
127
130
  redis: connections.redis,
128
131
  registry: composed.registry,
132
+ filesForTenant: makeFilesForTenant(entrypoint.context),
129
133
  ...(bootCrypto.masterKeyProvider && { masterKeyProvider: bootCrypto.masterKeyProvider }),
130
134
  ...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
131
135
  metricsFor: createMetricsForFactory(entrypoint.observability.meter),
@@ -1,3 +1,4 @@
1
+ import { assertNoActiveRowLevelSecurityScope } from "@cosmicdrift/kumiko-framework/db";
1
2
  import { flushEscapeHatchAudits } from "@cosmicdrift/kumiko-framework/pipeline";
2
3
  import { installShutdownSignals } from "./boot-options/process-signals.js";
3
4
  import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options.js";
@@ -45,7 +46,8 @@ export function createProdAppHandle(input) {
45
46
  const { options, config, composed, connections, entrypoint, log } = input;
46
47
  const { fetchHandler, cspScriptHashes } = input.fetch;
47
48
  // Upgrade rejections (401/403/426...) are ordinary responses and get the same headers as fetchHandler.
48
- const securedUpgradeFetch = withSecurityHeaders(async (req, env) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, env)), options.securityHeaders, cspScriptHashes);
49
+ // Same read-only demo gate as fetchHandler: upgrade requests are GETs, but the socket then writes.
50
+ const securedUpgradeFetch = withSecurityHeaders(config.bootOptionWiring.wrapFetch(async (req, env) => stripNoRouteMatchHeader(await entrypoint.app.fetch(req, env))), options.securityHeaders, cspScriptHashes);
49
51
  const webSocketUpgradeFetch = async (req, env) => securedUpgradeFetch(req, env);
50
52
  let uninstallShutdownSignals;
51
53
  let uninstallFatalHandlers;
@@ -64,6 +66,7 @@ export function createProdAppHandle(input) {
64
66
  throw new Error("[runProdApp] listen() requires Bun runtime (Bun.serve). " +
65
67
  "Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.");
66
68
  }
69
+ assertNoActiveRowLevelSecurityScope("prod app server");
67
70
  handle.server = Bun.serve(buildBunServeOptions(listenPort, fetchHandler, options.maxRequestBodySize ?? resolveDerivedMaxRequestBodySize(composed.registry), { upgradeFetch: webSocketUpgradeFetch }));
68
71
  // SIGTERM/SIGINT — graceful shutdown. Only registered when we
69
72
  // actually own a Bun-server, otherwise the test process picks up
@@ -2,6 +2,7 @@ import { resolveAnonymousAccessFromRegistry } from "@cosmicdrift/kumiko-bundled-
2
2
  import { createDefaultSseBroker } from "@cosmicdrift/kumiko-framework/api";
3
3
  import { configureBlindIndexKey, configurePiiSubjectKms, } from "@cosmicdrift/kumiko-framework/crypto";
4
4
  import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
5
+ import { appHookScopedRunner } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
5
6
  import { createFallbackLogger, setProcessLogger } from "@cosmicdrift/kumiko-framework/logging";
6
7
  import { createEntityCache, createEventDedup, createIdempotencyGuard, } from "@cosmicdrift/kumiko-framework/pipeline";
7
8
  import { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, } from "./run-prod-app-boot-context.js";
@@ -47,7 +48,13 @@ function buildProdExtraContext(options, config, composed, connections, deps) {
47
48
  ...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
48
49
  };
49
50
  }
50
- async function resolveProdAnonymousAccess(options, deps) {
51
+ async function resolveProdAnonymousAccess(options, requestDeps, config) {
52
+ // extraContext services run inside requests and keep the ambient tenant scope; only this
53
+ // boot-time factory and the registry resolvers get the system scope.
54
+ const deps = {
55
+ ...requestDeps,
56
+ db: appHookScopedRunner(requestDeps.db, config.rowLevelSecurity),
57
+ };
51
58
  const baseAnonymousAccess = typeof options.anonymousAccess === "function"
52
59
  ? options.anonymousAccess(deps)
53
60
  : options.anonymousAccess;
@@ -90,7 +97,7 @@ export async function buildProdRequestContext(options, config, composed, connect
90
97
  ...(bootCrypto.configCipher && { configEncryption: bootCrypto.configCipher }),
91
98
  };
92
99
  const extraContext = buildProdExtraContext(options, config, composed, connections, deps);
93
- const resolvedAnonymousAccess = await resolveProdAnonymousAccess(options, deps);
100
+ const resolvedAnonymousAccess = await resolveProdAnonymousAccess(options, deps, config);
94
101
  return {
95
102
  idempotency,
96
103
  eventDedup,
@@ -1,7 +1,9 @@
1
1
  import { withFileProviderResolver } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { createEsOperationsTable, createSeedMigrationContext, runPendingSeedMigrations, } from "@cosmicdrift/kumiko-framework/es-ops";
3
+ import { runInSystemRowLevelSecurityScope } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
3
4
  import { createDispatcher, createUnlimitedRateLimitResolver, } from "@cosmicdrift/kumiko-framework/pipeline";
4
5
  import { applyStandardBootSeeds } from "./boot/shared-boot-steps.js";
6
+ import { runStreamTenantBackfillOnBoot } from "./boot/stream-tenant-backfill-on-boot.js";
5
7
  import { makeSeedCreateFileRef, withSeedErrorContext, } from "./seed-dispatcher.js";
6
8
  // Seeds are bootstrap writes, not tenant traffic: they always get the unlimited resolver,
7
9
  // even when the app has a real one, so a handler's rateLimit can neither fail nor drain them.
@@ -58,29 +60,42 @@ async function runSeedMigrations(run, seedsDir, seedDispatcher) {
58
60
  // preconditions. Config-seeds rely on a deterministic
59
61
  // aggregate-id so re-boot becomes a version_conflict skip.
60
62
  export async function runProdBootSeeds(run, entrypoint, dispatcherOptions) {
61
- const { options, config, composed, connections } = run;
62
- await applyStandardBootSeeds({
63
- db: connections.db,
64
- registry: composed.registry,
65
- admin: composed.effectiveAuth?.admin,
66
- shortcutSeeds: config.bootOptionWiring.shortcutSeeds,
67
- cipher: composed.bootCrypto.configCipher,
63
+ return runInSystemRowLevelSecurityScope("boot seeds write rows for arbitrary tenants", async () => {
64
+ const { options, config, composed, connections } = run;
65
+ // First: custom seeds update tenants by version and would crash against unmigrated streams.
66
+ await runStreamTenantBackfillOnBoot({
67
+ db: connections.db,
68
+ registry: composed.registry,
69
+ extraContext: run.requestContext.extraContext,
70
+ processName: "runProdApp",
71
+ ...(options.search && { search: options.search }),
72
+ ...(options.databaseWait && { databaseWait: options.databaseWait }),
73
+ });
74
+ await applyStandardBootSeeds({
75
+ db: connections.db,
76
+ registry: composed.registry,
77
+ admin: composed.effectiveAuth?.admin,
78
+ shortcutSeeds: config.bootOptionWiring.shortcutSeeds,
79
+ cipher: composed.bootCrypto.configCipher,
80
+ processName: "runProdApp",
81
+ ...(options.databaseWait && { databaseWait: options.databaseWait }),
82
+ });
83
+ const hasSeeds = config.bootOptionWiring.customSeeds.length > 0 || options.seedsDir !== undefined;
84
+ const seedContext = hasSeeds ? buildSeedContext(run) : undefined;
85
+ const seedDispatcher = seedContext
86
+ ? withSeedErrorContext(createDispatcher(composed.registry, seedContext, {
87
+ ...dispatcherOptions,
88
+ lifecycle: entrypoint.lifecycleHooks,
89
+ ...(entrypoint.mode === "all-in-one" && { jobRunner: entrypoint.jobRunner }),
90
+ }))
91
+ : undefined;
92
+ if (seedDispatcher && seedContext) {
93
+ await runCustomSeeds(run, seedContext, seedDispatcher);
94
+ }
95
+ if (seedDispatcher &&
96
+ options.seedsDir !== undefined &&
97
+ config.envSource["KUMIKO_SKIP_ES_OPS"] !== "1") {
98
+ await runSeedMigrations(run, options.seedsDir, seedDispatcher);
99
+ }
68
100
  });
69
- const hasSeeds = config.bootOptionWiring.customSeeds.length > 0 || options.seedsDir !== undefined;
70
- const seedContext = hasSeeds ? buildSeedContext(run) : undefined;
71
- const seedDispatcher = seedContext
72
- ? withSeedErrorContext(createDispatcher(composed.registry, seedContext, {
73
- ...dispatcherOptions,
74
- lifecycle: entrypoint.lifecycleHooks,
75
- ...(entrypoint.mode === "all-in-one" && { jobRunner: entrypoint.jobRunner }),
76
- }))
77
- : undefined;
78
- if (seedDispatcher && seedContext) {
79
- await runCustomSeeds(run, seedContext, seedDispatcher);
80
- }
81
- if (seedDispatcher &&
82
- options.seedsDir !== undefined &&
83
- config.envSource["KUMIKO_SKIP_ES_OPS"] !== "1") {
84
- await runSeedMigrations(run, options.seedsDir, seedDispatcher);
85
- }
86
101
  }
@@ -1,9 +1,10 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { join, resolve, sep } from "node:path";
2
3
  import { cachedResponse, computeStrongEtag, computeWeakEtag, createClientIpResolver, } from "@cosmicdrift/kumiko-framework/api";
3
4
  import { rankPreferredLocales } from "@cosmicdrift/kumiko-framework/i18n";
4
5
  import { resolveAndInjectPageHead } from "@cosmicdrift/kumiko-renderer/headless/apex";
5
6
  import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
6
- import { ASSETS_DIR } from "./build-prod-bundle.js";
7
+ import { ASSETS_DIR, collectBuildScriptHashes } from "./build-prod-bundle.js";
7
8
  import { BUNDLED_ASSETS_DIST_DIR } from "./bundled-assets.js";
8
9
  import { CSP_HASHES_FILE, parseCspHashes } from "./csp-hashes.js";
9
10
  import { buildRequestBoundSystemQuery, } from "./request-bound-system-query.js";
@@ -67,7 +68,14 @@ function usesDefaultCsp(securityHeaders) {
67
68
  export async function resolveHostDispatchScriptHashes(staticDir, securityHeaders, defaultCspHashes) {
68
69
  return usesDefaultCsp(securityHeaders) ? defaultCspHashes : readBuildScriptHashes(staticDir);
69
70
  }
71
+ // Apps also write HTML into staticDir at boot (rendered landing pages), after kumiko build wrote
72
+ // the file; staticDir is server-owned, so hashing what is on disk never trusts request data.
70
73
  export async function readBuildScriptHashes(staticDir) {
74
+ const fromFile = await readBuildHashesFile(staticDir);
75
+ const onDisk = existsSync(staticDir) ? collectBuildScriptHashes(staticDir).scriptSrc : [];
76
+ return [...new Set([...fromFile, ...onDisk])].sort();
77
+ }
78
+ async function readBuildHashesFile(staticDir) {
71
79
  const file = await readStaticFile(join(staticDir, CSP_HASHES_FILE));
72
80
  const result = file ? parseCspHashes(new TextDecoder().decode(file.bytes)) : undefined;
73
81
  if (result?.kind === "found")
@@ -3,7 +3,8 @@ import type { AccountLockoutOptions, AccountUnlockOptions, AuthMailLocale, AuthP
3
3
  import type { SeedAdminOptions } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
4
4
  import type { ExtraRouteDefinition, KumikoServeEnv, PostAuthLandingResolver, SseBroker } from "@cosmicdrift/kumiko-framework/api";
5
5
  import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
6
- import type { FeatureDefinition, ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { type RowLevelSecurityMode } from "@cosmicdrift/kumiko-framework/db";
7
+ import type { BundledListsOptions, FeatureDefinition, ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
7
8
  import type { AllInOneEntrypoint, ApiEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
8
9
  import type { ComposedEnvSchema, KumikoBootError } from "@cosmicdrift/kumiko-framework/env";
9
10
  import type { ErrorDocsConfig } from "@cosmicdrift/kumiko-framework/errors";
@@ -12,6 +13,8 @@ import type { ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observ
12
13
  import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
13
14
  import type { PageHeadMeta, PageHeadResolver, PageHeadSystemQuery } from "@cosmicdrift/kumiko-renderer/headless/apex";
14
15
  import type { Redis } from "ioredis";
16
+ import type { SearchBackendDeclaration } from "./boot/stream-tenant-backfill-on-boot.ts";
17
+ import type { DatabaseWaitClock } from "./boot/wait-for-database.ts";
15
18
  import type { BillingBootOption } from "./boot-options/billing-option.ts";
16
19
  import type { DemoModeOption } from "./boot-options/demo-mode.ts";
17
20
  import type { FileStorageBootOption } from "./boot-options/file-storage-option.ts";
@@ -29,7 +32,7 @@ export { parseRunMode } from "./boot/env-gate.ts";
29
32
  export { buildBunServeOptions } from "./bun-serve-options.ts";
30
33
  export { requireEnv } from "./require-env.ts";
31
34
  export { resolveUnsubscribeWiring, type UnsubscribeOption, } from "./resolve-unsubscribe-wiring.ts";
32
- export { addConfigAccessorFactory, buildBootExtraContext, type ConfigOverridesInput, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.ts";
35
+ export { addConfigAccessorFactory, buildBootExtraContext, buildConfigOverrides, type ConfigOverridesInput, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.ts";
33
36
  export { staticCachePolicy } from "./run-prod-app-static-files.ts";
34
37
  /** Wrapper API for the password-reset flow.
35
38
  *
@@ -263,6 +266,15 @@ export type RunProdAppOptions = {
263
266
  readonly features: readonly FeatureDefinition[];
264
267
  /** Opt-in boot-validator warnings — see ValidateBootOptions. */
265
268
  readonly validateBootOptions?: ValidateBootOptions;
269
+ /** One table block for every list of the bundled features, see ComposeFeaturesOptions.bundledLists. */
270
+ readonly bundledLists?: BundledListsOptions;
271
+ /** Row level security mode of every connection; pass `appConfig.rowLevelSecurity`. Default "on". */
272
+ readonly rowLevelSecurity?: RowLevelSecurityMode;
273
+ /** `"none"`: the app has no search backend in any process (no `searchAdapter` in `extraContext`),
274
+ * so no search docs can be orphaned and the stream-tenant backfill runs without a reindex. Unset:
275
+ * a process without a search adapter defers the backfill of searchable entities to one that has
276
+ * it. Combining `"none"` with a `searchAdapter` fails the boot. Must match the worker process. */
277
+ readonly search?: SearchBackendDeclaration;
266
278
  /** Listen-Port. Default 3000 (or $PORT). */
267
279
  readonly port?: number;
268
280
  /** Ceiling Bun.serve buffers a request body to before any app-level
@@ -339,16 +351,19 @@ export type RunProdAppOptions = {
339
351
  readonly defaultLocale?: string;
340
352
  readonly extraContext?: ExtraContextOption;
341
353
  /** MasterKeyProvider replacing the env KEK (`KUMIKO_SECRETS_MASTER_KEY_V<n>`)
342
- * for `ctx.secrets`, encrypted config keys and `encrypted: true` entity
354
+ * for `ctx.services.secrets`, encrypted config keys and `encrypted: true` entity
343
355
  * fields. Default: `createEnvMasterKeyProvider`. Use it for KMS backends
344
356
  * (AWS/GCP/Azure); with it set, `KUMIKO_SECRETS_MASTER_KEY_V1` is not
345
357
  * required in the env. */
346
358
  readonly masterKey?: MasterKeyProvider;
347
359
  /** Subject-key adapter for crypto-shredding (GDPR Art. 17). When set, it is
348
- * exposed to feature code as `ctx.kms`, and boot checks its health so the app
360
+ * exposed to feature code as `ctx.services.kms`, and boot checks its health so the app
349
361
  * cannot start against an unreachable KMS. Without an adapter,
350
362
  * crypto-shredding remains disabled. */
351
363
  readonly kms?: KmsAdapter;
364
+ /** Replaces the real clock, sleep and log sink of the boot backoff for the database, KMS and
365
+ * seed waits; tests use it to run the retry path without real waiting. */
366
+ readonly databaseWait?: DatabaseWaitClock;
352
367
  /** 32-byte base64 key for blind-index HMACs on `lookupable` fields
353
368
  * (env: KUMIKO_BLIND_INDEX_KEY, `openssl rand -base64 32`). It is separate
354
369
  * from PLATFORM_KEK and not part of the KmsAdapter contract. It is required
@@ -39,9 +39,11 @@
39
39
  // same JWT_SECRET.
40
40
  // PORT=3000
41
41
  // KUMIKO_INSTANCE_ID=<stable per replica>
42
+ import { assertRowLevelSecurityBootState, knownTablesOf, } from "@cosmicdrift/kumiko-framework/db";
42
43
  import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/event-store";
43
44
  import { runEnvGate } from "./boot/env-gate.js";
44
45
  import { buildEffectiveFeaturesResolver } from "./boot/shared-boot-steps.js";
46
+ import { warnIfRowLevelSecurityOff } from "./boot/warn-row-level-security-off.js";
45
47
  import { installProcessLogger, uninstallProcessLogger } from "./process-logger.js";
46
48
  import { wireProdAuth } from "./run-prod-app-auth.js";
47
49
  import { composeProdFeatures, resolveProdBootConfig, runWithBootAbortCleanups, } from "./run-prod-app-config.js";
@@ -54,7 +56,7 @@ export { parseRunMode } from "./boot/env-gate.js";
54
56
  export { buildBunServeOptions } from "./bun-serve-options.js";
55
57
  export { requireEnv } from "./require-env.js";
56
58
  export { resolveUnsubscribeWiring, } from "./resolve-unsubscribe-wiring.js";
57
- export { addConfigAccessorFactory, buildBootExtraContext, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.js";
59
+ export { addConfigAccessorFactory, buildBootExtraContext, buildConfigOverrides, loggerFromExtraContext, resolveAuthMail, } from "./run-prod-app-boot-context.js";
58
60
  export { staticCachePolicy } from "./run-prod-app-static-files.js";
59
61
  // Returned from runProdApp when KUMIKO_DRY_RUN_ENV is set AND envSource
60
62
  // was passed (= test-mode). The handle is intentionally inert — listen()
@@ -119,7 +121,7 @@ async function bootProdApp(options, registerAbortCleanup) {
119
121
  }
120
122
  return makeDryRunHandle();
121
123
  }
122
- const connections = await connectProdDependencies(config, log, registerAbortCleanup);
124
+ const connections = await connectProdDependencies(config, log, registerAbortCleanup, options.databaseWait);
123
125
  // Tier-composition auto-wire: if a feature carries a tenantTierResolver extension, the
124
126
  // resolver is built here, before the dispatcher exists. The app author only mounts
125
127
  // `createTierEngineFeature(opts)`; the framework does the wiring.
@@ -131,12 +133,14 @@ async function bootProdApp(options, registerAbortCleanup) {
131
133
  cacheSync: connections.cacheSync,
132
134
  });
133
135
  await assertProdSchemaCurrent(connections.db, options.migrations, log);
136
+ await assertRowLevelSecurityBootState(connections.db, config.rowLevelSecurity, knownTablesOf(features));
137
+ warnIfRowLevelSecurityOff(config.rowLevelSecurity, log);
134
138
  const requestContext = await buildProdRequestContext(options, config, composed, connections);
135
139
  const authWiring = await wireProdAuth(config, composed, connections);
136
140
  const entrypointOptions = buildProdEntrypointOptions(options, config, composed, connections, requestContext, authWiring, effectiveFeatures);
137
141
  const entrypoint = createProdEntrypoint(options, config, composed, connections, entrypointOptions);
138
142
  registerProdShutdownHooks(entrypoint, connections.cacheSync, requestContext.ownedRedisSseBroker);
139
- await runProdWireHook(options, composed, connections, entrypoint);
143
+ await runProdWireHook(options, config, composed, connections, entrypoint);
140
144
  await runProdBootSeeds({ options, config, composed, connections, requestContext }, entrypoint, entrypointOptions.dispatcherOptions);
141
145
  await startProdBackgroundWork(entrypoint, config, composed, connections);
142
146
  const fetch = await buildProdFetchHandler(options, config, entrypoint);
@@ -1,19 +1,22 @@
1
1
  import type { ServerOptions } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
3
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
4
- import type { FeatureDefinition, Registry, ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { DbConnection, RowLevelSecurityMode } from "@cosmicdrift/kumiko-framework/db";
4
+ import type { BundledListsOptions, FeatureDefinition, Registry, ValidateBootOptions } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import type { WorkerEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
6
6
  import type { ComposedEnvSchema, KumikoBootError } from "@cosmicdrift/kumiko-framework/env";
7
7
  import type { JobRunnerOptions } from "@cosmicdrift/kumiko-framework/jobs";
8
8
  import { type ObservabilityProvider } from "@cosmicdrift/kumiko-framework/observability";
9
9
  import type { MasterKeyProvider, SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
10
10
  import type { Redis } from "ioredis";
11
+ import { type SearchBackendDeclaration } from "./boot/stream-tenant-backfill-on-boot.ts";
12
+ import type { DatabaseWaitClock } from "./boot/wait-for-database.ts";
11
13
  import { type WorkerMetricsOptions, type WorkerMetricsServer } from "./boot/worker-metrics-server.ts";
12
14
  import type { BillingBootOption } from "./boot-options/billing-option.ts";
13
15
  import type { FileStorageBootOption } from "./boot-options/file-storage-option.ts";
14
16
  import type { KmsMode } from "./boot-options/kms-mode.ts";
15
17
  import type { MailBootOption } from "./boot-options/mail-option.ts";
16
18
  import { type SystemWireDeps } from "./extra-routes-deps.ts";
19
+ import type { ConfigOverridesInput } from "./run-prod-app-boot-context.ts";
17
20
  export type WorkerContextOption = Record<string, unknown> | ((deps: WorkerDeps) => Record<string, unknown>);
18
21
  export type WorkerDeps = {
19
22
  readonly db: DbConnection;
@@ -27,18 +30,27 @@ export type WorkerDeps = {
27
30
  * `lifecycle` for `registerShutdownHook`. */
28
31
  export type WorkerWireDeps = SystemWireDeps & {
29
32
  readonly lifecycle: WorkerEntrypoint["lifecycle"];
30
- /** The worker's ctx.secrets — what `createInboundMailSupervisor` needs as
33
+ /** The worker's ctx.services.secrets — what `createInboundMailSupervisor` needs as
31
34
  * `providerCtx.secrets` (the IMAP provider reads credentials through it,
32
35
  * with `auditUserId` set to a system identity for the audit trail). Undefined
33
36
  * when the secrets feature is not mounted or no master key is available. */
34
37
  readonly secrets?: SecretsContext;
35
38
  };
36
39
  export type RunWorkerAppOptions = {
40
+ /** Row level security mode of every connection; pass appConfig.rowLevelSecurity. Default "on". */
41
+ readonly rowLevelSecurity?: RowLevelSecurityMode;
42
+ /** `"none"`: the app has no search backend in any process (no `searchAdapter` in `extraContext`),
43
+ * so no search docs can be orphaned and the stream-tenant backfill runs without a reindex. Unset:
44
+ * a process without a search adapter defers the backfill of searchable entities to one that has
45
+ * it. Combining `"none"` with a `searchAdapter` fails the boot. Must match the API process. */
46
+ readonly search?: SearchBackendDeclaration;
37
47
  /** App-specific features — same array as in the API/all-in-one process,
38
48
  * so the registry + schema stay identical across processes. */
39
49
  readonly features: readonly FeatureDefinition[];
40
50
  /** Opt-in boot-validator warnings — see ValidateBootOptions. */
41
51
  readonly validateBootOptions?: ValidateBootOptions;
52
+ /** One table block for every list of the bundled features, see ComposeFeaturesOptions.bundledLists. Must match the API process. */
53
+ readonly bundledLists?: BundledListsOptions;
42
54
  /** Mount the auto-mixed config/user/tenant/auth-email-password features —
43
55
  * MUST match the API process's `includeBundled` value, otherwise API
44
56
  * and worker run with a diverging registry topology. Also controls
@@ -54,7 +66,7 @@ export type RunWorkerAppOptions = {
54
66
  * RunProdAppOptions["extraContext"], without sseBroker (the worker
55
67
  * has none — see entrypoint/index.ts's documented SSE limitation). */
56
68
  readonly extraContext?: WorkerContextOption;
57
- /** MasterKeyProvider replacing the env KEK for ctx.secrets, encrypted
69
+ /** MasterKeyProvider replacing the env KEK for ctx.services.secrets, encrypted
58
70
  * config keys and `encrypted: true` entity fields; with it set,
59
71
  * `KUMIKO_SECRETS_MASTER_KEY_V1` is not required (see
60
72
  * RunProdAppOptions["masterKey"]). */
@@ -62,6 +74,12 @@ export type RunWorkerAppOptions = {
62
74
  /** Subject-key adapter for crypto-shredding — boot checks health()
63
75
  * before any connection (see RunProdAppOptions["kms"]). */
64
76
  readonly kms?: KmsAdapter;
77
+ /** App-level config values, same semantics and merge as RunProdAppOptions["appOverrides"];
78
+ * the worker renders the mail layouts and must resolve the same branding as the API. */
79
+ readonly appOverrides?: ConfigOverridesInput;
80
+ /** Replaces the real clock, sleep and log sink of the boot backoff for the database and
81
+ * KMS waits; tests use it to run the retry path without real waiting. */
82
+ readonly databaseWait?: DatabaseWaitClock;
65
83
  /** Blind-index key for lookupable fields (see
66
84
  * RunProdAppOptions["blindIndexKey"]). */
67
85
  readonly blindIndexKey?: string;
@@ -15,18 +15,21 @@
15
15
  // Container/Coolify sets the same env vars as runProdApp:
16
16
  // DATABASE_URL, REDIS_URL, JWT_SECRET — PORT is not needed.
17
17
  import { assertSeedModeNotInProduction } from "@cosmicdrift/kumiko-framework/event-store";
18
+ import { appHookScopedRunner, DEFAULT_ROW_LEVEL_SECURITY_MODE, } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
18
19
  import { scrubLogText } from "@cosmicdrift/kumiko-framework/logging";
19
20
  import { createMetricsForFactory, } from "@cosmicdrift/kumiko-framework/observability";
20
21
  import { logRedisConnectionState } from "@cosmicdrift/kumiko-framework/redis";
21
22
  import { runEnvGate } from "./boot/env-gate.js";
22
23
  import { startPiiEventBackfillOnBoot } from "./boot/pii-event-backfill-on-boot.js";
23
24
  import { recordAppStartedOnBoot } from "./boot/record-app-started-on-boot.js";
25
+ import { runStreamTenantBackfillOnBoot, } from "./boot/stream-tenant-backfill-on-boot.js";
24
26
  import { bootWorkerProcess, resolveWorkerEnvSource, } from "./boot/worker-boot-core.js";
25
27
  import { startWorkerMetricsServer, } from "./boot/worker-metrics-server.js";
26
28
  import { resolveBootOptionWiring } from "./boot-options/resolve-boot-options.js";
27
29
  import { assertStackBootRequirements } from "./boot-options/stack-boot-requirements.js";
28
30
  import { makeDispatchSystemQuery, makeDispatchSystemWrite, } from "./extra-routes-deps.js";
29
31
  import { installFatalErrorHandlers } from "./fatal-error-handlers.js";
32
+ import { makeFilesForTenant } from "./files-for-tenant.js";
30
33
  import { installProcessLogger, uninstallProcessLogger } from "./process-logger.js";
31
34
  function makeBootModeHandle() {
32
35
  return {
@@ -48,6 +51,22 @@ async function resolveGatedEnvSource(options) {
48
51
  function assertWorkerStackRequirements(options) {
49
52
  assertStackBootRequirements({ features: options.features, ...(options.featureToggles && { featureToggles: true }) }, ["featureToggles"]);
50
53
  }
54
+ function wireHookDb(db, options) {
55
+ return appHookScopedRunner(db, options.rowLevelSecurity ?? DEFAULT_ROW_LEVEL_SECURITY_MODE);
56
+ }
57
+ // Runs before the metrics port opens and before start() consumes jobs: a worker must not process
58
+ // events of a feature whose streams still sit on the wrong tenant.
59
+ async function backfillThenStartMetrics(boot, options) {
60
+ await runStreamTenantBackfillOnBoot({
61
+ db: boot.db,
62
+ registry: boot.registry,
63
+ extraContext: boot.extraContext,
64
+ processName: "runWorkerApp",
65
+ ...(options.search && { search: options.search }),
66
+ ...(options.databaseWait && { databaseWait: options.databaseWait }),
67
+ });
68
+ return options.metrics ? startWorkerMetricsServer(options.metrics, boot.entrypoint) : undefined;
69
+ }
51
70
  export async function runWorkerApp(options) {
52
71
  assertSeedModeNotInProduction();
53
72
  const resolvedEnvSource = await resolveGatedEnvSource(options);
@@ -91,9 +110,7 @@ export async function runWorkerApp(options) {
91
110
  // hook, so a failing start() does not leak the listener.
92
111
  let metricsServer;
93
112
  try {
94
- metricsServer = options.metrics
95
- ? startWorkerMetricsServer(options.metrics, entrypoint)
96
- : undefined;
113
+ metricsServer = await backfillThenStartMetrics(boot, options);
97
114
  await entrypoint.start();
98
115
  }
99
116
  catch (error) {
@@ -124,9 +141,10 @@ export async function runWorkerApp(options) {
124
141
  });
125
142
  if (options.wireComponents) {
126
143
  await options.wireComponents({
127
- db,
144
+ db: wireHookDb(db, options),
128
145
  redis,
129
146
  registry,
147
+ filesForTenant: makeFilesForTenant(entrypoint.context),
130
148
  metricsFor: createMetricsForFactory(entrypoint.observability.meter),
131
149
  dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
132
150
  dispatchSystemQuery: makeDispatchSystemQuery(entrypoint.dispatcher),
@@ -7,7 +7,9 @@ export function makeSeedCreateFileRef(deps) {
7
7
  const fileResolver = context._fileProviderResolver;
8
8
  return (tenantId, input) => createFileRefFromBytes({
9
9
  db: createTenantDb({ db, tenantId }),
10
- files: fileResolver ? createTenantFileContext(fileResolver, tenantId) : context.files,
10
+ services: {
11
+ files: fileResolver ? createTenantFileContext(fileResolver, tenantId) : context.files,
12
+ },
11
13
  registry,
12
14
  user: createSystemUser(tenantId),
13
15
  }, input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-server-runtime",
3
- "version": "0.388.0",
3
+ "version": "0.390.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 tooling.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -70,6 +70,10 @@
70
70
  "types": "./dist/renderer-web-fonts.d.ts",
71
71
  "default": "./dist/renderer-web-fonts.js"
72
72
  },
73
+ "./resolve-installed-package": {
74
+ "types": "./dist/resolve-installed-package.d.ts",
75
+ "default": "./dist/resolve-installed-package.js"
76
+ },
73
77
  "./resolve-tailwind-cli": {
74
78
  "types": "./dist/resolve-tailwind-cli.d.ts",
75
79
  "default": "./dist/resolve-tailwind-cli.js"
@@ -92,18 +96,21 @@
92
96
  }
93
97
  },
94
98
  "dependencies": {
95
- "@cosmicdrift/kumiko-bundled-features": "0.388.0",
96
- "@cosmicdrift/kumiko-framework": "0.388.0",
97
- "@cosmicdrift/kumiko-renderer": "0.388.0",
98
- "@cosmicdrift/kumiko-renderer-mail-html": "0.388.0",
99
- "@cosmicdrift/kumiko-types": "0.388.0",
100
- "bun-types": "^1.3.13",
99
+ "@cosmicdrift/kumiko-bundled-features": "0.390.0",
100
+ "@cosmicdrift/kumiko-framework": "0.390.0",
101
+ "@cosmicdrift/kumiko-renderer": "0.390.0",
102
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.390.0",
103
+ "@cosmicdrift/kumiko-types": "0.390.0",
101
104
  "ioredis": "^5.10.1",
102
105
  "temporal-polyfill": "^0.3.2",
103
106
  "zod": "^4.4.3"
104
107
  },
108
+ "peerDependencies": {
109
+ "bun-types": "^1.4.4"
110
+ },
105
111
  "devDependencies": {
106
- "@cosmicdrift/kumiko-testing": "0.388.0"
112
+ "@cosmicdrift/kumiko-testing": "0.390.0",
113
+ "bun-types": "^1.4.4"
107
114
  },
108
115
  "publishConfig": {
109
116
  "registry": "https://registry.npmjs.org",
@@ -161,6 +168,10 @@
161
168
  "types": "./dist/renderer-web-fonts.d.ts",
162
169
  "default": "./dist/renderer-web-fonts.js"
163
170
  },
171
+ "./resolve-installed-package": {
172
+ "types": "./dist/resolve-installed-package.d.ts",
173
+ "default": "./dist/resolve-installed-package.js"
174
+ },
164
175
  "./resolve-tailwind-cli": {
165
176
  "types": "./dist/resolve-tailwind-cli.d.ts",
166
177
  "default": "./dist/resolve-tailwind-cli.js"